diff --git a/.derived/codebase-index/by-spec/006-fleet.json b/.derived/codebase-index/by-spec/006-fleet.json index cbc7a86..cd7cf86 100644 --- a/.derived/codebase-index/by-spec/006-fleet.json +++ b/.derived/codebase-index/by-spec/006-fleet.json @@ -28,5 +28,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "f047fde050b7ef249853c6c497c7a1bbabe267ca679b36528ecf4e47e1301020" + "shardHash": "6fb00aa7d68b4675557a231d91033bd26c1f35cc227736ebc02dcb79f1d4d5b8" } diff --git a/.derived/codebase-index/by-spec/009-control-plane-deploy.json b/.derived/codebase-index/by-spec/009-control-plane-deploy.json index 8fd3084..e358747 100644 --- a/.derived/codebase-index/by-spec/009-control-plane-deploy.json +++ b/.derived/codebase-index/by-spec/009-control-plane-deploy.json @@ -115,5 +115,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "5478dde659fdaf2418cf2ac372aaef462b2d145b85ac9b7282afb0a186afdf35" + "shardHash": "921b56b33fbbc47ab67416fec2c146adf2bc1cf1813d9ee48b153b21f6812354" } diff --git a/.derived/codebase-index/by-spec/010-statecraft-cluster.json b/.derived/codebase-index/by-spec/010-statecraft-cluster.json index 4c9f44d..db3e379 100644 --- a/.derived/codebase-index/by-spec/010-statecraft-cluster.json +++ b/.derived/codebase-index/by-spec/010-statecraft-cluster.json @@ -28,5 +28,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "488a8a9034c90945a6281eb07cc89fc3b831ce55b761e530da918bdca5a62eb7" + "shardHash": "e66285f43ed7e810579a06a20c094791e6ac1434c56c61be67c173478be45481" } diff --git a/.derived/spec-registry/by-spec/006-fleet.json b/.derived/spec-registry/by-spec/006-fleet.json index 32619aa..32bc808 100644 --- a/.derived/spec-registry/by-spec/006-fleet.json +++ b/.derived/spec-registry/by-spec/006-fleet.json @@ -25,13 +25,14 @@ "5. Out of scope", "2026-07-20: the addon transferred out; this spec narrowed, not retired", "Amendment (2026-07-21): spec 011 tenant lifecycle", - "Amendment (2026-07-22): remove-gate confirmation attributes" + "Amendment (2026-07-22): remove-gate confirmation attributes", + "Amendment (2026-07-23): deploy-chosen container port" ], "specPath": "specs/006-fleet/spec.md", "status": "approved", "summary": "Milestone M3: operate stamped apps. The unit of placement is \"one EnRaHiTu container + one volume + one ingress\" on the existing hetzner-k3s cluster. deployd-api-rs (the OAP-era Rust K8s orchestrator, axum + hiqlite) donates its orchestration core as a napi-rs addon (the hiqlite-native pattern): the axum HTTP layer disappears, the K8s knowledge stays. A fleet/ Encore service exposes deploy / status / update / backup over the addon. Done-when is a fleet of ten stamped apps on one box with update and backup exercised.\n", "title": "Fleet: deployd's core as an in-process addon, placing EnRaHiTu apps" }, - "shardHash": "2519cf1793b565a51af9968b30333967442bc1e027055e6a5fa352659724fad7", + "shardHash": "67db401155879258649c91fd1e408de84c840ddaa8223f2d4e31692f4ebc4539", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/009-control-plane-deploy.json b/.derived/spec-registry/by-spec/009-control-plane-deploy.json index 5b8a37f..1bad17e 100644 --- a/.derived/spec-registry/by-spec/009-control-plane-deploy.json +++ b/.derived/spec-registry/by-spec/009-control-plane-deploy.json @@ -68,6 +68,6 @@ "summary": "Stand the control plane up on the spec 010 cluster as a platform-grade K8s deployment at app.statecraft.ing. Rewritten ground-up 2026-07-19 to the two-plane thesis (001 section 3), which unpauses it. The rewrite inverts the deploy's job: the published image is embedded-rauthy and self-seeds its own identity on first boot, so the shared-rauthy secret set is void, five of the eleven Encore secrets are discarded by the entrypoint if injected, and the /data volume becomes the identity anchor of the platform. What the deploy still owes the container: nine real secrets, the non-secret env, a Postgres URL, an ingress, and one seeder pass for the only thing first boot cannot seed (the upstream GitHub provider, which rauthy has no declarative bootstrap for). Operator surfaces gate on the custom statecraft_operator role, seeded here; rauthy_admin stays break-glass. Live bring-up is a human checkpoint.\n", "title": "The control-plane deploy: one governed container on the statecraft cluster" }, - "shardHash": "8a779d39ed43b1e0ef827c010b36a9786c59a82a334a8e914a505c072178b1dd", + "shardHash": "5becd8699f22edf419ce219bc450f8db5626707821e7073ea8785a2af538c78d", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/010-statecraft-cluster.json b/.derived/spec-registry/by-spec/010-statecraft-cluster.json index 1985695..7813601 100644 --- a/.derived/spec-registry/by-spec/010-statecraft-cluster.json +++ b/.derived/spec-registry/by-spec/010-statecraft-cluster.json @@ -31,13 +31,14 @@ "6. Human checkpoints", "7. Out of scope", "Amendment (2026-07-22): the RAUTHY_API_KEY catalog delta", - "Amendment (2026-07-22): spec 012 frontend-admin adoption, the scrape lands" + "Amendment (2026-07-22): spec 012 frontend-admin adoption, the scrape lands", + "Amendment (2026-07-23): FLEET_IMAGE_PULL_SECRET is a name, not a credential" ], "specPath": "specs/010-statecraft-cluster/spec.md", "status": "approved", "summary": "The statecraft-owned hetzner-k3s cluster, reconciled by Flux from an in-repo GitOps tree, with one documented secret source that generates the operator `.env.example` and the SOPS-encrypted secrets Flux decrypts in-cluster. Rewritten ground-up 2026-07-19 to the two-plane thesis (001 §3): identity and observability moved inside the control-plane container, so the cluster keeps only what a container cannot do for itself. The standalone cluster rauthy is retired (embedded rauthy is THE platform IdP) and cluster Grafana is dropped with its OIDC client (platform observability is the in-substrate flag-gated admin dashboard); Prometheus is kept, demoted to an unexposed in-cluster metrics sink. What stands: the Flux tree, SOPS, cert-manager, ingress-nginx, reflector, Postgres, NSQ, and Hetzner Object Storage. The cluster is live (PRs #27-#29); this rewrite is the first change that prunes services from it. Amended 2026-07-20 on explicit operator authorization, closing spec 009 checkpoint 1: the claim that every `RAUTHY_*` key survives the move into the container is corrected to the verified image behavior, which self-seeds its own identity. The catalog goes from 47 keys to 33, `auth.` is settled as not returning, and the keys a deploy genuinely owes the container are named.\n", "title": "The statecraft cluster: the substrate beneath the control-plane container" }, - "shardHash": "5f1d8fc39f38a8d5ef07773e6405fb69f6d72b6c5e70a22e38d0e7e5c0a238ac", + "shardHash": "c169aeed543fd75420f2d4e95ee6272b4b04142778bf8e0b4654f272c25c0ba2", "specVersion": "1.1.0" } diff --git a/backend/fleet/api.ts b/backend/fleet/api.ts index 2e19348..5e1e2a0 100644 --- a/backend/fleet/api.ts +++ b/backend/fleet/api.ts @@ -17,7 +17,7 @@ import { backupTarget, fleetBaseDomain, fleetImagePullSecret } from "./config"; import type { FleetApp, FleetAppStatus } from "./entities"; import { gateOrDeny } from "./gate"; import * as native from "./native"; -import { isValidAppName } from "./ops"; +import { FLEET_DEFAULT_PORT, isValidAppName, isValidPort } from "./ops"; import { createApp, finishOp, @@ -38,6 +38,7 @@ export interface FleetAppView { namespace: string; image: string; volumeSize: number; + port: number; host: string; status: string; createdAt: string; @@ -53,6 +54,7 @@ function toView(a: FleetApp): FleetAppView { namespace: a.namespace, image: a.image, volumeSize: a.volumeSize, + port: a.port, host: a.host, status: a.status, createdAt: a.createdAt.toISOString(), @@ -99,6 +101,15 @@ interface DeployRequest { name: string; image: string; volumeSize?: number; + /** + * Container port the image serves (default 4000, the addon's default; + * integer 1024-65535, privileged ports rejected because placed pods run + * non-root). enrahitu chassis images are fixed on 8080, so placing one + * requires passing it here; the probes, Service, and Ingress all key off + * it. Immutable after deploy: update forwards the persisted value, so + * changing it means remove + redeploy. + */ + port?: number; stampJobId?: string; } @@ -109,7 +120,7 @@ interface DeployRequest { */ export const deploy = api( { expose: true, auth: true, method: "POST", path: "/api/v1/tenants/:id/fleet" }, - async ({ id, name, image, volumeSize, stampJobId }: DeployRequest): Promise => { + async ({ id, name, image, volumeSize, port, stampJobId }: DeployRequest): Promise => { const auth = getAuthData()!; const tenant = await authorizeTenant(id, principalFrom(auth), "write"); if (!tenant) throw APIError.notFound("tenant not found"); @@ -128,6 +139,12 @@ export const deploy = api( const namespace = namespaceFor(id); const host = `${appName}.${domain}`; const size = volumeSize && volumeSize > 0 ? volumeSize : 1; + const appPort = port ?? FLEET_DEFAULT_PORT; + if (!isValidPort(appPort)) { + throw APIError.invalidArgument( + "port must be an integer between 1024 and 65535 (placed pods run non-root and cannot bind privileged ports)", + ); + } const pullSecret = fleetImagePullSecret(); const gated = await gateOrDeny("deploy", { tenantId: id, app: appName, image: img }, "soft"); @@ -138,6 +155,7 @@ export const deploy = api( namespace, image: img, volumeSize: size, + port: appPort, host, }); const op = await startOp(app.id, "deploy"); @@ -149,6 +167,7 @@ export const deploy = api( image: img, host, volumeSizeGi: size, + port: appPort, ...(pullSecret ? { imagePullSecret: pullSecret } : {}), }); const ok = status.status === "running"; @@ -157,7 +176,7 @@ export const deploy = api( host: status.host || host, }); await finishOp(op.id, ok ? "succeeded" : "failed", status.message ?? null); - await record("deploy", app, auth.userID, { tenantId: id, app: appName, namespace, image: img, host }, gated.configHash); + await record("deploy", app, auth.userID, { tenantId: id, app: appName, namespace, image: img, host, port: appPort }, gated.configHash); logInfo("fleet.deployed", { app: app.id, namespace, host, ok }); return toView((await getApp(app.id))!); } catch (err) { @@ -226,12 +245,16 @@ export const update = api( image: img, host: app.host, volumeSizeGi: app.volumeSize, + // The deploy-chosen port, persisted on the row: update rebuilds the + // Deployment spec, and omitting it would revert probes to the addon + // default (4000) on every image change. + port: app.port, ...(pullSecret ? { imagePullSecret: pullSecret } : {}), }); const ok = live.status === "running"; await setAppStatus(app.id, ok ? "running" : "failed", { image: img, host: live.host || app.host }); await finishOp(op.id, ok ? "succeeded" : "failed", live.message ?? null); - await record("update", app, auth.userID, { tenantId: app.tenantId, app: app.name, image: img }, gated.configHash); + await record("update", app, auth.userID, { tenantId: app.tenantId, app: app.name, image: img, port: app.port }, gated.configHash); logInfo("fleet.updated", { app: app.id, image: img, ok }); return toView((await getApp(app.id))!); } catch (err) { diff --git a/backend/fleet/entities.test.ts b/backend/fleet/entities.test.ts index 67dee5d..8e33b0b 100644 --- a/backend/fleet/entities.test.ts +++ b/backend/fleet/entities.test.ts @@ -57,6 +57,7 @@ for (const arm of arms) { namespace: `t-${randomUUID()}`, image: "ghcr.io/acme/app:v1", volumeSize: 3, + port: 8080, host: "acme.deployd.xyz", status: "placing" as const, }); @@ -65,6 +66,7 @@ for (const arm of arms) { const back = await repo.findById(app.id); expect(back?.image).toBe("ghcr.io/acme/app:v1"); expect(back?.volumeSize).toBe(3); + expect(back?.port).toBe(8080); expect(back?.status).toBe("placing"); expect(back?.stampJobId).toBeNull(); diff --git a/backend/fleet/entities.ts b/backend/fleet/entities.ts index bb17839..4271c7b 100644 --- a/backend/fleet/entities.ts +++ b/backend/fleet/entities.ts @@ -31,6 +31,13 @@ export class FleetApp { @Column() image = ""; /** PVC size in GiB (default 1). */ @Column({ type: "integer" }) volumeSize = 1; + /** + * Container port the app serves; the addon keys PORT env, probes, Service, + * and Ingress off it. Persisted so update rebuilds the Deployment with the + * same port the deploy chose (enrahitu chassis images are fixed on 8080; + * the addon default is 4000). + */ + @Column({ type: "integer" }) port = 4000; @Column() host = ""; @Column({ index: true }) status: FleetAppStatus = "placing"; @Column({ type: "timestamp" }) createdAt = new Date(); diff --git a/backend/fleet/ops.test.ts b/backend/fleet/ops.test.ts index 35f65c2..70597f6 100644 --- a/backend/fleet/ops.test.ts +++ b/backend/fleet/ops.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest"; import type { FleetAppStatus } from "./entities"; import { + FLEET_DEFAULT_PORT, canTransitionApp, canTransitionOp, type FleetOpStatus, isValidAppName, + isValidPort, } from "./ops"; describe("fleet-op state machine", () => { @@ -65,3 +67,21 @@ describe("app name validation (DNS-1123 label)", () => { } }); }); + +describe("container port validation", () => { + it("accepts the unprivileged range boundaries and the defaults in use", () => { + for (const p of [1024, FLEET_DEFAULT_PORT, 8080, 65535]) { + expect(isValidPort(p)).toBe(true); + } + }); + + it("rejects privileged, out-of-range, and non-integer ports", () => { + for (const p of [0, 1, 80, 443, 1023, 65536, -8080, 8080.5, Number.NaN, Infinity]) { + expect(isValidPort(p)).toBe(false); + } + }); + + it("treats float-typed integers as integers (JS number semantics)", () => { + expect(isValidPort(8080.0)).toBe(true); + }); +}); diff --git a/backend/fleet/ops.ts b/backend/fleet/ops.ts index 8251c34..4ec7057 100644 --- a/backend/fleet/ops.ts +++ b/backend/fleet/ops.ts @@ -60,3 +60,16 @@ export class InvalidAppTransitionError extends Error { export function isValidAppName(name: string): boolean { return name.length <= 63 && /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name); } + +/** The addon's default container port, applied when deploy names none. */ +export const FLEET_DEFAULT_PORT = 4000; + +/** + * The deploy-chosen container port. Privileged ports are rejected up front: + * placed pods run as a non-root UID with no NET_BIND_SERVICE, so a port + * below 1024 cannot be bound and would hang the rollout wait on a probe + * aimed at a port nothing can listen on. + */ +export function isValidPort(port: number): boolean { + return Number.isInteger(port) && port >= 1024 && port <= 65535; +} diff --git a/backend/fleet/store.ts b/backend/fleet/store.ts index db49a87..178a9d0 100644 --- a/backend/fleet/store.ts +++ b/backend/fleet/store.ts @@ -75,6 +75,7 @@ export interface CreateAppInput { namespace: string; image: string; volumeSize: number; + port: number; host: string; } @@ -88,6 +89,7 @@ export async function createApp(input: CreateAppInput): Promise { namespace: input.namespace, image: input.image, volumeSize: input.volumeSize, + port: input.port, host: input.host, status: "placing" as FleetAppStatus, createdAt: now, diff --git a/infra/gitops/clusters/statecraft-hetzner/statecraft/deployment.yaml b/infra/gitops/clusters/statecraft-hetzner/statecraft/deployment.yaml index 906e067..396a582 100644 --- a/infra/gitops/clusters/statecraft-hetzner/statecraft/deployment.yaml +++ b/infra/gitops/clusters/statecraft-hetzner/statecraft/deployment.yaml @@ -181,6 +181,15 @@ spec: value: "https://nbg1.your-objectstorage.com" - name: FLEET_RESTIC_IMAGE value: "restic/restic:0.17.3" + # The NAME of the dockerconfigjson Secret fleet references as + # imagePullSecrets on placed pods; a resource name, not a + # credential, hence plain env and not the Secret. The Secret + # itself must be operator-provisioned in each tenant namespace: + # fleet's RBAC (rbac.yaml) deliberately grants nothing on + # secrets, so fleet can reference but never create it. Empty + # would mean public images only. + - name: FLEET_IMAGE_PULL_SECRET + value: "ghcr-pull" - name: FACTORY_TEMPLATE_REPO value: "statecrafting/enrahitu" - name: FACTORY_TEMPLATE_REF diff --git a/infra/hetzner/.env.example b/infra/hetzner/.env.example index 9643a50..c498771 100644 --- a/infra/hetzner/.env.example +++ b/infra/hetzner/.env.example @@ -110,6 +110,10 @@ GOOGLE_UPSTREAM_CLIENT_ID= # required: yes | consumer: seed-rauthy Job, once it converges a second provider; spec 009 section 4.5 specifies only the GitHub one | group: google_upstream (all-or-nothing) GOOGLE_UPSTREAM_CLIENT_SECRET= +# NAME of the kubernetes.io/dockerconfigjson Secret fleet references as imagePullSecrets on placed pods (ghcr-pull in production). A resource name, not a credential: the Secret itself is operator-provisioned in each tenant namespace, because fleet's RBAC deliberately grants nothing on secrets. Empty means public images only. Until 2026-07-23 this entry claimed to be the dockerconfigjson value itself, which no code ever read that way. +# required: yes | consumer: fleet (imagePullSecrets name on placed pods) +FLEET_IMAGE_PULL_SECRET= + # ========================================================================== # Generated: minted with crypto/rand or `npm run generate-keys`. # ========================================================================== @@ -165,11 +169,3 @@ RAUTHY_CLIENT_SECRET= # Rauthy admin API key in the form $, used by the seeder to create the GitHub upstream provider. Only the provider needs the API: the OIDC client is seeded declaratively at first boot and scopes are not converged at all. Not required yet, because first-boot.mjs does not compose api_keys.json (spec 009 section 4.8 item 3), so rauthy has not handed this value back. # required: no | consumer: seed-rauthy Job (spec 009 section 4.5); its own Secret, never the app pod RAUTHY_ADMIN_TOKEN= - -# ========================================================================== -# Derived: computed from another value (must equal the formula). -# ========================================================================== - -# Base64 dockerconfigjson (derived from GHCR_PAT) that fleet attaches to placed pods so they can pull private GHCR images. -# required: yes | consumer: fleet (image pull) -FLEET_IMAGE_PULL_SECRET= diff --git a/infra/secrets/catalog.toml b/infra/secrets/catalog.toml index 108c082..6ef5e87 100644 --- a/infra/secrets/catalog.toml +++ b/infra/secrets/catalog.toml @@ -23,9 +23,11 @@ # user-supplied the operator provides it (tokens, domain, upstream creds) # generated minted with crypto/rand or `npm run generate-keys` # provider-produced a provider hands it back (GitHub App, rauthy OIDC client) -# derived computed from another value (the sole survivor is the -# dockerconfigjson built from GHCR_PAT; the DOMAIN-derived -# service URLs were dropped, see the amendment below) +# derived computed from another value (no members today: the +# dockerconfigjson built from GHCR_PAT became the NAME of +# an operator-provisioned Secret, see +# FLEET_IMAGE_PULL_SECRET; the DOMAIN-derived service URLs +# were dropped, see the amendment below) # # `group` ties optional keys into an all-or-nothing set: if any member is set, # every member must be set; if none are set, the group is skipped as optional. @@ -442,17 +444,19 @@ consumer = "seed-rauthy Job (spec 009 section 4.5); its own Secret, never the ap description = "Rauthy admin API key in the form $, used by the seeder to create the GitHub upstream provider. Only the provider needs the API: the OIDC client is seeded declaratively at first boot and scopes are not converged at all. Not required yet, because first-boot.mjs does not compose api_keys.json (spec 009 section 4.8 item 3), so rauthy has not handed this value back." # --------------------------------------------------------------------------- -# Derived: computed from another value. The operator .env lists them -# explicitly. The two URL formulas that lived here (APP_BASE_URL and -# RAUTHY_URL) are gone with their consumers: no code reads either, and the -# issuer is now same-origin and derived by the entrypoint from -# ENRAHITU_PUBLIC_URL (spec 009 section 2.4). +# Fleet placement config. FLEET_IMAGE_PULL_SECRET sat here as "derived" +# (a dockerconfigjson computed from GHCR_PAT) until 2026-07-23; it is +# actually the NAME of the pre-provisioned pull Secret, per fleet/config.ts. +# The two URL formulas that lived here (APP_BASE_URL and RAUTHY_URL) are +# gone with their consumers: no code reads either, and the issuer is now +# same-origin and derived by the entrypoint from ENRAHITU_PUBLIC_URL +# (spec 009 section 2.4). # --------------------------------------------------------------------------- [[key]] name = "FLEET_IMAGE_PULL_SECRET" -provenance = "derived" -secret = true +provenance = "user-supplied" +secret = false required = true -consumer = "fleet (image pull)" -description = "Base64 dockerconfigjson (derived from GHCR_PAT) that fleet attaches to placed pods so they can pull private GHCR images." +consumer = "fleet (imagePullSecrets name on placed pods)" +description = "NAME of the kubernetes.io/dockerconfigjson Secret fleet references as imagePullSecrets on placed pods (ghcr-pull in production). A resource name, not a credential: the Secret itself is operator-provisioned in each tenant namespace, because fleet's RBAC deliberately grants nothing on secrets. Empty means public images only. Until 2026-07-23 this entry claimed to be the dockerconfigjson value itself, which no code ever read that way." diff --git a/specs/006-fleet/spec.md b/specs/006-fleet/spec.md index 5b2f70d..103aa8c 100644 --- a/specs/006-fleet/spec.md +++ b/specs/006-fleet/spec.md @@ -379,3 +379,44 @@ echoes `subject_name` (the app name) and `confirm_name` (the caller's typed confirm) in the remove gate attributes. The endpoint-level name-confirm guard is unchanged; the gate now sees the same evidence it demands. See specs/011-tenant-lifecycle/spec.md §9. + +## Amendment (2026-07-23): deploy-chosen container port + +The addon's `DeploySpec.port` (default 4000) was never reachable from the +HTTP API: `DeployRequest` had no `port` field, so every placement keyed the +container `PORT` env, the probes, the Service, and the Ingress backend off +4000. That default fits nothing this platform actually places: enrahitu +chassis images serve 8080, fixed in the image (`EXPOSE 8080`, an entrypoint +that never honors `$PORT`), so placing one would create every object and +then hang at the rollout wait on a probe aimed at a port nothing listens +on, a permanent failure indistinguishable from a slow start. + +`api.ts` now accepts an optional `port` on deploy (integer 1024-65535: +privileged ports are rejected up front because placed pods run as a +non-root UID with no NET_BIND_SERVICE, so a port below 1024 could only +reproduce the same permanent rollout hang; default 4000 unchanged), +persists it on `FleetApp`, and forwards the +persisted value on `update`, which rebuilds the Deployment spec and would +otherwise silently revert a port-8080 app's probes to 4000 on its first +image change. The port travels in the deploy attestation payload and the +`FleetAppView`. + +CoreLedger schema init is CREATE-only, so the live database needs a manual +`ALTER TABLE "fleet_app" ADD COLUMN "port" BIGINT NOT NULL DEFAULT 4000` +before the image carrying this change deploys (precedent: the spec 011 +`user_account` ALTER, applied 2026-07-22). Applied to the live database +2026-07-23, ahead of the merge, so no deploy ordering window exists. The backfill default cannot +mislabel an existing 8080 placement, verified against the live database +2026-07-23: `fleet_app` holds exactly one row, the 2026-07-22 walk's +`probe` app, in the terminal `removed` state, and its placement died +Forbidden before any cluster object was created (the pre-PR-#62 RBAC +defect), so no row describes a deployment that live traffic depends on. + +The deploy side of the pull-secret story lands with it: spec 009 §4.4 has +always listed `FLEET_IMAGE_PULL_SECRET` as deploy-set, but the Deployment +never set it, so `fleetImagePullSecret()` resolved empty in production and +placed pods carried no `imagePullSecrets` at all. The Deployment now sets +it to `ghcr-pull` (see the spec 009 §4.4 note and the spec 010 catalog +correction; the semantics here are unchanged from §3 finding #2: a +pre-provisioned Secret NAME, operator-created per tenant namespace, +because fleet's RBAC deliberately grants nothing on secrets). diff --git a/specs/009-control-plane-deploy/spec.md b/specs/009-control-plane-deploy/spec.md index 18e9935..e8d56f4 100644 --- a/specs/009-control-plane-deploy/spec.md +++ b/specs/009-control-plane-deploy/spec.md @@ -641,6 +641,17 @@ URL to select the Postgres driver. `ENRAHITU_LEDGER_POOL_SIZE` is optional. `FLEET_RESTIC_IMAGE`, `FACTORY_TEMPLATE_REPO`, `FACTORY_TEMPLATE_REF`, `FACTORY_DATA_DIR`, and `STATECRAFT_GOVERNANCE_STATE_DIR`. +`FLEET_IMAGE_PULL_SECRET` was on this list from the start but the +Deployment never set it, found 2026-07-23 while preparing the in-pod fleet +E2E: `fleetImagePullSecret()` resolved empty in production, so placed pods +carried no `imagePullSecrets` and private images could not be pulled at +all. The Deployment now sets it to `ghcr-pull`. It is the NAME of the +dockerconfigjson Secret fleet references on placed pods, not a credential +(the spec 010 catalog carried the opposite claim and is corrected in the +same change); the Secret itself is operator-provisioned in each tenant +namespace, because fleet's RBAC (section 4.2 rbac.yaml) deliberately +grants nothing on secrets. + **`STATECRAFT_GOVERNANCE_CONFIG_DIR` was on that list and must not be, found live 2026-07-21.** Listing it beside `STATECRAFT_GOVERNANCE_STATE_DIR` treated two variables as a pair when they are opposites, and the Deployment followed diff --git a/specs/010-statecraft-cluster/spec.md b/specs/010-statecraft-cluster/spec.md index a3a3e8e..fa6cdbd 100644 --- a/specs/010-statecraft-cluster/spec.md +++ b/specs/010-statecraft-cluster/spec.md @@ -634,3 +634,17 @@ observability is the in-substrate flag-gated frontend-admin"): `/admin` and `/api/admin/*` stay routed (they are gated in-app, server-side). A separate resource because the allowlist annotation applies per-Ingress and must not touch the `/` paths. + +## Amendment (2026-07-23): FLEET_IMAGE_PULL_SECRET is a name, not a credential + +The catalog entry for `FLEET_IMAGE_PULL_SECRET` claimed a base64 +dockerconfigjson derived from `GHCR_PAT`, inherited from the OAP-era +design. No code ever read it that way: `fleet/config.ts` has always +consumed it as the NAME of a pre-provisioned `dockerconfigjson` Secret to +reference as `imagePullSecrets` on placed pods (spec 006 §3 finding #2), +and fleet's RBAC deliberately cannot create Secrets. The entry is +corrected to `user-supplied`, non-secret, still required (`ghcr-pull` in +production, matching the spec 009 deploy env), the `derived` provenance +class is left with no members, and `.env.example` is regenerated; +`secrets:validate` and `secrets:check` stay green. The operator `.env` +value predates the correction and should be updated to the Secret name.