// 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 . 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 }