Merge pull request 'feat(cli): standardiser la résolution de configuration et la provenance' (#16) from feat/config-resolution-provenance into release/v1.2
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/16
This commit is contained in:
commit
11fc89dc71
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`.
|
Le package fournit aussi un socle réutilisable pour une commande `doctor`.
|
||||||
L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.),
|
L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.),
|
||||||
mais peut réutiliser les checks communs et ajouter ses propres hooks :
|
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