Files
Maestro/src/lib/containers/start.go
T
Admin a9e2182396 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.
2026-08-31 00:48:51 +01:00

455 lines
16 KiB
Go

// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"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"
)
// identityMountDir is where Maestro bind-mounts the instance CA and the
// service's identity certificate inside every peer container.
const identityMountDir = "/dcntrlized/pki"
// StartContainer creates and starts the service containers defined in the
// service's docker-compose.yaml.
func StartContainer(ctx context.Context, item types.RegistryContainer) error {
dc, err := docker.NewDockerClient()
if err != nil {
return fmt.Errorf("failed to initialize docker client: %w", err)
}
defer dc.Close()
serviceName := resolveServiceName(ctx, dc, item.Name)
project, err := loadComposeProject(ctx, serviceName)
if err != nil {
return err
}
// Infrastructure first: the external dcntrlized_net network must exist
// before any compose project is started, and services that depend on the
// shared database need it running before they boot.
if err := EnsureDCNTRLIZEDNetwork(ctx, dc); err != nil {
return err
}
if serviceName != DatabaseServiceName {
if err := EnsureDatabaseRunning(ctx); err != nil {
logger.Warn("Failed to ensure shared database before starting %s: %v", serviceName, err)
}
}
// Issue (or refresh) the service's identity certificate from the instance
// CA and mount it plus the CA bundle into the container. The compose file
// downloaded from git never declares these mounts — Maestro injects them
// (security theme M1 §2). Failing to provision identity is fatal: a peer
// without a cert cannot authenticate on the mTLS edges. Database-capable
// services additionally get their per-service database role DSN here (M2
// §4): on an armed (mTLS) shared database the shared-superuser URL from
// their compose file cannot connect at all.
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.
// Make sure those short names exist so Up() can find them.
installedImage := item.Image
if installedImage == "" {
if dir, dirErr := getContainerDataDir(serviceName); dirErr == nil {
if metadata, metaErr := readInstalledMetadata(dir); metaErr == nil {
installedImage = metadata.Image
}
}
}
pinServiceImages(project, installedImage)
ensureServiceImages(ctx, dc, project, installedImage)
composeService, err := newComposeService(dc)
if err != nil {
return err
}
logger.Info("Starting service %s...", serviceName)
err = composeService.Up(ctx, project, api.UpOptions{
Create: api.CreateOptions{
Recreate: api.RecreateDiverged,
AssumeYes: true,
},
Start: api.StartOptions{},
})
if err != nil {
return fmt.Errorf("failed to start service %s: %w", serviceName, err)
}
logger.Info("Successfully started service: %s", serviceName)
return nil
}
// injectServiceIdentity issues (or refreshes) the service's identity
// certificate from the instance CA and injects the CA bundle, the identity
// cert/key, and the mTLS env vars into every non-oneoff compose service. The
// env vars follow the shared-module contract (AUTH_CA_BUNDLE/AUTH_CLIENT_CERT/
// AUTH_CLIENT_KEY) so consumers need no per-service config.
//
// It also injects AUTH_PINNED_FINGERPRINT (M2 §5 TOFU): the auth signing key's
// fingerprint recorded at install. A service that already has the pin skips the
// fetch; one installed before this feature (or whose install could not reach
// the auth service) backfills it here, best-effort.
//
// For services declaring the database capability (M2 §4), when the shared
// database runs mTLS-armed, every app service additionally receives a
// per-service DATABASE_URL (role = the service's cert CN, verify-full against
// the instance CA), overriding the shared-superuser URL their compose file
// ships. One-off helpers (migrate/seed) get the identity mount plus libpq SSL
// env so they can reach the armed database under the same role.
func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, serviceName string) error {
dataRoot, err := getDataRoot()
if err != nil {
return fmt.Errorf("resolve data root: %w", err)
}
ca, err := pki.EnsureCA(dataRoot)
if err != nil {
return fmt.Errorf("ensure instance CA: %w", err)
}
identity, err := pki.EnsureServiceIdentity(ca, serviceName)
if err != nil {
return fmt.Errorf("provision identity for %s: %w", serviceName, err)
}
mounts := []string{
fmt.Sprintf("%s:%s/ca.crt:ro", hostBindSource(dataRoot, pki.CertPath(dataRoot)), identityMountDir),
fmt.Sprintf("%s:%s/tls.crt:ro", hostBindSource(dataRoot, identity.CertPath), identityMountDir),
fmt.Sprintf("%s:%s/tls.key:ro", hostBindSource(dataRoot, identity.KeyPath), identityMountDir),
}
env := map[string]string{
"SERVICE_NAME": serviceName,
"AUTH_CA_BUNDLE": identityMountDir + "/ca.crt",
"AUTH_CLIENT_CERT": identityMountDir + "/tls.crt",
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
}
// Per-service database role DSN (M2 §4). Only on an armed database — a
// plain (compose-managed dev) Postgres cannot satisfy verify-full, and its
// stock trust rule needs no per-service roles anyway. Best-effort: without
// the capability in the install record (pre-manifest installs) nothing is
// injected and the compose defaults apply.
dbEnv := map[string]string{}
if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) {
if dbctl.New(dc).Armed(ctx) {
env["DATABASE_URL"] = mtlsDatabaseURL(serviceName)
dbEnv = map[string]string{
"PGUSER": serviceName,
"PGSSLMODE": "verify-full",
"PGSSLCERT": identityMountDir + "/tls.crt",
"PGSSLKEY": identityMountDir + "/tls.key",
"PGSSLROOTCERT": identityMountDir + "/ca.crt",
}
logger.Info("Injected per-service database DSN for %s (armed shared database)", serviceName)
}
}
// The auth service is the attester itself — nothing to pin there. For every
// other peer, pin its signing key (fetch + persist once).
if serviceName != AuthServiceName {
if pinned := pinAuthFingerprint(ctx, serviceName); pinned != "" {
env["AUTH_PINNED_FINGERPRINT"] = pinned
}
}
for name, s := range project.Services {
if isOneoff(s) {
// Migrate/seed helpers talk libpq/psycopg straight to the shared
// database; on an armed database they need the identity mount plus
// the same per-service role (their compose file's PGUSER is the
// plain-dev default).
if len(dbEnv) > 0 {
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
s.Environment = mergeEnv(s.Environment, dbEnv)
project.Services[name] = s
}
continue
}
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
s.Environment = mergeEnv(s.Environment, env)
project.Services[name] = s
}
logger.Info("Injected service identity for %s (fingerprint %s)", serviceName, identity.Meta.Fingerprint)
return nil
}
// mtlsDatabaseURL builds the per-service connection URL for the shared
// database: role = the service's cert CN, no password (clientcert=verify-full
// makes the certificate the credential), TLS verified against the instance CA.
func mtlsDatabaseURL(serviceName string) string {
return fmt.Sprintf(
"postgres://%s@%s:5432/%s?sslmode=verify-full&sslrootcert=%s/ca.crt&sslcert=%s/tls.crt&sslkey=%s/tls.key",
serviceName, DatabaseServiceName, dbctl.DBName,
identityMountDir, identityMountDir, identityMountDir,
)
}
// 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 {
dir, err := getContainerDataDir(serviceName)
if err != nil {
return false
}
metadata, err := readInstalledMetadata(dir)
if err != nil {
return false
}
for _, c := range metadata.Capabilities {
if c == "database" {
return true
}
}
return false
}
// hostBindSource maps a dataRoot-relative path to a bind-mount source the
// Docker daemon can resolve. Bind sources are resolved on the daemon's host
// namespace, but inside the dev container the data root lives at a
// container-internal path (/data/.dcntrlized via XDG_DATA_HOME=/data) that
// does not exist on the host — the daemon would silently create it as an
// empty directory. DCNTRLIZED_HOST_DATA_ROOT (compose-injected, e.g.
// $HOME/.dcntrlized) carries the host path backing the data root; when set,
// the container-internal prefix is swapped for it. On a host install the path
// is already host-resolvable and returned unchanged.
func hostBindSource(dataRoot, p string) string {
if host := os.Getenv("DCNTRLIZED_HOST_DATA_ROOT"); host != "" && strings.HasPrefix(p, dataRoot) {
return filepath.Join(host, strings.TrimPrefix(p, dataRoot))
}
return p
}
// pinAuthFingerprint returns the service's pinned auth fingerprint, fetching
// and persisting it when the install record does not have one yet (M2 §5).
// Best-effort: when the auth service is unreachable, it returns "" and the
// next start retries.
func pinAuthFingerprint(ctx context.Context, serviceName string) string {
dir, err := getContainerDataDir(serviceName)
if err != nil {
return ""
}
metadata, err := readInstalledMetadata(dir)
if err != nil {
return ""
}
if metadata.AuthFingerprint != "" {
return metadata.AuthFingerprint
}
att, err := fetchAuthAttestation(ctx)
if err != nil {
logger.Warn("Auth pin for %s: attestation fetch failed (will retry at next start): %v", serviceName, err)
return ""
}
if att.Fingerprint == "" {
return ""
}
metadata.AuthFingerprint = att.Fingerprint
metadata.AuthKid = att.Kid
metadata.AuthAttestedAt = att.IssuedAt
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
logger.Warn("Auth pin for %s: persist failed: %v", serviceName, err)
}
logger.Info("Pinned auth fingerprint %s for %s", att.Fingerprint, serviceName)
return att.Fingerprint
}
// composeVolumes converts "src:dst:ro" mount strings to compose volume configs.
func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig {
out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts))
for _, m := range mounts {
parts := strings.SplitN(m, ":", 3)
v := composetypes.ServiceVolumeConfig{
Type: "bind",
Source: parts[0],
Target: parts[1],
}
if len(parts) == 3 && strings.Contains(parts[2], "ro") {
v.ReadOnly = true
}
out = append(out, v)
}
return out
}
// isOneoff reports whether a compose service is a one-shot helper (e.g. the
// migrate container) via the org.dcntrlized.oneoff label the compose file
// declares.
func isOneoff(s composetypes.ServiceConfig) bool {
if s.CustomLabels == nil {
return false
}
v, ok := s.CustomLabels["org.dcntrlized.oneoff"]
if !ok {
v = s.Labels["org.dcntrlized.oneoff"]
}
return v == "true"
}
// mergeEnv merges a map into the compose service's env slice, overriding any
// existing value with the same key.
func mergeEnv(existing map[string]*string, add map[string]string) map[string]*string {
if existing == nil {
existing = map[string]*string{}
}
for k, v := range add {
val := v
existing[k] = &val
}
return existing
}
// pinServiceImages rewrites each compose service's image reference to the exact
// installed image (e.g. git.../testservice:3.4.0) when its base name matches, so
// containers run pinned to a concrete semver tag instead of a moving "latest" tag.
func pinServiceImages(project *composetypes.Project, installedImage string) {
if installedImage == "" {
return
}
for name, s := range project.Services {
if s.Image == "" || s.Image == installedImage {
continue
}
if baseImageName(s.Image) != baseImageName(installedImage) {
continue
}
logger.Info("Pinning service %s image %s to installed image %s", name, s.Image, installedImage)
s.Image = installedImage
project.Services[name] = s
}
}
// ensureServiceImages makes sure every image referenced by the compose project
// exists locally. Registries install the image under its full reference (e.g.
// git.../testservice:3.4) while the service's compose file often references it
// by a short name (e.g. "testservice"); when the short name is missing locally
// and its base name matches the installed image, it is tagged so start works.
func ensureServiceImages(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, installedImage string) {
if installedImage == "" {
return
}
for name, s := range project.Services {
ref := s.Image
if ref == "" || ref == installedImage {
continue
}
if imageExists(ctx, dc, ref) {
continue
}
if baseImageName(ref) != baseImageName(installedImage) {
continue
}
logger.Info("Tagging %s as %s for compose service %s", installedImage, ref, name)
if err := dc.Client.ImageTag(ctx, installedImage, ref); err != nil {
logger.Warn("Failed to tag %s as %s: %v", installedImage, ref, err)
}
}
}
// imageExists reports whether an image reference exists locally.
func imageExists(ctx context.Context, dc *docker.DockerClient, ref string) bool {
_, _, err := dc.Client.ImageInspectWithRaw(ctx, ref)
return err == nil
}
// baseImageName strips the registry/namespace and tag from an image reference,
// returning the repository name (e.g. "testservice").
func baseImageName(ref string) string {
base := ref
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
if i := strings.LastIndex(base, ":"); i >= 0 {
base = base[:i]
}
return base
}