feat(cli): add reusable doctor checks #14
3 changed files with 558 additions and 1 deletions
50
README.md
50
README.md
|
|
@ -19,7 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework
|
|||
|
||||
## Packages
|
||||
|
||||
- `cli` : helpers pour résoudre un profil, valider une URL et demander des valeurs à l'utilisateur.
|
||||
- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`.
|
||||
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
||||
- `manifest` : lecture de `mcp.toml` à la racine du projet et conversion vers `update.ReleaseSource`.
|
||||
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
|
||||
|
|
@ -34,6 +34,7 @@ Le flux typique côté application est :
|
|||
3. Lire les secrets avec `secretstore`.
|
||||
4. Charger `mcp.toml` avec `manifest`.
|
||||
5. Exécuter l'auto-update avec `update` si nécessaire.
|
||||
6. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier.
|
||||
|
||||
## Manifeste `mcp.toml`
|
||||
|
||||
|
|
@ -235,6 +236,53 @@ if err != nil {
|
|||
}
|
||||
```
|
||||
|
||||
Le package fournit aussi un socle réutilisable pour une commande `doctor`.
|
||||
L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.),
|
||||
mais peut réutiliser les checks communs et ajouter ses propres hooks :
|
||||
|
||||
```go
|
||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||
ConfigCheck: cli.NewConfigCheck(store),
|
||||
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
}),
|
||||
RequiredSecrets: []cli.DoctorSecret{
|
||||
{Name: "api-token", Label: "API token"},
|
||||
},
|
||||
SecretStoreFactory: func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
},
|
||||
ManifestDir: ".",
|
||||
ConnectivityCheck: func(context.Context) cli.DoctorResult {
|
||||
if err := pingBackend(); err != nil {
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusFail,
|
||||
Summary: "backend is unreachable",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
return cli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: cli.DoctorStatusOK,
|
||||
Summary: "backend is reachable",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if err := cli.RenderDoctorReport(os.Stdout, report); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if report.HasFailures() {
|
||||
os.Exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Update
|
||||
|
||||
Le package `update` ne déduit pas la forge ni l'authentification.
|
||||
|
|
|
|||
336
cli/doctor.go
Normal file
336
cli/doctor.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type DoctorStatus string
|
||||
|
||||
const (
|
||||
DoctorStatusOK DoctorStatus = "ok"
|
||||
DoctorStatusWarn DoctorStatus = "warn"
|
||||
DoctorStatusFail DoctorStatus = "fail"
|
||||
)
|
||||
|
||||
type DoctorResult struct {
|
||||
Name string
|
||||
Status DoctorStatus
|
||||
Summary string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type DoctorCheck func(context.Context) DoctorResult
|
||||
|
||||
type DoctorReport struct {
|
||||
Results []DoctorResult
|
||||
}
|
||||
|
||||
type DoctorSummary struct {
|
||||
OK int
|
||||
Warn int
|
||||
Fail int
|
||||
Total int
|
||||
}
|
||||
|
||||
type DoctorSecret struct {
|
||||
Name string
|
||||
Label string
|
||||
}
|
||||
|
||||
type DoctorManifestValidator func(manifest.File, string) []string
|
||||
|
||||
type DoctorOptions struct {
|
||||
ConfigCheck DoctorCheck
|
||||
SecretStoreCheck DoctorCheck
|
||||
RequiredSecrets []DoctorSecret
|
||||
SecretStoreFactory func() (secretstore.Store, error)
|
||||
ManifestDir string
|
||||
ManifestValidator DoctorManifestValidator
|
||||
ConnectivityCheck DoctorCheck
|
||||
ExtraChecks []DoctorCheck
|
||||
}
|
||||
|
||||
func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
||||
checks := make([]DoctorCheck, 0, 5+len(options.ExtraChecks))
|
||||
|
||||
if options.ConfigCheck != nil {
|
||||
checks = append(checks, options.ConfigCheck)
|
||||
}
|
||||
if options.SecretStoreCheck != nil {
|
||||
checks = append(checks, options.SecretStoreCheck)
|
||||
}
|
||||
if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil {
|
||||
checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets))
|
||||
}
|
||||
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
||||
if options.ConnectivityCheck != nil {
|
||||
checks = append(checks, options.ConnectivityCheck)
|
||||
}
|
||||
checks = append(checks, options.ExtraChecks...)
|
||||
|
||||
results := make([]DoctorResult, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
if check == nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, normalizeDoctorResult(check(ctx)))
|
||||
}
|
||||
|
||||
return DoctorReport{Results: results}
|
||||
}
|
||||
|
||||
func (r DoctorReport) Summary() DoctorSummary {
|
||||
summary := DoctorSummary{Total: len(r.Results)}
|
||||
for _, result := range r.Results {
|
||||
switch result.Status {
|
||||
case DoctorStatusOK:
|
||||
summary.OK++
|
||||
case DoctorStatusWarn:
|
||||
summary.Warn++
|
||||
case DoctorStatusFail:
|
||||
summary.Fail++
|
||||
default:
|
||||
summary.Fail++
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (r DoctorReport) HasFailures() bool {
|
||||
return r.Summary().Fail > 0
|
||||
}
|
||||
|
||||
func RenderDoctorReport(w io.Writer, report DoctorReport) error {
|
||||
for _, result := range report.Results {
|
||||
if _, err := fmt.Fprintf(w, "[%s] %s: %s\n", doctorLabel(result.Status), result.Name, result.Summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail := strings.TrimSpace(result.Detail); detail != "" {
|
||||
if _, err := fmt.Fprintf(w, " %s\n", detail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary := report.Summary()
|
||||
_, err := fmt.Fprintf(
|
||||
w,
|
||||
"Summary: %d ok, %d warning(s), %d failure(s), %d total\n",
|
||||
summary.OK,
|
||||
summary.Warn,
|
||||
summary.Fail,
|
||||
summary.Total,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func NewConfigCheck[T any](store config.Store[T]) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
path, err := store.ConfigPath()
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "cannot resolve config path",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
switch {
|
||||
case err == nil && info.IsDir():
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "config path is a directory",
|
||||
Detail: path,
|
||||
}
|
||||
case err == nil:
|
||||
if _, err := store.Load(path); err != nil {
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "config file is unreadable or invalid",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "config file is readable",
|
||||
Detail: path,
|
||||
}
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusWarn,
|
||||
Summary: "config file is missing",
|
||||
Detail: path,
|
||||
}
|
||||
default:
|
||||
return DoctorResult{
|
||||
Name: "config",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "cannot access config file",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
if factory == nil {
|
||||
return DoctorResult{
|
||||
Name: "secret-store",
|
||||
Status: DoctorStatusWarn,
|
||||
Summary: "secret store check is not configured",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := factory()
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "secret-store",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "secret backend is unavailable",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "secret-store",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "secret backend is available",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
store, err := factory()
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "required-secrets",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "cannot inspect required secrets",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, secret := range required {
|
||||
name := strings.TrimSpace(secret.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := store.GetSecret(name)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, secretstore.ErrNotFound):
|
||||
if label := strings.TrimSpace(secret.Label); label != "" {
|
||||
missing = append(missing, fmt.Sprintf("%s (%s)", name, label))
|
||||
} else {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
default:
|
||||
return DoctorResult{
|
||||
Name: "required-secrets",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: fmt.Sprintf("cannot read secret %q", name),
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return DoctorResult{
|
||||
Name: "required-secrets",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "required secrets are missing",
|
||||
Detail: strings.Join(missing, ", "),
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "required-secrets",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "all required secrets are present",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
file, path, err := manifest.LoadDefault(startDir)
|
||||
if err != nil {
|
||||
return DoctorResult{
|
||||
Name: "manifest",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "manifest is missing or invalid",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
if validator != nil {
|
||||
issues := validator(file, path)
|
||||
if len(issues) > 0 {
|
||||
return DoctorResult{
|
||||
Name: "manifest",
|
||||
Status: DoctorStatusFail,
|
||||
Summary: "manifest validation failed",
|
||||
Detail: strings.Join(issues, "; "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorResult{
|
||||
Name: "manifest",
|
||||
Status: DoctorStatusOK,
|
||||
Summary: "manifest is valid",
|
||||
Detail: path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDoctorResult(result DoctorResult) DoctorResult {
|
||||
result.Name = strings.TrimSpace(result.Name)
|
||||
if result.Name == "" {
|
||||
result.Name = "unnamed-check"
|
||||
}
|
||||
|
||||
result.Summary = strings.TrimSpace(result.Summary)
|
||||
if result.Summary == "" {
|
||||
result.Summary = "no details provided"
|
||||
}
|
||||
|
||||
result.Detail = strings.TrimSpace(result.Detail)
|
||||
switch result.Status {
|
||||
case DoctorStatusOK, DoctorStatusWarn, DoctorStatusFail:
|
||||
default:
|
||||
result.Status = DoctorStatusFail
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func doctorLabel(status DoctorStatus) string {
|
||||
switch status {
|
||||
case DoctorStatusOK:
|
||||
return "OK"
|
||||
case DoctorStatusWarn:
|
||||
return "WARN"
|
||||
default:
|
||||
return "FAIL"
|
||||
}
|
||||
}
|
||||
173
cli/doctor_test.go
Normal file
173
cli/doctor_test.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type doctorProfile struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
func TestRunDoctorAggregatesCommonChecksAndExtras(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempHome)
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
store := config.NewStore[doctorProfile]("doctor-test")
|
||||
path, err := store.ConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigPath returned error: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.FileConfig[doctorProfile]{
|
||||
Version: config.CurrentVersion,
|
||||
CurrentProfile: "default",
|
||||
Profiles: map[string]doctorProfile{
|
||||
"default": {BaseURL: "https://api.example.com"},
|
||||
},
|
||||
}
|
||||
if err := store.Save(path, cfg); err != nil {
|
||||
t.Fatalf("Save returned error: %v", err)
|
||||
}
|
||||
|
||||
manifestDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
secretFactory := func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == "api-token" {
|
||||
return "secret", true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
ConfigCheck: NewConfigCheck(store),
|
||||
SecretStoreCheck: SecretStoreAvailabilityCheck(secretFactory),
|
||||
RequiredSecrets: []DoctorSecret{
|
||||
{Name: "api-token", Label: "API token"},
|
||||
},
|
||||
SecretStoreFactory: secretFactory,
|
||||
ManifestDir: manifestDir,
|
||||
ConnectivityCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "service is reachable"}
|
||||
},
|
||||
ExtraChecks: []DoctorCheck{
|
||||
func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "custom", Status: DoctorStatusWarn, Summary: "non blocking drift"}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
summary := report.Summary()
|
||||
if summary.OK != 5 || summary.Warn != 1 || summary.Fail != 0 || summary.Total != 6 {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConfigCheckWarnsWhenConfigIsMissing(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tempHome)
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
store := config.NewStore[doctorProfile]("doctor-test")
|
||||
result := NewConfigCheck(store)(context.Background())
|
||||
|
||||
if result.Status != DoctorStatusWarn {
|
||||
t.Fatalf("status = %q, want warn", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "config.json") {
|
||||
t.Fatalf("detail = %q, want config path", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredSecretsCheckFailsWhenSecretIsMissing(t *testing.T) {
|
||||
check := RequiredSecretsCheck(func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
return "", false
|
||||
},
|
||||
})
|
||||
}, []DoctorSecret{{Name: "API_TOKEN", Label: "API token"}})
|
||||
|
||||
result := check(context.Background())
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "API_TOKEN") {
|
||||
t.Fatalf("detail = %q, want secret name", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestCheckUsesValidator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile manifest: %v", err)
|
||||
}
|
||||
|
||||
result := ManifestCheck(dir, func(_ manifest.File, path string) []string {
|
||||
_ = path
|
||||
return []string{"missing business section"}
|
||||
})(context.Background())
|
||||
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
report := DoctorReport{
|
||||
Results: []DoctorResult{
|
||||
{Name: "config", Status: DoctorStatusOK, Summary: "config file is readable", Detail: "/tmp/config.json"},
|
||||
{Name: "custom", Status: DoctorStatusWarn, Summary: "optional check skipped"},
|
||||
{Name: "manifest", Status: DoctorStatusFail, Summary: "manifest is invalid", Detail: "parse error"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := RenderDoctorReport(&out, report); err != nil {
|
||||
t.Fatalf("RenderDoctorReport returned error: %v", err)
|
||||
}
|
||||
|
||||
text := out.String()
|
||||
for _, needle := range []string{
|
||||
"[OK] config: config file is readable",
|
||||
"[WARN] custom: optional check skipped",
|
||||
"[FAIL] manifest: manifest is invalid",
|
||||
"Summary: 1 ok, 1 warning(s), 1 failure(s), 3 total",
|
||||
} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Fatalf("output = %q, want substring %q", text, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) {
|
||||
result := SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
|
||||
return nil, errors.New("backend missing")
|
||||
})(context.Background())
|
||||
|
||||
if result.Status != DoctorStatusFail {
|
||||
t.Fatalf("status = %q, want fail", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "backend missing") {
|
||||
t.Fatalf("detail = %q", result.Detail)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue