feat(cli): standardize config resolution and provenance

This commit is contained in:
thibaud-lclr 2026-04-14 08:55:01 +02:00
parent c6c38d6019
commit 76fa01c669
3 changed files with 623 additions and 0 deletions

View file

@ -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 :

283
cli/resolve.go Normal file
View file

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

291
cli/resolve_test.go Normal file
View file

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