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
|
## Auto-Update
|
||||||
|
|
||||||
Le package `update` supporte les drivers `gitea`, `gitlab` et `github`.
|
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 {
|
func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck {
|
||||||
return func(context.Context) DoctorResult {
|
return func(context.Context) DoctorResult {
|
||||||
file, path, err := manifest.LoadDefault(startDir)
|
file, path, err := manifest.LoadDefault(startDir)
|
||||||
|
|
@ -334,3 +410,41 @@ func doctorLabel(status DoctorStatus) string {
|
||||||
return "FAIL"
|
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)
|
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