feat(cli): add reusable doctor check for resolved profile fields
This commit is contained in:
parent
ea1768982e
commit
3d8a7dc84d
3 changed files with 256 additions and 0 deletions
30
README.md
30
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`.
|
||||
|
|
|
|||
114
cli/doctor.go
114
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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue