feat(cli): standardiser la résolution de configuration et la provenance #16
3 changed files with 623 additions and 0 deletions
49
README.md
49
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 :
|
||||
|
|
|
|||
283
cli/resolve.go
Normal file
283
cli/resolve.go
Normal 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
291
cli/resolve_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue