feat(cli): add reusable doctor check for resolved profile fields

This commit is contained in:
thibaud-lclr 2026-04-14 16:53:26 +02:00
parent ea1768982e
commit 3d8a7dc84d
3 changed files with 256 additions and 0 deletions

View file

@ -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`.

View file

@ -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"
}

View file

@ -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)
}
}