diff --git a/README.md b/README.md index 3ffcb74..51c79cd 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,55 @@ if err != nil { } ``` +Pour standardiser la résolution `flag > env > config > secret` avec provenance : + +```go +lookup := func(source cli.ValueSource, key string) (string, bool, error) { + switch source { + case cli.SourceFlag: + value, ok := flagValues[key] + return value, ok, nil + case cli.SourceEnv: + value, ok := os.LookupEnv(key) + return value, ok, nil + case cli.SourceConfig: + value, ok := configValues[key] + return value, ok, nil + case cli.SourceSecret: + value, err := store.GetSecret(key) + switch { + case err == nil: + return value, true, nil + case errors.Is(err, secretstore.ErrNotFound): + return "", false, nil + default: + return "", false, err + } + default: + return "", false, nil + } +} + +resolution, err := cli.ResolveFields(cli.ResolveOptions{ + Fields: []cli.FieldSpec{ + {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, + {Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"}, + {Name: "timeout", DefaultValue: "30s"}, + }, + Lookup: lookup, +}) +if err != nil { + return err +} + +if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil { + return err +} +``` + +`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et +`FieldSpec.Sources` permet de définir un ordre spécifique pour un champ. + 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 : diff --git a/cli/resolve.go b/cli/resolve.go new file mode 100644 index 0000000..725571b --- /dev/null +++ b/cli/resolve.go @@ -0,0 +1,283 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "slices" + "strings" +) + +type ValueSource string + +const ( + SourceFlag ValueSource = "flag" + SourceEnv ValueSource = "env" + SourceConfig ValueSource = "config" + SourceSecret ValueSource = "secret" + SourceDefault ValueSource = "default" +) + +var DefaultResolutionOrder = []ValueSource{ + SourceFlag, + SourceEnv, + SourceConfig, + SourceSecret, +} + +var ErrInvalidResolverInput = errors.New("invalid resolver input") + +type FieldSpec struct { + Name string + Required bool + DefaultValue string + Sources []ValueSource + FlagKey string + EnvKey string + ConfigKey string + SecretKey string +} + +type LookupFunc func(source ValueSource, key string) (string, bool, error) + +type ResolveOptions struct { + Fields []FieldSpec + Order []ValueSource + Lookup LookupFunc +} + +type ResolvedField struct { + Name string + Value string + Source ValueSource + Found bool +} + +type Resolution struct { + Fields []ResolvedField +} + +func (r Resolution) Get(name string) (ResolvedField, bool) { + needle := strings.TrimSpace(name) + for _, field := range r.Fields { + if field.Name == needle { + return field, true + } + } + return ResolvedField{}, false +} + +type MissingRequiredValuesError struct { + Fields []string +} + +func (e *MissingRequiredValuesError) Error() string { + return fmt.Sprintf("missing required configuration values: %s", strings.Join(e.Fields, ", ")) +} + +type SourceLookupError struct { + Field string + Source ValueSource + Key string + Err error +} + +func (e *SourceLookupError) Error() string { + return fmt.Sprintf( + "resolve %q from %q (key %q): %v", + e.Field, + e.Source, + e.Key, + e.Err, + ) +} + +func (e *SourceLookupError) Unwrap() error { + return e.Err +} + +type StaticLookup struct { + Flags map[string]string + Env map[string]string + Config map[string]string + Secrets map[string]string +} + +func (l StaticLookup) Lookup(source ValueSource, key string) (string, bool, error) { + var values map[string]string + switch source { + case SourceFlag: + values = l.Flags + case SourceEnv: + values = l.Env + case SourceConfig: + values = l.Config + case SourceSecret: + values = l.Secrets + case SourceDefault: + return "", false, fmt.Errorf("%w: source %q is reserved", ErrInvalidResolverInput, source) + default: + return "", false, fmt.Errorf("%w: unknown source %q", ErrInvalidResolverInput, source) + } + + value, ok := values[key] + return value, ok, nil +} + +func ResolveFields(options ResolveOptions) (Resolution, error) { + if options.Lookup == nil { + return Resolution{}, fmt.Errorf("%w: lookup is required", ErrInvalidResolverInput) + } + + globalOrder, err := normalizeOrder(options.Order, DefaultResolutionOrder) + if err != nil { + return Resolution{}, fmt.Errorf("%w: %v", ErrInvalidResolverInput, err) + } + + resolution := Resolution{ + Fields: make([]ResolvedField, 0, len(options.Fields)), + } + missingRequired := make([]string, 0) + seenNames := make(map[string]struct{}, len(options.Fields)) + + for i, spec := range options.Fields { + name := strings.TrimSpace(spec.Name) + if name == "" { + return Resolution{}, fmt.Errorf("%w: field at index %d has empty name", ErrInvalidResolverInput, i) + } + if _, exists := seenNames[name]; exists { + return Resolution{}, fmt.Errorf("%w: duplicate field name %q", ErrInvalidResolverInput, name) + } + seenNames[name] = struct{}{} + + order := globalOrder + if len(spec.Sources) > 0 { + order, err = normalizeOrder(spec.Sources, globalOrder) + if err != nil { + return Resolution{}, fmt.Errorf("%w: field %q: %v", ErrInvalidResolverInput, name, err) + } + } + + field := ResolvedField{Name: name} + for _, source := range order { + key := spec.keyFor(source) + if key == "" { + continue + } + + value, found, err := options.Lookup(source, key) + if err != nil { + return resolution, &SourceLookupError{ + Field: name, + Source: source, + Key: key, + Err: err, + } + } + + trimmed := strings.TrimSpace(value) + if found && trimmed != "" { + field.Value = trimmed + field.Source = source + field.Found = true + break + } + } + + if !field.Found { + defaultValue := strings.TrimSpace(spec.DefaultValue) + if defaultValue != "" { + field.Value = defaultValue + field.Source = SourceDefault + field.Found = true + } + } + + if spec.Required && !field.Found { + missingRequired = append(missingRequired, name) + } + resolution.Fields = append(resolution.Fields, field) + } + + if len(missingRequired) > 0 { + return resolution, &MissingRequiredValuesError{Fields: missingRequired} + } + + return resolution, nil +} + +func RenderResolutionProvenance(w io.Writer, resolution Resolution) error { + for _, field := range resolution.Fields { + source := "missing" + if field.Found { + source = string(field.Source) + } + + if _, err := fmt.Fprintf(w, "%s: %s\n", field.Name, source); err != nil { + return err + } + } + + return nil +} + +func normalizeOrder(input []ValueSource, fallback []ValueSource) ([]ValueSource, error) { + order := input + if len(order) == 0 { + order = fallback + } + + result := make([]ValueSource, 0, len(order)) + seen := make(map[ValueSource]struct{}, len(order)) + for _, source := range order { + if !isKnownSource(source) { + return nil, fmt.Errorf("unknown source %q", source) + } + if source == SourceDefault { + return nil, fmt.Errorf("source %q cannot be used in resolution order", source) + } + if _, exists := seen[source]; exists { + continue + } + + seen[source] = struct{}{} + result = append(result, source) + } + + if len(result) == 0 { + return nil, fmt.Errorf("resolution order is empty") + } + + return slices.Clone(result), nil +} + +func isKnownSource(source ValueSource) bool { + switch source { + case SourceFlag, SourceEnv, SourceConfig, SourceSecret, SourceDefault: + return true + default: + return false + } +} + +func (s FieldSpec) keyFor(source ValueSource) string { + switch source { + case SourceFlag: + return fallbackKey(s.FlagKey, s.Name) + case SourceEnv: + return fallbackKey(s.EnvKey, s.Name) + case SourceConfig: + return fallbackKey(s.ConfigKey, s.Name) + case SourceSecret: + return fallbackKey(s.SecretKey, s.Name) + default: + return "" + } +} + +func fallbackKey(explicit, fallback string) string { + if key := strings.TrimSpace(explicit); key != "" { + return key + } + return strings.TrimSpace(fallback) +} diff --git a/cli/resolve_test.go b/cli/resolve_test.go new file mode 100644 index 0000000..27792fe --- /dev/null +++ b/cli/resolve_test.go @@ -0,0 +1,291 @@ +package cli + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestResolveFieldsUsesDefaultOrderAndTracksSource(t *testing.T) { + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "base_url", + Required: true, + FlagKey: "base-url", + }, + { + Name: "api_token", + Required: true, + EnvKey: "API_TOKEN", + SecretKey: "my-api-token", + }, + { + Name: "stream_id", + }, + }, + Lookup: StaticLookup{ + Flags: map[string]string{ + "base-url": "https://flag.example.com", + }, + Env: map[string]string{ + "base_url": "https://env.example.com", + "API_TOKEN": "", + }, + Config: map[string]string{ + "stream_id": "stream-from-config", + }, + Secrets: map[string]string{ + "my-api-token": "secret-token", + }, + }.Lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + baseURL, ok := resolution.Get("base_url") + if !ok { + t.Fatalf("base_url was not resolved") + } + if !baseURL.Found { + t.Fatalf("base_url must be found") + } + if baseURL.Source != SourceFlag { + t.Fatalf("base_url source = %q, want %q", baseURL.Source, SourceFlag) + } + if baseURL.Value != "https://flag.example.com" { + t.Fatalf("base_url value = %q", baseURL.Value) + } + + apiToken, ok := resolution.Get("api_token") + if !ok { + t.Fatalf("api_token was not resolved") + } + if apiToken.Source != SourceSecret { + t.Fatalf("api_token source = %q, want %q", apiToken.Source, SourceSecret) + } + if apiToken.Value != "secret-token" { + t.Fatalf("api_token value = %q", apiToken.Value) + } + + streamID, ok := resolution.Get("stream_id") + if !ok { + t.Fatalf("stream_id was not resolved") + } + if streamID.Source != SourceConfig { + t.Fatalf("stream_id source = %q, want %q", streamID.Source, SourceConfig) + } + if streamID.Value != "stream-from-config" { + t.Fatalf("stream_id value = %q", streamID.Value) + } +} + +func TestResolveFieldsSupportsCustomGlobalOrder(t *testing.T) { + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "base_url", + Required: true, + }, + }, + Order: []ValueSource{SourceConfig, SourceEnv, SourceFlag}, + Lookup: StaticLookup{ + Flags: map[string]string{ + "base_url": "https://flag.example.com", + }, + Env: map[string]string{ + "base_url": "https://env.example.com", + }, + Config: map[string]string{ + "base_url": "https://config.example.com", + }, + }.Lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + baseURL, _ := resolution.Get("base_url") + if baseURL.Source != SourceConfig { + t.Fatalf("base_url source = %q, want %q", baseURL.Source, SourceConfig) + } +} + +func TestResolveFieldsSupportsPerFieldOrderAndDefaultValues(t *testing.T) { + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "api_token", + Required: true, + Sources: []ValueSource{SourceSecret, SourceEnv}, + SecretKey: "API_TOKEN_SECRET", + DefaultValue: "default-token", + }, + { + Name: "region", + DefaultValue: "eu-west", + }, + }, + Lookup: StaticLookup{ + Env: map[string]string{ + "api_token": "env-token", + }, + Secrets: map[string]string{ + "API_TOKEN_SECRET": "secret-token", + }, + }.Lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + apiToken, _ := resolution.Get("api_token") + if apiToken.Source != SourceSecret { + t.Fatalf("api_token source = %q, want %q", apiToken.Source, SourceSecret) + } + if apiToken.Value != "secret-token" { + t.Fatalf("api_token value = %q", apiToken.Value) + } + + region, _ := resolution.Get("region") + if region.Source != SourceDefault { + t.Fatalf("region source = %q, want %q", region.Source, SourceDefault) + } + if region.Value != "eu-west" { + t.Fatalf("region value = %q, want eu-west", region.Value) + } +} + +func TestResolveFieldsReturnsHomogeneousMissingRequiredError(t *testing.T) { + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + {Name: "base_url", Required: true}, + {Name: "api_token", Required: true}, + {Name: "region"}, + }, + Lookup: StaticLookup{}.Lookup, + }) + + var missingErr *MissingRequiredValuesError + if !errors.As(err, &missingErr) { + t.Fatalf("ResolveFields error = %v, want MissingRequiredValuesError", err) + } + + if got := strings.Join(missingErr.Fields, ","); got != "base_url,api_token" { + t.Fatalf("missing fields = %q, want base_url,api_token", got) + } + + region, ok := resolution.Get("region") + if !ok { + t.Fatalf("region should exist in partial resolution") + } + if region.Found { + t.Fatalf("region should not be found") + } +} + +func TestResolveFieldsWrapsLookupErrorsWithContext(t *testing.T) { + lookupErr := errors.New("secret backend unavailable") + _, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "api_token", + Sources: []ValueSource{SourceSecret}, + }, + }, + Lookup: func(source ValueSource, key string) (string, bool, error) { + return "", false, lookupErr + }, + }) + + var sourceErr *SourceLookupError + if !errors.As(err, &sourceErr) { + t.Fatalf("ResolveFields error = %v, want SourceLookupError", err) + } + if sourceErr.Field != "api_token" { + t.Fatalf("sourceErr.Field = %q, want api_token", sourceErr.Field) + } + if sourceErr.Source != SourceSecret { + t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret) + } + if !errors.Is(err, lookupErr) { + t.Fatalf("ResolveFields error should wrap the original lookup error") + } +} + +func TestResolveFieldsRejectsInvalidDefinitions(t *testing.T) { + tests := []struct { + name string + options ResolveOptions + }{ + { + name: "missing lookup", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: "base_url"}}, + }, + }, + { + name: "empty field name", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: " "}}, + Lookup: StaticLookup{}.Lookup, + }, + }, + { + name: "duplicate field names", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: "base_url"}, {Name: "base_url"}}, + Lookup: StaticLookup{}.Lookup, + }, + }, + { + name: "unknown source in order", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: "base_url"}}, + Order: []ValueSource{"kv"}, + Lookup: StaticLookup{}.Lookup, + }, + }, + { + name: "default source forbidden in order", + options: ResolveOptions{ + Fields: []FieldSpec{{Name: "base_url"}}, + Order: []ValueSource{SourceDefault}, + Lookup: StaticLookup{}.Lookup, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := ResolveFields(tc.options) + if !errors.Is(err, ErrInvalidResolverInput) { + t.Fatalf("ResolveFields error = %v, want ErrInvalidResolverInput", err) + } + }) + } +} + +func TestRenderResolutionProvenance(t *testing.T) { + var out bytes.Buffer + err := RenderResolutionProvenance(&out, Resolution{ + Fields: []ResolvedField{ + {Name: "base_url", Source: SourceFlag, Found: true}, + {Name: "api_token", Found: false}, + }, + }) + if err != nil { + t.Fatalf("RenderResolutionProvenance returned error: %v", err) + } + + text := out.String() + if !strings.Contains(text, "base_url: flag") { + t.Fatalf("output = %q, want base_url provenance", text) + } + if !strings.Contains(text, "api_token: missing") { + t.Fatalf("output = %q, want missing provenance", text) + } +} +