feat(config): fail-closed manifest-driven injection + runtime write-back delivery (D6)

This commit is contained in:
2026-08-31 09:43:43 +01:00
parent a9e2182396
commit c540ad5d7f
6 changed files with 326 additions and 16 deletions
+3 -1
View File
@@ -987,7 +987,9 @@ paths:
stored plaintext (read-write); password fields are AES-256-GCM
encrypted at rest, keyed by <dataRoot>/maestro/secrets.key, and never
returned again — the response is only a ServiceConfigEntry with set:true.
The value is injected as the field's env key on the next start.
The value is injected as the field's env key at start, and, when the
service is running and exposes POST /config (D6 write-back), delivered
to it at runtime over the mTLS peer edge.
requestBody:
required: true
content:
@@ -44,6 +44,11 @@ export function ServiceConfigDialog({ service, onClose }: ServiceConfigDialogPro
const open = service !== null
const { load, refresh } = useServiceConfig(service?.name ?? null, open)
const running =
(service?.status ?? '').toLowerCase() === 'running' ||
(service?.status ?? '').toLowerCase() === 'starting' ||
(service?.status ?? '').toLowerCase() === 'restarting'
return (
<ModalOverlay
isOpen={open}
@@ -66,8 +71,8 @@ export function ServiceConfigDialog({ service, onClose }: ServiceConfigDialogPro
</h2>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Operator-configured values declared by the service manifest. Passwords are stored
encrypted and are never shown again — only whether they are set. Applies on next
start.
encrypted and are never shown again — only whether they are set.
{running ? ' Values apply on the next restart.' : ' Values apply at next start.'}
</p>
</div>
<Button data-slot="service-config-close" variant="ghost" size="icon-sm" aria-label="Close" onPress={onClose}>
@@ -76,7 +81,7 @@ export function ServiceConfigDialog({ service, onClose }: ServiceConfigDialogPro
</header>
<div className="grid min-w-0 gap-4 px-5 py-5">
<ServiceConfigSection serviceName={service?.name ?? ''} load={load} onRefresh={refresh} />
<ServiceConfigSection serviceName={service?.name ?? ''} load={load} onRefresh={refresh} running={running} />
</div>
</Dialog>
</Modal>
@@ -88,9 +93,10 @@ interface ServiceConfigSectionProps {
serviceName: string
load: ServiceConfigLoadState
onRefresh: () => void
running: boolean
}
function ServiceConfigSection({ serviceName, load, onRefresh }: ServiceConfigSectionProps) {
function ServiceConfigSection({ serviceName, load, onRefresh, running }: ServiceConfigSectionProps) {
if (load.kind === 'loading') {
return (
<div
@@ -139,6 +145,7 @@ function ServiceConfigSection({ serviceName, load, onRefresh }: ServiceConfigSec
entry={entry}
serviceName={serviceName}
onDone={onRefresh}
running={running}
/>
))}
</div>
@@ -149,17 +156,21 @@ function ConfigRow({
entry,
serviceName,
onDone,
running,
}: {
entry: ServiceConfigEntry
serviceName: string
onDone: () => void
running: boolean
}) {
const [value, setValue] = useState('')
const save = useAsyncAction(async () => {
try {
await putServiceConfig(serviceName, entry.field, value)
toast.success(`Saved ${entry.key || entry.field}`)
toast.success(`Saved ${entry.key || entry.field}`, {
description: running ? 'Delivered to running service — will also apply on next restart' : 'Applies at next start',
})
setValue('')
onDone()
} catch (error) {
@@ -171,7 +182,7 @@ function ConfigRow({
try {
await deleteServiceConfig(serviceName, entry.field)
toast.success(`Cleared ${entry.key || entry.field}`, {
description: 'The compose default applies on next start',
description: running ? 'Service will use compose default on next restart' : 'Compose default applies at next start',
})
setValue('')
onDone()
+7
View File
@@ -148,6 +148,13 @@ func (h *Handler) Value(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, view)
// Runtime write-back (D6): best-effort delivery to the running service's
// POST /config endpoint. The goroutine is fire-and-forget; values always
// also take effect at next start via injectConfigSecrets (M1).
go libcontainers.DeliverConfigValues(r.Context(), container.ServiceName, []libcontainers.ConfigDelivery{
{Field: field, Key: spec.Key, Type: spec.Type, Value: action.Value},
})
default:
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
}
+122
View File
@@ -0,0 +1,122 @@
// Package containers implements Maestro's runtime write-back delivery for the
// Service Config & Secrets theme (D6). When the operator sets or clears a
// config value via the Settings panel, Maestro delivers it to the running
// service's POST /config endpoint over the existing mTLS peer edge (same
// transport as /action/* and manifest fetch).
//
// Delivery is best-effort: failures are logged (with the field name, never
// the value) and do not prevent the panel write from succeeding. Values always
// also take effect at the next StartContainer via injectConfigSecrets (M1),
// so delivery is an accelerator, not the sole path.
package containers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// ConfigDelivery is one key-value pair to deliver to a running service.
type ConfigDelivery struct {
Field string // manifest config field name
Key string // container env var name
Type string // "cleartext" | "password"
Value string // the operator-entered value (sent over mTLS; never logged)
}
// configRequest is the JSON body POSTed to the service's /config endpoint.
type configRequest struct {
Field string `json:"field"`
Value string `json:"value"`
Type string `json:"type"`
}
// DeliverConfigValues delivers the given config pairs to the running service
// over the mTLS peer edge. If the service is not running or unreachable, the
// error is logged but not returned — the values will take effect at next start
// via injectConfigSecrets.
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
if len(items) == 0 {
return
}
dc, err := docker.NewDockerClient()
if err != nil {
logger.Warn("Config delivery to %s: cannot open docker client: %v", serviceName, err)
return
}
defer dc.Close()
inspectData, err := docker.ResolveContainer(ctx, dc, serviceName)
if err != nil {
logger.Debug("Config delivery to %s: service not running or not found (%v); values will apply at next start", serviceName, err)
return
}
shouldCache := false
network, _, err := docker.ExtractNetworkInfo(ctx, dc, inspectData)
if err != nil || !shouldCache {
logger.Debug("Config delivery to %s: cannot extract network info; values will apply at next start", serviceName)
return
}
port := inspectData.Config.Labels["org.dcntrlized.port"]
if port == "" && len(network.Ports) > 0 {
port = fmt.Sprintf("%d", network.Ports[0].Guest)
}
if port == "" {
logger.Debug("Config delivery to %s: no service port; values will apply at next start", serviceName)
return
}
transport, err := authmgmt.PeerTransport()
if err != nil {
logger.Warn("Config delivery to %s: cannot build peer transport: %v", serviceName, err)
return
}
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
for _, item := range items {
body, err := json.Marshal(configRequest{
Field: item.Field,
Value: item.Value,
Type: item.Type,
})
if err != nil {
logger.Warn("Config delivery to %s/%s: marshal failed: %v", serviceName, item.Field, err)
continue
}
url := fmt.Sprintf("%s://%s:%s/config", authmgmt.Scheme(), network.IPAddress.Address, port)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, item.Field, err)
continue
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
logger.Debug("Config delivery to %s/%s: service unreachable (%v); value will apply at next start", serviceName, item.Field, err)
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
logger.Info("Delivered operator config %s/%s to running service %s", serviceName, item.Field, serviceName)
} else {
logger.Debug("Config delivery to %s/%s: service responded %d; value will apply at next start", serviceName, item.Field, resp.StatusCode)
}
}
}
+54 -8
View File
@@ -224,12 +224,18 @@ 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.
// memory, only for the container); cleartext rows are stored plaintext in the
// store but never reach logs or error strings.
//
// 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.
// Injection is manifest-driven: only fields the live manifest still declares are
// candidates. A stored row whose field was dropped from the manifest is skipped
// (the service no longer expects that env var). The declared env key wins over
// the stored one so a service that renames its var keeps receiving it.
//
// Fail closed: a set row whose password cannot be decrypted (secrets.key was
// rotated or removed) must never silently fall back to the compose default —
// which could be a public placeholder. The start is aborted with a clear error
// so the operator re-enters the value or restores the key.
func injectConfigSecrets(project *composetypes.Project, serviceName string) error {
dataRoot, err := getDataRoot()
if err != nil {
@@ -245,17 +251,36 @@ func injectConfigSecrets(project *composetypes.Project, serviceName string) erro
return nil
}
declared, err := declaredConfigFields(serviceName)
if err != nil {
return fmt.Errorf("resolve config schema for %s: %w", serviceName, err)
}
env := make(map[string]string, len(rows))
for _, view := range rows {
if !view.Set {
continue
}
spec, ok := declared[view.Field]
if !ok {
logger.Warn("Skipping stale config %s/%s: field no longer declared by the manifest", serviceName, view.Field)
continue
}
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
return fmt.Errorf("start %s: operator secret %s is set but undecryptable (secrets.key rotated or removed?): %w", serviceName, view.Field, err)
}
if !found {
continue
}
env[row.Key] = row.Value
key := spec.Key
if key == "" {
key = row.Key
}
if key == "" {
key = view.Field
}
env[key] = row.Value
}
if len(env) == 0 {
return nil
@@ -272,6 +297,27 @@ func injectConfigSecrets(project *composetypes.Project, serviceName string) erro
return nil
}
// declaredConfigFields returns the config fields declared in the installed
// service's manifest metadata, keyed by field name. This is the live schema
// that injection uses to decide which stored rows are still relevant.
func declaredConfigFields(serviceName string) (map[string]types.ConfigField, error) {
dir, err := getContainerDataDir(serviceName)
if err != nil {
return nil, err
}
metadata, err := readInstalledMetadata(dir)
if err != nil {
return nil, err
}
out := make(map[string]types.ConfigField, len(metadata.Config))
for _, item := range metadata.Config {
if name := item.FieldName(); name != "" {
out[name] = item[name]
}
}
return out, nil
}
// hasDatabaseCapability reports whether the installed service declared the
// database capability in its manifest (persisted at install/reconcile).
func hasDatabaseCapability(serviceName string) bool {
+123 -1
View File
@@ -16,6 +16,8 @@
package containers
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@@ -43,15 +45,44 @@ func TestHostBindSource(t *testing.T) {
}
}
// writeTestMetadata creates a minimal installed metadata.json with the given
// config schema under dataRoot/serviceName, satisfying the manifest-driven
// injection requirement.
func writeTestMetadata(t *testing.T, dataRoot, serviceName string, config []types.ConfigItem) {
t.Helper()
dir := filepath.Join(dataRoot, serviceName)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("MkdirAll %s: %v", dir, err)
}
m := types.InstalledMetadata{
RegistryContainer: types.RegistryContainer{Name: serviceName},
Config: config,
}
data, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), data, 0o600); err != nil {
t.Fatalf("write metadata.json: %v", err)
}
}
// TestInjectConfigSecrets covers the operator-config env injection (Service
// Config & Secrets theme M1): set rows are injected into non-oneoff services
// (passwords decrypted), one-off helpers are skipped, and fields without a
// stored row never clobber the compose default.
// stored row never clobber the compose default. Injection is now
// manifest-driven: only fields declared in the service's metadata config block
// are candidates.
func TestInjectConfigSecrets(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_DATA_HOME", root)
dataRoot := filepath.Join(root, ".dcntrlized")
writeTestMetadata(t, dataRoot, "svc", []types.ConfigItem{
{"owner_email": {Type: types.ConfigCleartext, Key: "OWNER_EMAIL"}},
{"db_password": {Type: types.ConfigPassword, Key: "DB_PASSWORD"}},
})
store, err := secrets.NewFileStore(filepath.Join(dataRoot, "maestro"))
if err != nil {
t.Fatalf("NewFileStore: %v", err)
@@ -114,6 +145,97 @@ func envValue(env map[string]*string, key string) string {
return *v
}
// TestInjectConfigSecretsStaleField ensures a stored row whose manifest field
// was dropped is skipped silently, leaving the compose default intact.
func TestInjectConfigSecretsStaleField(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_DATA_HOME", root)
dataRoot := filepath.Join(root, ".dcntrlized")
// Manifest declares only owner_email — db_password was dropped.
writeTestMetadata(t, dataRoot, "svc", []types.ConfigItem{
{"owner_email": {Type: types.ConfigCleartext, Key: "OWNER_EMAIL"}},
})
store, err := secrets.NewFileStore(filepath.Join(dataRoot, "maestro"))
if err != nil {
t.Fatalf("NewFileStore: %v", err)
}
if _, err := store.Set("svc", "owner_email", types.ConfigField{Type: types.ConfigCleartext, Key: "OWNER_EMAIL"}, "[email protected]"); err != nil {
t.Fatalf("set cleartext: %v", err)
}
if _, err := store.Set("svc", "db_password", types.ConfigField{Type: types.ConfigPassword, Key: "DB_PASSWORD"}, "old-secret"); err != nil {
t.Fatalf("set password: %v", err)
}
strPtr := func(v string) *string { return &v }
project := &composetypes.Project{Services: map[string]composetypes.ServiceConfig{
"app": {Environment: map[string]*string{"DB_PASSWORD": strPtr("compose-default")}},
}}
if err := injectConfigSecrets(project, "svc"); err != nil {
t.Fatalf("injectConfigSecrets: %v", err)
}
app := project.Services["app"]
if got := envValue(app.Environment, "OWNER_EMAIL"); got != "[email protected]" {
t.Errorf("OWNER_EMAIL = %q, want [email protected]", got)
}
// Stale field not injected — compose default stays.
if got := envValue(app.Environment, "DB_PASSWORD"); got != "compose-default" {
t.Errorf("DB_PASSWORD = %q, want compose-default (stale field should be skipped)", got)
}
}
// TestInjectConfigSecretsFailClosed ensures a set password row that cannot be
// decrypted (secrets.key rotated/removed) aborts the start rather than
// silently booting the compose default.
func TestInjectConfigSecretsFailClosed(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_DATA_HOME", root)
dataRoot := filepath.Join(root, ".dcntrlized")
writeTestMetadata(t, dataRoot, "svc", []types.ConfigItem{
{"db_password": {Type: types.ConfigPassword, Key: "DB_PASSWORD"}},
})
store, err := secrets.NewFileStore(filepath.Join(dataRoot, "maestro"))
if err != nil {
t.Fatalf("NewFileStore: %v", err)
}
if _, err := store.Set("svc", "db_password", types.ConfigField{Type: types.ConfigPassword, Key: "DB_PASSWORD"}, "s3cr3t"); err != nil {
t.Fatalf("set password: %v", err)
}
// Rotate the key: remove the original secrets.key so the next NewFileStore
// creates a fresh (different) key that cannot decrypt the existing rows.
if err := os.Remove(filepath.Join(dataRoot, "maestro", "secrets.key")); err != nil {
t.Fatalf("remove secrets.key: %v", err)
}
// Write a different-length invalid key to trigger a clean decrypt failure
// (NewFileStore would create a new key; we force a mismatch by re-creating
// the store with a fresh key before injection).
_, err = secrets.NewFileStore(filepath.Join(dataRoot, "maestro"))
if err != nil {
t.Fatalf("NewFileStore after rotate: %v", err)
}
strPtr := func(v string) *string { return &v }
project := &composetypes.Project{Services: map[string]composetypes.ServiceConfig{
"app": {Environment: map[string]*string{"DB_PASSWORD": strPtr("public-placeholder")}},
}}
err = injectConfigSecrets(project, "svc")
if err == nil {
t.Fatal("injectConfigSecrets should fail when a set password cannot be decrypted")
}
app := project.Services["app"]
if got := envValue(app.Environment, "DB_PASSWORD"); got != "public-placeholder" {
t.Errorf("DB_PASSWORD = %q, want compose default preserved (injection should not have mutated the project)", got)
}
}
// TestInjectConfigSecretsNoRows ensures a composition with zero operator-set
// rows is left completely untouched.
func TestInjectConfigSecretsNoRows(t *testing.T) {