Runbook — Live-stack CI lane operations
Applies to: the heavy analyzer CI lane that stands up the full ~40-service stack from a bare checkout (.gitea/workflows/e2e-analyzer-coverage.yml; runs-on: apparmor-host). These reproduce the whole deploy from clean, so they surface drift no other lane catches. Companion: docs/runbooks/registry/ci-registry-pull-through-cache.md, the live-deploy-operator role playbook (AGENTS.md — Memory), the orchestrator role playbook (AGENTS.md — Memory). Verify against src/ and the workflow YAML — code wins.
Meta-lesson first (it cost ~3 CI rounds): when a CI run’s behavior contradicts what the file shows, run
git blame/git diff HEADbefore theorizing about YAML/compose internals. Uncommitted working-tree edits to a concurrent file make the runner’s HEAD differ from what you’re reading.
Topology (ruled — Q-9, 2026-08-25: isolated-runner)
Live lanes run against a disposable Stella Ops estate colocated with the isolated runner: the stack is composed up on the runner host per run (or per operational window) and torn down after. The runner is never granted network reach into the remote developer estate — a sometimes-reachable remote estate produces greens that verify nothing, and a standing CI credential into a long-lived estate widens the trust boundary for no gain. Secret VALUES (the STELLAOPS_ADMIN_PASS-class preflights) stay operator-managed on the runner and are never committed. A lane failing because the runner host lacks the estate bring-up or its secrets is an OPERATIONAL provisioning gap (owner-attended), not a product or lane defect. Settlement receipt: docs/implplan/SPRINT_20260825_001_DevOps_ci_gate_liveness_restoration.md Decisions & Risks.
What these lanes need from a bare checkout (gitignored dev assets)
A fresh runner has none of the operator’s local secrets/certs/env. Provision in this order — each gap only surfaces after the previous is fixed:
.env(devops/compose/.env, gitignored) — image pins + ~15 generated secrets + the postgres password embedded literally in theSTELLAOPS_POSTGRES_*connection strings. Materialize withdevops/compose/scripts/init-local-env.sh --force(rendersenv/stellaops.env.local-dev.example). Do NOT pinPOSTGRES_PASSWORDin workflow env — it desyncs from the connection-string literal. Symptom:invalid empty volume spec/STELLAOPS_POSTGRES_<SERVICE>_CONNECTION must be set for ….- Dev TLS pfx —
devops/etc/authority/keys/kestrel-dev.pfxis gitignored (only the.crtis committed). Generate withdevops/compose/scripts/ensure-dev-certs.sh(self-signed; SANstella-ops.local,*.stella-ops.local,127.1.0.1,127.0.0.1). Symptom: AuthorityFileNotFoundException: …/kestrel-dev.pfx. - Cert perms — generated files are root-owned
0600; the Authority container runs non-root (UID 10001) over a read-only mount →UnauthorizedAccessException.chmod 0644the key/crt/pfx. - Scanner surface — append
devops/compose/docker-compose.scanner-surface-fix.override.yml(named volume + chown init): scanner surface validation rejects the dev HTTP S3 endpoint, and uid 10001 can’t write the surface tmpfs atmode:1777on apparmor-host. - Vault — registering an SCM integration stores its credential via Vault; wire
VAULT_ADDR/VAULT_TOKEN(dev root token) in the e2e override or integrations-web 500s.
Compliance golden-path disposable estate (CIL-2)
.gitea/workflows/compliance-golden-path.yml follows the same Q-9 topology but deliberately does not boot the full stack. It builds and retains one exact-SHA cohort in three serialized jobs (the host runner enforces a 60-minute per-job ceiling), then the fourth job creates only the services used by the signed compliance journey. No image is pushed.
The runtime chain is exactly, in order:
devops/compose/docker-compose.stella-ops.yml(one merged model containing infrastructure, services and Evidence)devops/compose/docker-compose.integrations.yml(customer-registry simulator only)devops/compose/docker-compose.compliance-golden-path.override.yml(last)
The canonical model defines both Evidence replica roles. The compliance override replaces their names, image tags, key material and resources for the disposable estate. Do not append the Evidence family file a second time. The lane builds and starts evidence-web only; evidence-worker appears in retained topology but the journey exercises no worker surface.
prepare-compliance-ci-env.sh creates the ignored mode-0600 .env, including distinct Authority client secrets and the service-owned database connections that the full render requires. It direction-checks every ${VAR:?} in the same file list, in the same order, so a newly required input makes the helper red instead of surfacing as a late Compose interpolation failure.
The disposable PostgreSQL override retains only its per-run data volume and omits the inherited predecessor initialization scripts. Those scripts pre-create Authority schemas as the cluster administrator, which prevents the constrained service owner from creating its migration ledger. Each service creates its own schemas through startup migrations on an empty database. The workflow contract renders the exact Compose chain and proves that restoring the initialization bind fails the guard.
PostgreSQL starts first; the workflow provisions and isolation-probes authority, evidence, integrations, jobengine, notify, policy, registry, sbomservice, and scanner before any application starts. Platform is outside these nine provisioned service databases. The required NOLOGIN migration admin roles are pre-created separately; each service runtime retains its constrained role. JobEngine is defined by the canonical model but is not started by this bounded application cohort. Its generated owner connection and Doctor-client secret satisfy the full render and Authority bootstrap contracts.
Authority also supplies its reviewed migration tenant contexts through its owner-specific execution policy. Default-only operational repairs use their declared tenant; estate-wide permission retirements traverse actual catalog tenants within the existing transaction. A role bypass or a blanket default session is not a substitute for this policy. Immutable SQL and ledger checksums remain unchanged.
The overlay changes every selected fixed container name and every selected physical volume to the stellaops-compliance-* namespace, uses stellaops-compliance-network and stellaops-compliance-frontdoor, and remaps only the two host-facing fixtures:
- Gateway:
127.1.0.1:18080(HTTP) and127.1.0.1:18443(HTTPS). - Customer registry:
127.1.2.9:15000.
The journey therefore sets BASE_URL=https://stella-ops.local:18443, RESOLVE_PORT=18443, REGISTRY_HOST=127.1.2.9:15000, and PG_CONTAINER=stellaops-compliance-postgres. It extracts only the generated admin password key from .env, masks it before use, and never shell-sources the file (PostgreSQL DSNs contain semicolons). The per-run ExportCenter signing key’s public SPKI and fingerprint replace the stale development trust root in the disposable container only.
Three private keys are generated per run into the git-ignored devops/etc/secrets/, because a fresh checkout carries none and each host fails closed without its own: authority-signing, authority-ack-token, and attestor-signing. The third is the consolidated Evidence roles’ local transparency-log checkpoint signing key, and it differs from the other two in one way that matters: its public half IS pinned, inline and in two shapes, by docker-compose.evidence.yml. The lane therefore derives the public half and feeds it back through the override, exactly as it does for the SoA trust root. Mounting a private half that does not match the inline pin renders perfectly and then fails at verification time.
The messaging and Notify runtime-plugin keys are resolved by purpose through Stella Ops cryptography before packaging. In a fresh disposable checkout, CI explicitly enrolls each missing module key with the existing signing tool’s enroll-runtime-key command, then exports its public trust. Existing usable keys are reused; enrollment refuses to replace an existing unusable key. The packager receives the same ignored .local-secrets/compliance-runtime-signing root and continues to fail closed if lookup or signing fails. Private material is neither uploaded with evidence nor copied from the long-lived developer estate.
Authority validates the complete Standard bootstrap-client descriptor, including clients whose services are outside this bounded cohort. The environment preparer supplies the Signals and OfflineKit Doctor client credentials and the CI override forwards them to Authority. The rendered-topology test derives every environment-backed confidential client from standard.yaml and requires a nonempty container value, so adding a descriptor without provisioning fails before the disposable estate starts.
All host-stack workflows share concurrency group host-stack-e2e. The compliance runtime job pre-cleans only its own project, refuses any other stellaops-* fixed-name estate or occupied high port, uploads journey/build/topology/database evidence on red or green, and then always runs project-scoped down -v --remove-orphans. Completion additionally proves no project container, run-scoped network, run-scoped volume, or exact-SHA Scanner cache remains. A health check, image build, or successful teardown without every build/journey artifact is not a compliance pass. This lane therefore uses the Gitea-compatible upload-artifact@v3 action as a hard evidence gate rather than applying the general continue-on-error artifact-plumbing allowance below.
The recurring failure classes (durable — these recur across lanes and real deploys)
- OIDC metadata over HTTPS to a self-signed Authority → token validation fails. .NET-on-Linux won’t reliably trust the self-signed cert. Fix/posture: every resource server fetches OIDC metadata over the internal
http://authority.stella-ops.local/.well-known/openid-configuration(RequireHttpsMetadataalready false; the public issuer stays https). This is the consistent dev posture — seedocs/runbooks/authority/authority-gateway-auth-troubleshooting.md§1. - Cached
:devimage drift — the big one. e2e lanes that run cached images are blind to any change in a service’s source or mounted-config contract. A service can crash-loop on an old message (e.g. Authority bootstrap-secret handling) while current source is correct. Fix: add the changed service to the lane’sALWAYS_REBUILD(the analyzer lane already rebuilds scanner/authority/integrations each run; airgap rebuilds concelier/notify/authority). The pragmatic systemic guard = rebuild every service whose source/mounted-config changed. This drift is main-wide — the real deployed stack needs the same rebuilds. - Multi-tenant
client_credentials→ ambiguous tenant. A client withtenants: "a b"and no default can’t resolve a tenant on a client_credentials grant (no account to default from — unlike a password grant). Fix: set the client’sDefaultTenant(e.g.CONCELIER_AUTHORITY__DEFAULTTENANT), don’t omit the tenant. Symptom: outbound auth400 invalid_request, often swallowed (best-effort publish) so a downstream notification silently never arrives. - Password grant tenant selection. A password grant authenticates an account; the tenant is the account’s home tenant, never a required request param (
requireExplicitSelection=falsefor password grant). An explicit tenant is still validated against the client allow-list.
Concurrency
The analyzer lane uses fixed container names (stellaops-valkey, …), so no other disposable estate may overlap it. The former Concelier air-gap lane no longer starts containers after VULN-G6.
Diagnostics that pay off
- Distinguish “secret absent from container” vs “present but app reads empty”: a non-leaking
docker inspect <ctr> --format '{{range .Config.Env}}…{{end}}' | awk 'KEY=len:N'dump (lengths only, no values) on the failure path. This instantly localizes bootstrap-secret problems. - Dump base-service logs on any failure (authority/platform/router-gateway/postgres), not just the feature service — a dependency crash-loop otherwise shows only as a compose “Restarting” line.
- The bring-up step often health-gates only postgres+authority; other services can crash-loop unnoticed — check all container health, not just the gate.
Artifacts caveat
The Gitea runner reports as GHES: actions/upload-artifact@v4 hard-fails the job at upload even when tests passed. Use @v3 with continue-on-error so artifact plumbing never sets the verdict.
Verify (live forcing-function)
A lane is green only when it reaches and passes its real harness (analyzer corpus / the full federation export→download→verify→import→notifier→Mailpit flow), not when the stack merely boots.
Compose health inheritance validation
Run bash tools/scripts/validate/check-compose-healthchecks.sh --self-test and the same command without --self-test after changing a registered Compose chain. The guard reads the RAR, SCN and overlay owner manifests, including the SCN current record. Current chains must retain the accepted predecessor in order and name a digest-pinned current profile. Service identity and source provenance remain the respective owner checker’s responsibility.
An explicitly registered private chain input may use its tracked .yml.example sibling in a clean checkout. This admission covers only a single owned service with image, pull_policy: never and named scalar environment settings. A present private input must have the same shape. The guard rejects untracked examples, redirected paths, missing tracked inputs, additional fields, private includes and healthcheck overrides. An earlier base must still supply an enabled healthcheck that can fail. This does not exempt arbitrary examples or change the shrink-only unchecked-service limits.
For the RAR-owned Platform and Scanner callers, run node tools/scripts/validate/check-local-runtime-sources.cjs --root <committed-checkout> and --self-test. Its EVD-5 tail profiles retain exact accepted image, source and URL values from the September 12 consumer receipt; it checks their tracked examples and any present private inputs. Earlier profile and Gateway pins remain independently checked. The Platform Notify queue check compares the canonical JobEngine Scheduler producer; a retired scheduler-web declaration cannot satisfy that producer requirement.
The SCN source checker (node tools/scripts/validate/check-local-scn-runtime-sources.cjs) accepts only the three registered EVD-5 Policy/ReleaseOrchestrator .example profiles when their private files are absent. Their committed examples must have the exact accepted owner, image and environment values. Present private files are preferred and checked too; missing, untracked, redirected or altered examples fail. Its receipt lists substituted profiles and keeps executionReady: false. Runtime replay/probe/preflight operations still require actual inputs and cannot substitute examples. Run the checker’s --self-test for source admission and unchanged current identity rejection controls.
