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()`.
|
||||
- `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).
|
||||
- `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.
|
||||
|
||||
## Utilisation type
|
||||
|
|
@ -288,7 +288,20 @@ Backends keyring typiques :
|
|||
- Linux : Secret Service ou KWallet selon l'environnement
|
||||
- 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
|
||||
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{
|
||||
ConfigCheck: cli.NewConfigCheck(store),
|
||||
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
}),
|
||||
|
|
@ -488,7 +501,7 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
|||
{Name: "api-token", Label: "API token"},
|
||||
},
|
||||
SecretStoreFactory: func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||
ServiceName: "my-mcp",
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -377,7 +377,6 @@ type Runtime struct {
|
|||
ProfileEnv string
|
||||
TokenEnv string
|
||||
SecretName string
|
||||
SecretStorePolicy string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, args []string, version string) error {
|
||||
|
|
@ -420,7 +419,6 @@ func NewRuntime(version string) (Runtime, error) {
|
|||
ProfileEnv: profileEnv,
|
||||
TokenEnv: tokenEnv,
|
||||
SecretName: binaryName + "-api-token",
|
||||
SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -609,9 +607,8 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error
|
|||
}
|
||||
|
||||
func (r Runtime) openSecretStore() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
ServiceName: r.BinaryName,
|
||||
BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy),
|
||||
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||
ServiceName: r.BinaryName,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == r.SecretName {
|
||||
return os.LookupEnv(r.TokenEnv)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
|||
}
|
||||
for _, snippet := range []string{
|
||||
"config.NewStore[Profile]",
|
||||
"secretstore.Open",
|
||||
"secretstore.OpenFromManifest",
|
||||
"update.Run",
|
||||
"manifest.LoadDefault",
|
||||
"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 ErrBackendUnavailable = errors.New("secret backend unavailable")
|
||||
var ErrReadOnly = errors.New("secret backend is read-only")
|
||||
var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy")
|
||||
|
||||
type BackendPolicy string
|
||||
|
||||
|
|
@ -77,15 +78,9 @@ var (
|
|||
)
|
||||
|
||||
func Open(options Options) (Store, error) {
|
||||
policy := options.BackendPolicy
|
||||
if policy == "" {
|
||||
policy = BackendAuto
|
||||
}
|
||||
|
||||
switch policy {
|
||||
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
|
||||
policy, err := normalizeBackendPolicy(options.BackendPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if policy == BackendEnvOnly {
|
||||
|
|
@ -249,7 +244,7 @@ func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]k
|
|||
}
|
||||
return []keyring.BackendType{keyring.KWalletBackend}, nil
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidBackendPolicy) {
|
||||
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue