From 3d8a7dc84dc5a0bbda46883a4d1a8a0a6f6a400e Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:53:26 +0200 Subject: [PATCH] feat(cli): add reusable doctor check for resolved profile fields --- README.md | 30 ++++++++++++ cli/doctor.go | 114 +++++++++++++++++++++++++++++++++++++++++++++ cli/doctor_test.go | 112 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) diff --git a/README.md b/README.md index 942c8cd..a713318 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,36 @@ if report.HasFailures() { } ``` +Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi +un helper basé sur `FieldSpec` : + +```go +report := cli.RunDoctor(context.Background(), cli.DoctorOptions{ + ExtraChecks: []cli.DoctorCheck{ + cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"}, + { + Name: "api_token", + Required: true, + EnvKey: "MY_MCP_API_TOKEN", + SecretKey: "my-mcp-api-token", + Sources: []cli.ValueSource{cli.SourceEnv, cli.SourceSecret}, + }, + }, + Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{ + Env: cli.EnvLookup(os.LookupEnv), + Config: cli.ConfigMap(configValues), + Secret: cli.SecretStore(secretStore), // provenance explicite: env ou secret + }), + }), + }, +}) +``` + +En cas d'échec de résolution, tu peux aussi réutiliser le formatteur +`cli.FormatResolveFieldsError(err)` dans un check custom pour garder des messages homogènes. + ## Auto-Update Le package `update` supporte les drivers `gitea`, `gitlab` et `github`. diff --git a/cli/doctor.go b/cli/doctor.go index ae2cdd9..8dfca94 100644 --- a/cli/doctor.go +++ b/cli/doctor.go @@ -270,6 +270,82 @@ func RequiredSecretsCheck(factory func() (secretstore.Store, error), required [] } } +func RequiredResolvedFieldsCheck(options ResolveOptions) DoctorCheck { + required := requiredFieldNames(options.Fields) + + return func(context.Context) DoctorResult { + if len(required) == 0 { + return DoctorResult{ + Name: "required-profile-fields", + Status: DoctorStatusWarn, + Summary: "no required profile field is configured", + } + } + + resolution, err := ResolveFields(options) + if err != nil { + return DoctorResult{ + Name: "required-profile-fields", + Status: DoctorStatusFail, + Summary: resolveFieldsErrorSummary(err), + Detail: FormatResolveFieldsError(err), + } + } + + sources := make([]string, 0, len(required)) + for _, name := range required { + field, ok := resolution.Get(name) + if !ok || !field.Found { + return DoctorResult{ + Name: "required-profile-fields", + Status: DoctorStatusFail, + Summary: "required profile values are missing", + Detail: name, + } + } + sources = append(sources, fmt.Sprintf("%s=%s", name, field.Source)) + } + + return DoctorResult{ + Name: "required-profile-fields", + Status: DoctorStatusOK, + Summary: "required profile values are resolved", + Detail: strings.Join(sources, ", "), + } + } +} + +func FormatResolveFieldsError(err error) string { + if err == nil { + return "" + } + + var missing *MissingRequiredValuesError + if errors.As(err, &missing) { + if len(missing.Fields) == 0 { + return "missing required configuration values" + } + return strings.Join(missing.Fields, ", ") + } + + var lookupErr *SourceLookupError + if errors.As(err, &lookupErr) { + key := strings.TrimSpace(lookupErr.Key) + if key == "" { + key = lookupErr.Field + } + return fmt.Sprintf( + "field %q via source %q (key %q): %v", + lookupErr.Field, + lookupErr.Source, + key, + lookupErr.Err, + ) + } + + return err.Error() +} + func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck { return func(context.Context) DoctorResult { file, path, err := manifest.LoadDefault(startDir) @@ -334,3 +410,41 @@ func doctorLabel(status DoctorStatus) string { return "FAIL" } } + +func requiredFieldNames(specs []FieldSpec) []string { + required := make([]string, 0, len(specs)) + seen := make(map[string]struct{}, len(specs)) + + for _, spec := range specs { + if !spec.Required { + continue + } + + name := strings.TrimSpace(spec.Name) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + required = append(required, name) + } + + return required +} + +func resolveFieldsErrorSummary(err error) string { + var missing *MissingRequiredValuesError + if errors.As(err, &missing) { + return "required profile values are missing" + } + + var lookupErr *SourceLookupError + if errors.As(err, &lookupErr) { + return fmt.Sprintf("cannot resolve profile value %q", lookupErr.Field) + } + + return "profile resolution failed" +} diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 61ac464..04dc23f 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -171,3 +171,115 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { t.Fatalf("detail = %q", result.Detail) } } + +func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) { + check := RequiredResolvedFieldsCheck(ResolveOptions{ + Fields: []FieldSpec{ + {Name: "base_url", Required: true, ConfigKey: "base_url"}, + { + Name: "api_token", + Required: true, + EnvKey: "MY_API_TOKEN", + SecretKey: "my-api-token", + Sources: []ValueSource{SourceEnv, SourceSecret}, + }, + }, + Lookup: ResolveLookup(ResolveLookupOptions{ + Env: MapLookup(map[string]string{"MY_API_TOKEN": "env-token"}), + Config: ConfigMap(map[string]string{"base_url": "https://api.example.com"}), + Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}), + }), + }) + + result := check(context.Background()) + if result.Status != DoctorStatusOK { + t.Fatalf("status = %q, want ok", result.Status) + } + for _, needle := range []string{"base_url=config", "api_token=env"} { + if !strings.Contains(result.Detail, needle) { + t.Fatalf("detail = %q, want substring %q", result.Detail, needle) + } + } +} + +func TestRequiredResolvedFieldsCheckUsesSecretAsFallback(t *testing.T) { + check := RequiredResolvedFieldsCheck(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "api_token", + Required: true, + EnvKey: "MY_API_TOKEN", + SecretKey: "my-api-token", + Sources: []ValueSource{SourceEnv, SourceSecret}, + }, + }, + Lookup: ResolveLookup(ResolveLookupOptions{ + Env: MapLookup(map[string]string{}), + Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}), + }), + }) + + result := check(context.Background()) + if result.Status != DoctorStatusOK { + t.Fatalf("status = %q, want ok", result.Status) + } + if !strings.Contains(result.Detail, "api_token=secret") { + t.Fatalf("detail = %q, want secret provenance", result.Detail) + } +} + +func TestRequiredResolvedFieldsCheckFailsWithMissingRequiredField(t *testing.T) { + check := RequiredResolvedFieldsCheck(ResolveOptions{ + Fields: []FieldSpec{ + {Name: "base_url", Required: true}, + }, + Lookup: ResolveLookup(ResolveLookupOptions{ + Env: MapLookup(map[string]string{}), + }), + }) + + result := check(context.Background()) + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if result.Summary != "required profile values are missing" { + t.Fatalf("summary = %q", result.Summary) + } + if result.Detail != "base_url" { + t.Fatalf("detail = %q, want base_url", result.Detail) + } +} + +func TestRequiredResolvedFieldsCheckFormatsLookupErrors(t *testing.T) { + lookupErr := errors.New("vault unavailable") + check := RequiredResolvedFieldsCheck(ResolveOptions{ + Fields: []FieldSpec{ + {Name: "api_token", Required: true, Sources: []ValueSource{SourceSecret}}, + }, + Lookup: func(source ValueSource, key string) (string, bool, error) { + if source == SourceSecret { + return "", false, lookupErr + } + return "", false, nil + }, + }) + + result := check(context.Background()) + if result.Status != DoctorStatusFail { + t.Fatalf("status = %q, want fail", result.Status) + } + if result.Summary != "cannot resolve profile value \"api_token\"" { + t.Fatalf("summary = %q", result.Summary) + } + for _, needle := range []string{"api_token", "source \"secret\"", "key \"api_token\"", "vault unavailable"} { + if !strings.Contains(result.Detail, needle) { + t.Fatalf("detail = %q, want substring %q", result.Detail, needle) + } + } +} + +func TestFormatResolveFieldsErrorWithNilError(t *testing.T) { + if got := FormatResolveFieldsError(nil); got != "" { + t.Fatalf("FormatResolveFieldsError(nil) = %q, want empty string", got) + } +}