feat(config): per-service operator config + secrets (theme M1)
Services can declare an operator-configurable config block in /_manifest
(ordered array of single-key objects, pure schema: type cleartext|password,
description?, key = container env var name). Maestro:
- lib/secrets: AES-256-GCM FileStore; dedicated secrets.key (32 bytes,
chmod 600, separate from session.key); secrets.json rows keyed
service/field; cleartext read-write, password write-only & encrypted at
rest, decrypted only at injection. Footure key rotation = delete the key.
- handlers/config (owner-gated via auth.RequireSession):
GET /api/services/{service}/config (merged schema+set view, password
values never returned), PUT/DELETE /api/services/{service}/config/{key}
(uniform ERR_NOT_FOUND / ERR_INVALID_PAYLOAD error shape).
- start.go: injectConfigSecrets runs after injectServiceIdentity; only
operator-set rows become env on non-oneoff services; unset fields keep
the compose default.
- Manifest.Config carried onto Container/RegistryContainer/InstalledMetadata
via updateInstalledMetadataFromManifest + watcher.
- Frontend: per-service Settings dialog (ServiceConfigDialog) + api
helpers; cleartext editable, password set/unset indicator only.
- spec.yaml documents the new endpoints/schemas.
This commit is contained in:
@@ -26,6 +26,7 @@ import (
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/docker/compose/v2/pkg/api"
|
||||
@@ -73,6 +74,13 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
||||
if err := injectServiceIdentity(ctx, dc, project, serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
// Operator-configured env (Service Config & Secrets theme M1): inject the
|
||||
// values the owner set through the Settings API for this service. Rows only
|
||||
// exist for fields the operator configured; unset fields keep the compose
|
||||
// default untouched.
|
||||
if err := injectConfigSecrets(project, serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The compose file may reference the image by a short name (e.g.
|
||||
// "testservice") while install pulled it under its full registry reference.
|
||||
@@ -213,6 +221,57 @@ func mtlsDatabaseURL(serviceName string) string {
|
||||
)
|
||||
}
|
||||
|
||||
// injectConfigSecrets injects the operator-configured env values (Service
|
||||
// Config & Secrets theme M1) into every non-oneoff compose service. Values
|
||||
// come from the encrypted secrets store (password rows are decrypted here, in
|
||||
// memory, only for the container); cleartext rows are stored plaintext.
|
||||
//
|
||||
// A row only exists for a field the operator actually configured, so unset
|
||||
// fields leave the compose file's own environment untouched — the operator-set
|
||||
// value wins, the compose default stays otherwise. Password rows decrypt once
|
||||
// in this pass and never reach logs or error strings.
|
||||
func injectConfigSecrets(project *composetypes.Project, serviceName string) error {
|
||||
dataRoot, err := getDataRoot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve data root: %w", err)
|
||||
}
|
||||
store, err := secrets.NewFileStore(filepath.Join(dataRoot, "maestro"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open secrets store: %w", err)
|
||||
}
|
||||
|
||||
rows := store.All(serviceName)
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
env := make(map[string]string, len(rows))
|
||||
for _, view := range rows {
|
||||
row, found, err := store.Lookup(serviceName, view.Field)
|
||||
if err != nil {
|
||||
logger.Warn("Skipping config %s/%s: decrypt failed (secrets.key rotated?): %v", serviceName, view.Field, err)
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
env[row.Key] = row.Value
|
||||
}
|
||||
if len(env) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for name, s := range project.Services {
|
||||
if isOneoff(s) {
|
||||
continue
|
||||
}
|
||||
s.Environment = mergeEnv(s.Environment, env)
|
||||
project.Services[name] = s
|
||||
}
|
||||
logger.Info("Injected %d operator config values for %s", len(env), serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasDatabaseCapability reports whether the installed service declared the
|
||||
// database capability in its manifest (persisted at install/reconcile).
|
||||
func hasDatabaseCapability(serviceName string) bool {
|
||||
|
||||
Reference in New Issue
Block a user