From ea1768982eb576081047b9204a33763dbb985105 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:48:26 +0200 Subject: [PATCH] feat(cli): add multi-source resolve lookup helper --- README.md | 36 +++------ cli/resolve_lookup.go | 87 ++++++++++++++++++++++ cli/resolve_lookup_test.go | 145 +++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 cli/resolve_lookup.go create mode 100644 cli/resolve_lookup_test.go diff --git a/README.md b/README.md index db1fb4d..942c8cd 100644 --- a/README.md +++ b/README.md @@ -439,32 +439,20 @@ Les validations sont appliquées de manière cohérente en TTY et en stdin non i 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 - } +store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ + ServiceName: "my-mcp", +}) +if err != nil { + return err } +lookup := cli.ResolveLookup(cli.ResolveLookupOptions{ + Flag: cli.MapLookup(flagValues), + Env: cli.EnvLookup(os.LookupEnv), + Config: cli.ConfigMap(configValues), + Secret: cli.SecretStore(store), // ErrNotFound => valeur absente, pas une erreur +}) + resolution, err := cli.ResolveFields(cli.ResolveOptions{ Fields: []cli.FieldSpec{ {Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"}, diff --git a/cli/resolve_lookup.go b/cli/resolve_lookup.go new file mode 100644 index 0000000..f42fc45 --- /dev/null +++ b/cli/resolve_lookup.go @@ -0,0 +1,87 @@ +package cli + +import ( + "errors" + "os" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type KeyLookupFunc func(key string) (string, bool, error) + +type ResolveLookupOptions struct { + Flag KeyLookupFunc + Env KeyLookupFunc + Config KeyLookupFunc + Secret KeyLookupFunc +} + +func ResolveLookup(options ResolveLookupOptions) LookupFunc { + return func(source ValueSource, key string) (string, bool, error) { + switch source { + case SourceFlag: + return runKeyLookup(options.Flag, key) + case SourceEnv: + return runKeyLookup(options.Env, key) + case SourceConfig: + return runKeyLookup(options.Config, key) + case SourceSecret: + return runKeyLookup(options.Secret, key) + case SourceDefault: + return "", false, nil + default: + return "", false, nil + } + } +} + +func runKeyLookup(lookup KeyLookupFunc, key string) (string, bool, error) { + if lookup == nil { + return "", false, nil + } + if key == "" { + return "", false, nil + } + return lookup(key) +} + +func MapLookup(values map[string]string) KeyLookupFunc { + return func(key string) (string, bool, error) { + value, ok := values[key] + return value, ok, nil + } +} + +func EnvLookup(lookup func(string) (string, bool)) KeyLookupFunc { + reader := lookup + if reader == nil { + reader = os.LookupEnv + } + + return func(key string) (string, bool, error) { + value, ok := reader(key) + return value, ok, nil + } +} + +func ConfigMap(values map[string]string) KeyLookupFunc { + return MapLookup(values) +} + +func SecretStore(store secretstore.Store) KeyLookupFunc { + if store == nil { + return nil + } + + return func(key string) (string, bool, error) { + 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 + } + } +} diff --git a/cli/resolve_lookup_test.go b/cli/resolve_lookup_test.go new file mode 100644 index 0000000..32e4d23 --- /dev/null +++ b/cli/resolve_lookup_test.go @@ -0,0 +1,145 @@ +package cli + +import ( + "errors" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/secretstore" +) + +type testSecretStore struct { + values map[string]string + errs map[string]error +} + +func (s testSecretStore) SetSecret(name, label, secret string) error { + return nil +} + +func (s testSecretStore) GetSecret(name string) (string, error) { + if err, ok := s.errs[name]; ok { + return "", err + } + value, ok := s.values[name] + if !ok { + return "", secretstore.ErrNotFound + } + return value, nil +} + +func (s testSecretStore) DeleteSecret(name string) error { + return nil +} + +func TestResolveLookupWithStandardProviders(t *testing.T) { + t.Setenv("MCP_PASSWORD", "") + + lookup := ResolveLookup(ResolveLookupOptions{ + Flag: MapLookup(map[string]string{"host": "https://flag.example.com"}), + Env: EnvLookup(nil), + Config: ConfigMap(map[string]string{"username": "config-user"}), + Secret: SecretStore(testSecretStore{ + values: map[string]string{"smtp-password": "secret-password"}, + }), + }) + + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "host", + Required: true, + FlagKey: "host", + }, + { + Name: "username", + Required: true, + EnvKey: "MCP_USERNAME", + ConfigKey: "username", + }, + { + Name: "password", + Required: true, + EnvKey: "MCP_PASSWORD", + SecretKey: "smtp-password", + }, + }, + Lookup: lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + host, _ := resolution.Get("host") + if host.Source != SourceFlag || host.Value != "https://flag.example.com" { + t.Fatalf("host = %+v", host) + } + + username, _ := resolution.Get("username") + if username.Source != SourceConfig || username.Value != "config-user" { + t.Fatalf("username = %+v", username) + } + + password, _ := resolution.Get("password") + if password.Source != SourceSecret || password.Value != "secret-password" { + t.Fatalf("password = %+v", password) + } +} + +func TestSecretStoreProviderTreatsErrNotFoundAsMissing(t *testing.T) { + lookup := ResolveLookup(ResolveLookupOptions{ + Secret: SecretStore(testSecretStore{ + errs: map[string]error{"smtp-password": secretstore.ErrNotFound}, + }), + }) + + resolution, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "password", + Required: true, + SecretKey: "smtp-password", + DefaultValue: "fallback-password", + }, + }, + Lookup: lookup, + }) + if err != nil { + t.Fatalf("ResolveFields returned error: %v", err) + } + + password, _ := resolution.Get("password") + if password.Source != SourceDefault || password.Value != "fallback-password" { + t.Fatalf("password = %+v", password) + } +} + +func TestSecretStoreProviderPropagatesBackendErrors(t *testing.T) { + backendErr := errors.New("backend unavailable") + lookup := ResolveLookup(ResolveLookupOptions{ + Secret: SecretStore(testSecretStore{ + errs: map[string]error{"smtp-password": backendErr}, + }), + }) + + _, err := ResolveFields(ResolveOptions{ + Fields: []FieldSpec{ + { + Name: "password", + Required: true, + SecretKey: "smtp-password", + }, + }, + Lookup: lookup, + }) + + var sourceErr *SourceLookupError + if !errors.As(err, &sourceErr) { + t.Fatalf("ResolveFields error = %v, want SourceLookupError", err) + } + if sourceErr.Source != SourceSecret { + t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret) + } + if !errors.Is(err, backendErr) { + t.Fatalf("ResolveFields error should wrap backend error") + } +}