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 :
|
||||
|
||||
```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"},
|
||||
|
|
|
|||
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