feat(secretstore): add runtime manifest helper for backend opening
This commit is contained in:
parent
8c4f88ea93
commit
26238eb31b
7 changed files with 265 additions and 20 deletions
21
README.md
21
README.md
|
|
@ -45,7 +45,7 @@ go run ./cmd/my-mcp help
|
||||||
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
||||||
- `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
|
- `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
|
||||||
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage).
|
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage).
|
||||||
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
|
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`.
|
||||||
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
||||||
|
|
||||||
## Utilisation type
|
## Utilisation type
|
||||||
|
|
@ -288,7 +288,20 @@ Backends keyring typiques :
|
||||||
- Linux : Secret Service ou KWallet selon l'environnement
|
- Linux : Secret Service ou KWallet selon l'environnement
|
||||||
- Windows : Credential Manager
|
- Windows : Credential Manager
|
||||||
|
|
||||||
Exemple :
|
Ouverture recommandée depuis la policy du manifeste runtime (`mcp.toml` résolu
|
||||||
|
près de l'exécutable) :
|
||||||
|
|
||||||
|
```go
|
||||||
|
store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||||
|
ServiceName: "my-mcp",
|
||||||
|
LookupEnv: os.LookupEnv,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple bas niveau :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
store, err := secretstore.Open(secretstore.Options{
|
store, err := secretstore.Open(secretstore.Options{
|
||||||
|
|
@ -480,7 +493,7 @@ mais peut réutiliser les checks communs et ajouter ses propres hooks :
|
||||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||||
ConfigCheck: cli.NewConfigCheck(store),
|
ConfigCheck: cli.NewConfigCheck(store),
|
||||||
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
|
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
|
||||||
return secretstore.Open(secretstore.Options{
|
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||||
ServiceName: "my-mcp",
|
ServiceName: "my-mcp",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
@ -488,7 +501,7 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||||
{Name: "api-token", Label: "API token"},
|
{Name: "api-token", Label: "API token"},
|
||||||
},
|
},
|
||||||
SecretStoreFactory: func() (secretstore.Store, error) {
|
SecretStoreFactory: func() (secretstore.Store, error) {
|
||||||
return secretstore.Open(secretstore.Options{
|
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||||
ServiceName: "my-mcp",
|
ServiceName: "my-mcp",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -377,7 +377,6 @@ type Runtime struct {
|
||||||
ProfileEnv string
|
ProfileEnv string
|
||||||
TokenEnv string
|
TokenEnv string
|
||||||
SecretName string
|
SecretName string
|
||||||
SecretStorePolicy string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(ctx context.Context, args []string, version string) error {
|
func Run(ctx context.Context, args []string, version string) error {
|
||||||
|
|
@ -420,7 +419,6 @@ func NewRuntime(version string) (Runtime, error) {
|
||||||
ProfileEnv: profileEnv,
|
ProfileEnv: profileEnv,
|
||||||
TokenEnv: tokenEnv,
|
TokenEnv: tokenEnv,
|
||||||
SecretName: binaryName + "-api-token",
|
SecretName: binaryName + "-api-token",
|
||||||
SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -609,9 +607,8 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runtime) openSecretStore() (secretstore.Store, error) {
|
func (r Runtime) openSecretStore() (secretstore.Store, error) {
|
||||||
return secretstore.Open(secretstore.Options{
|
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||||
ServiceName: r.BinaryName,
|
ServiceName: r.BinaryName,
|
||||||
BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy),
|
|
||||||
LookupEnv: func(name string) (string, bool) {
|
LookupEnv: func(name string) (string, bool) {
|
||||||
if name == r.SecretName {
|
if name == r.SecretName {
|
||||||
return os.LookupEnv(r.TokenEnv)
|
return os.LookupEnv(r.TokenEnv)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, snippet := range []string{
|
for _, snippet := range []string{
|
||||||
"config.NewStore[Profile]",
|
"config.NewStore[Profile]",
|
||||||
"secretstore.Open",
|
"secretstore.OpenFromManifest",
|
||||||
"update.Run",
|
"update.Run",
|
||||||
"manifest.LoadDefault",
|
"manifest.LoadDefault",
|
||||||
"bootstrap.Run",
|
"bootstrap.Run",
|
||||||
|
|
|
||||||
80
secretstore/manifest_open.go
Normal file
80
secretstore/manifest_open.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package secretstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ManifestLoader func(startDir string) (manifest.File, string, error)
|
||||||
|
|
||||||
|
type ExecutableResolver func() (string, error)
|
||||||
|
|
||||||
|
type OpenFromManifestOptions struct {
|
||||||
|
ServiceName string
|
||||||
|
LookupEnv func(string) (string, bool)
|
||||||
|
KWalletAppID string
|
||||||
|
KWalletFolder string
|
||||||
|
ManifestLoader ManifestLoader
|
||||||
|
ExecutableResolver ExecutableResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
|
||||||
|
policy, err := resolveManifestBackendPolicy(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Open(Options{
|
||||||
|
ServiceName: options.ServiceName,
|
||||||
|
BackendPolicy: policy,
|
||||||
|
LookupEnv: options.LookupEnv,
|
||||||
|
KWalletAppID: options.KWalletAppID,
|
||||||
|
KWalletFolder: options.KWalletFolder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, error) {
|
||||||
|
manifestLoader := options.ManifestLoader
|
||||||
|
if manifestLoader == nil {
|
||||||
|
manifestLoader = manifest.LoadDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
executableResolver := options.ExecutableResolver
|
||||||
|
if executableResolver == nil {
|
||||||
|
executableResolver = os.Executable
|
||||||
|
}
|
||||||
|
|
||||||
|
executablePath, err := executableResolver()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve executable path for manifest lookup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
startDir := filepath.Dir(strings.TrimSpace(executablePath))
|
||||||
|
if strings.TrimSpace(startDir) == "" {
|
||||||
|
startDir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
file, manifestPath, err := manifestLoader(startDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return BackendAuto, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" {
|
||||||
|
return BackendAuto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid secret_store.backend_policy in manifest %q: %w", strings.TrimSpace(manifestPath), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
139
secretstore/manifest_open_test.go
Normal file
139
secretstore/manifest_open_test.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package secretstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/99designs/keyring"
|
||||||
|
|
||||||
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {
|
||||||
|
var gotStartDir string
|
||||||
|
|
||||||
|
store, err := OpenFromManifest(OpenFromManifestOptions{
|
||||||
|
ServiceName: "email-mcp",
|
||||||
|
LookupEnv: func(name string) (string, bool) {
|
||||||
|
if name == "EMAIL_TOKEN" {
|
||||||
|
return "from-env", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
},
|
||||||
|
ExecutableResolver: func() (string, error) {
|
||||||
|
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||||
|
},
|
||||||
|
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||||
|
gotStartDir = startDir
|
||||||
|
return manifest.File{
|
||||||
|
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)},
|
||||||
|
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFromManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantDir := filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin")
|
||||||
|
if gotStartDir != wantDir {
|
||||||
|
t.Fatalf("manifest loader startDir = %q, want %q", gotStartDir, wantDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := store.GetSecret("EMAIL_TOKEN")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSecret returned error: %v", err)
|
||||||
|
}
|
||||||
|
if value != "from-env" {
|
||||||
|
t.Fatalf("GetSecret = %q, want from-env", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenFromManifestFallsBackToAutoWhenManifestIsMissing(t *testing.T) {
|
||||||
|
withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) {
|
||||||
|
t.Fatal("unexpected keyring open call")
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
store, err := OpenFromManifest(OpenFromManifestOptions{
|
||||||
|
ServiceName: "email-mcp",
|
||||||
|
LookupEnv: func(name string) (string, bool) {
|
||||||
|
if name == "EMAIL_TOKEN" {
|
||||||
|
return "env-token", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
},
|
||||||
|
ExecutableResolver: func() (string, error) {
|
||||||
|
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||||
|
},
|
||||||
|
ManifestLoader: func(string) (manifest.File, string, error) {
|
||||||
|
return manifest.File{}, "", os.ErrNotExist
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFromManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := store.GetSecret("EMAIL_TOKEN")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSecret returned error: %v", err)
|
||||||
|
}
|
||||||
|
if value != "env-token" {
|
||||||
|
t.Fatalf("GetSecret = %q, want env-token", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing.T) {
|
||||||
|
_, err := OpenFromManifest(OpenFromManifestOptions{
|
||||||
|
ServiceName: "email-mcp",
|
||||||
|
ExecutableResolver: func() (string, error) {
|
||||||
|
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||||
|
},
|
||||||
|
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||||
|
return manifest.File{
|
||||||
|
SecretStore: manifest.SecretStore{BackendPolicy: "totally-invalid"},
|
||||||
|
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrInvalidBackendPolicy) {
|
||||||
|
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "secret_store.backend_policy") {
|
||||||
|
t.Fatalf("error = %v, want manifest policy context", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), manifest.DefaultFile) {
|
||||||
|
t.Fatalf("error = %v, want manifest path", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) {
|
||||||
|
execErr := errors.New("boom")
|
||||||
|
_, err := OpenFromManifest(OpenFromManifestOptions{
|
||||||
|
ExecutableResolver: func() (string, error) {
|
||||||
|
return "", execErr
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, execErr) {
|
||||||
|
t.Fatalf("error = %v, want wrapped executable resolver error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenFromManifestReturnsManifestLoaderError(t *testing.T) {
|
||||||
|
loadErr := errors.New("cannot parse manifest")
|
||||||
|
_, err := OpenFromManifest(OpenFromManifestOptions{
|
||||||
|
ExecutableResolver: func() (string, error) {
|
||||||
|
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||||
|
},
|
||||||
|
ManifestLoader: func(string) (manifest.File, string, error) {
|
||||||
|
return manifest.File{}, "", loadErr
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, loadErr) {
|
||||||
|
t.Fatalf("error = %v, want wrapped manifest loader error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
var ErrNotFound = errors.New("secret not found")
|
var ErrNotFound = errors.New("secret not found")
|
||||||
var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
||||||
var ErrReadOnly = errors.New("secret backend is read-only")
|
var ErrReadOnly = errors.New("secret backend is read-only")
|
||||||
|
var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy")
|
||||||
|
|
||||||
type BackendPolicy string
|
type BackendPolicy string
|
||||||
|
|
||||||
|
|
@ -77,15 +78,9 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func Open(options Options) (Store, error) {
|
func Open(options Options) (Store, error) {
|
||||||
policy := options.BackendPolicy
|
policy, err := normalizeBackendPolicy(options.BackendPolicy)
|
||||||
if policy == "" {
|
if err != nil {
|
||||||
policy = BackendAuto
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
switch policy {
|
|
||||||
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if policy == BackendEnvOnly {
|
if policy == BackendEnvOnly {
|
||||||
|
|
@ -249,7 +244,7 @@ func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]k
|
||||||
}
|
}
|
||||||
return []keyring.BackendType{keyring.KWalletBackend}, nil
|
return []keyring.BackendType{keyring.KWalletBackend}, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
|
return nil, invalidBackendPolicyError(policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,3 +255,21 @@ func backendNames(backends []keyring.BackendType) []string {
|
||||||
}
|
}
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) {
|
||||||
|
trimmed := BackendPolicy(strings.TrimSpace(string(policy)))
|
||||||
|
if trimmed == "" {
|
||||||
|
return BackendAuto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch trimmed {
|
||||||
|
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
|
||||||
|
return trimmed, nil
|
||||||
|
default:
|
||||||
|
return "", invalidBackendPolicyError(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidBackendPolicyError(policy BackendPolicy) error {
|
||||||
|
return fmt.Errorf("%w %q", ErrInvalidBackendPolicy, policy)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,9 @@ func TestOpenRejectsInvalidPolicy(t *testing.T) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
|
if !errors.Is(err, ErrInvalidBackendPolicy) {
|
||||||
|
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) {
|
func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue