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:
2026-08-31 00:48:51 +01:00
parent ca4c66110e
commit a9e2182396
22 changed files with 1831 additions and 12 deletions
+59
View File
@@ -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 {