feat(cli): add multi-source resolve lookup helper
This commit is contained in:
parent
26238eb31b
commit
ea1768982e
3 changed files with 244 additions and 24 deletions
36
README.md
36
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 :
|
Pour standardiser la résolution `flag > env > config > secret` avec provenance :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
lookup := func(source cli.ValueSource, key string) (string, bool, error) {
|
store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||||
switch source {
|
ServiceName: "my-mcp",
|
||||||
case cli.SourceFlag:
|
})
|
||||||
value, ok := flagValues[key]
|
if err != nil {
|
||||||
return value, ok, nil
|
return err
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
resolution, err := cli.ResolveFields(cli.ResolveOptions{
|
||||||
Fields: []cli.FieldSpec{
|
Fields: []cli.FieldSpec{
|
||||||
{Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"},
|
{Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"},
|
||||||
|
|
|
||||||
87
cli/resolve_lookup.go
Normal file
87
cli/resolve_lookup.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
cli/resolve_lookup_test.go
Normal file
145
cli/resolve_lookup_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue