diff --git a/greenfield/.oxlintrc.json b/greenfield/.oxlintrc.json index a974a69f0..b7d78b0d7 100644 --- a/greenfield/.oxlintrc.json +++ b/greenfield/.oxlintrc.json @@ -82,6 +82,12 @@ "pascalCase": true } } + ], + "unicorn/max-nested-calls": [ + "error", + { + "max": 6 + } ] }, "settings": { @@ -93,17 +99,6 @@ } }, "overrides": [ - { - "files": ["src/contracts/**/*.ts", "src/test/parity/**/*Schemas.ts"], - "rules": { - "unicorn/max-nested-calls": [ - "error", - { - "max": 6 - } - ] - } - }, { "env": { "node": true @@ -111,6 +106,7 @@ "files": [ "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", "src/app/dashboardServer.ts", + "src/app/databaseMaintenance.ts", "src/app/environmentSource.ts", "src/app/server.ts", "src/app/trpcHttpHandler.ts", @@ -125,6 +121,42 @@ "Bun": "readonly" } }, + { + "files": ["src/app/worker.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["**/browser/**", "**/scripts/**"], + "message": "The worker composition root may import only worker, server, contract, and environment-neutral shared modules." + } + ] + } + ] + } + }, + { + "files": ["src/app/databaseMaintenance.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/browser/**", + "**/scripts/**", + "**/worker/**" + ], + "message": "The database maintenance composition root may import only reviewed server and environment-neutral shared modules." + } + ] + } + ] + } + }, { "excludeFiles": [ "**/*.spec.*", @@ -374,10 +406,7 @@ "**/test/**", "**/testSupport/**" ], - "files": [ - "src/app/worker.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ], + "files": ["src/worker/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}"], "rules": { "no-restricted-imports": [ "error", diff --git a/greenfield/README.md b/greenfield/README.md index c6b8b7ffa..5f8d088b6 100644 --- a/greenfield/README.md +++ b/greenfield/README.md @@ -19,7 +19,10 @@ bun run check:boundaries bun run typecheck bun run lint bun run format:check +bun run build:browser +bun run build:processes bun run test +bun run test:coverage bun run docs:check bun run db:check ``` @@ -28,6 +31,10 @@ The root CI copies these contents into an isolated temporary directory before in dependencies and running the same gates. This prevents an accidental dependency on the coexisting application or its `node_modules`. +`bun run build:release` additionally requires a clean Git tree. It produces a commit-addressed +immutable release containing browser/process artifacts, migrations, generated documentation, +package/runtime identity, and the reviewed systemd units; it does not mutate production. + ## Documentation - [Documentation index](docs/index.md) diff --git a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md index 71854ea4d..1aa56433a 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md +++ b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md @@ -32,6 +32,12 @@ the remaining rewrite phases are still incomplete. canonical `/production/state` root for the static web/worker UID; empty database, docs, build, web, worker, and paired rollback then work end-to-end. +**Status (2026-08-06): complete in the greenfield future root.** The executable browser/web/worker +build, manifest-verified runtime and systemd artifacts, protected project-local state, copied +candidate migration, atomic database promotion, crash journal, paired rollback, readiness, logs, +and shutdown pass a disposable-project lifecycle. Production cutover, authenticated product +smokes, and the remaining domain/UI phases are deliberately not claimed by this foundation gate. + ### Phase 2: trust and transport - implement bootstrap, sessions, password, MFA, WebAuthn, recovery, step-up, automation diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 123d189e0..602545087 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -7,15 +7,15 @@ This matrix is the living phase status. Update it in the same change that materially advances or closes a phase; dated entries below provide the evidence, not a second status source. -| Phase | Status | Current evidence and remaining gate | -| ----------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | -| 1 — Foundation | In progress | Server composition, migrations, contracts, raw HTTP/realtime foundations, process-owned database runtime, source-boundary enforcement, staged typed configuration, generated configuration reference, structured logging/request correlation, and procedure error policy exist; executable web/worker roots, browser shell, complete generated references, and release/rollback closure remain. | -| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | -| 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | -| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | -| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | -| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | +| Phase | Status | Current evidence and remaining gate | +| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | +| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | +| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | +| 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | +| 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | +| 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | +| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | ### 2026-08-03 — Phase 0 started @@ -652,10 +652,9 @@ closes a phase; dated entries below provide the evidence, not a second status so | Complete shutdown | 128,774,144 | 3,133 | 25 | | Child-process cancel | 117,194,752 | 1,531 | 24 | -- Phase 0 is complete, but the rewrite is not: Phase 1 remains in progress with browser/worker - roots, complete generated references, immutable release/rollback, and end-to-end empty-database - web/worker delivery still open. Final production load, restore, cutover, and legacy-removal - evidence remains in Phase 6. +- Phase 0 closed with Phase 1 delivery work still open at that checkpoint. The later Phase 1 entry + below records its closure. Final production load, restore, cutover, and legacy-removal evidence + remains in Phase 6. ### 2026-08-06 — Source-boundary enforcement foundation @@ -710,9 +709,9 @@ closes a phase; dated entries below provide the evidence, not a second status so - The actual 36-procedure router, public contract metadata, and a server-owned `ContractErrorCode` allowlist now match mechanically. Immediate and deferred subscription errors outside a route's declared set are internalized, as is any implemented procedure missing from - the policy; framework routing and input/transport validation remain implicit. Phase 1 is still - in progress: executable web/worker roots, worker lifecycle, browser shell, and release/rollback - delivery remain open. + the policy; framework routing and input/transport validation remain implicit. At this checkpoint, + executable web/worker roots, worker lifecycle, browser shell, and release/rollback delivery were + still open; the Phase 1 closure below records their completion. ### 2026-08-06 — Process-owned database runtime @@ -734,10 +733,9 @@ closes a phase; dated entries below provide the evidence, not a second status so identities, and validates every rollback-journal, shared-memory, or WAL sidecar present during acquisition as a single-link current-user-owned `0600` regular file. It also rejects a writable or untrusted ancestor chain and never mutates host permissions. Persistent state remains at - `/production/state` inside the existing project layout. The future greenfield - bootstrap/release boundary must safely protect that ancestor chain before runtime validation; on - the current host this includes clearing group write from `/home/ubuntu/projects`. That caller and - its disposable-host activation test remain an explicit Phase 1 blocker. + `/production/state` inside the existing project layout. At this checkpoint, safe + ancestor preparation and disposable activation were still Phase 1 blockers; the delivery entry + below records their implementation and verification. - Every connection verifies foreign keys and checks enabled, `trusted_schema` disabled, WAL, `synchronous=FULL`, a 1,000-page automatic checkpoint, and `busy_timeout=0`. Zero is deliberate: SQLite never blocks the Bun thread waiting for another process; bounded Effect schedules own @@ -747,5 +745,40 @@ closes a phase; dated entries below provide the evidence, not a second status so creates an absent database. Already-current state is revalidated against the exact schema and immutable ledger. The ledger enforces bounded canonical ids, exact checksums/release identities, strictly increasing non-future timestamps, and append-only triggers. A reviewed pending graph - fails closed with `DatabaseRuntimeSnapshotRequiredError`: verified snapshot/promotion, worker - startup, backup/restore, and release-pair rollback remain later delivery slices. + fails closed with `DatabaseRuntimeSnapshotRequiredError`: the later delivery slice below adds + verified snapshot, copied candidate migration, promotion, worker startup, and release-pair + rollback without weakening normal web/worker startup modes. + +### 2026-08-06 — Phase 1 delivery foundation closed + +- Real browser, web, worker, and database-maintenance entrypoints now build deterministically from + the future root. The browser owns singleton router/query providers, an accessible shell, React + error containment, immutable manifest-indexed assets, controlled SPA fallback, strict security + headers, representation-specific validators, precompression, and enforced bundle budgets. +- Releases are clean-commit addressed and record the exact Bun revision, lockfile/direct package + identity, migration graph, generated documentation, browser/process/systemd artifact hashes, + build commands, and process roles. Publication verifies and freezes the complete tree; runtime + startup accepts only the matching immutable release and installed Bun identity. +- Production state remains exclusively beneath `/production`. Descriptor-rooted + preparation narrows unsafe current-user ancestors without broadening permissions and rejects + symlink, owner, device, inode, or path replacement drift. Web/worker structured logs, stdout, + stderr, backups, transition workspaces, and child output all remain project-local. +- Deployment holds one lease across snapshot, copied candidate migration, database promotion, + release/runtime pointer changes, readiness, activation-record compare-and-swap, and cleanup. A + durable journal recovers interruption at the promotion boundary. Any pre-commit failure stops a + partially started candidate and restores the previous database/release pair; a post-commit + cleanup failure retains the committed candidate. +- The two replacement user-systemd units are part of the immutable manifest. Installation accepts + only those exact files, atomically replaces protected user-unit entries, reloads user systemd, + and never implicitly starts, stops, enables, or disables a service. Activation installs the + verified stop-owner units before the first stop (using the candidate on an empty host), starts + worker before web, and stops web before worker; rollback reinstalls the previous release's units + first. +- A disposable project lifecycle performs the real browser/process build, documentation and + migration gates, exact runtime publication, empty-database initialization, web/worker startup, + readiness, browser serving, project-local logging, and complete shutdown. Focused adversarial + tests additionally cover path swaps, immutable artifact tampering, failed readiness, partial + start, crash recovery, stale activation state, and post-commit cleanup interruption. + +This completes Phase 1 only. Phase 3–6 domains, persistent Gateway/chat, privileged adapters, +full-browser parity, production rehearsal, cutover, and legacy deletion remain open. diff --git a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 22666e4b5..5f69ec8c1 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -230,10 +230,10 @@ that representation is absent. OpenAPI 3.1 documents only true raw HTTP endpoint pretend the tRPC wire format is a conventional REST API. The tRPC `AppRouter` type remains the client contract. -The new `/docs` frontend route renders the checked-in generated artifacts with navigation and -search. Rendering uses the existing Markdown/sanitization boundary and never reads source files -or secrets from production. A release may add its non-secret build identity at runtime without -rewriting deterministic documentation. +A later `/docs` frontend slice will render the checked-in generated artifacts with navigation and +search. Rendering must use the Markdown/sanitization boundary and never read source files or +secrets from production. The current immutable release already carries the generated artifacts; +adding their browser route remains part of full UI parity rather than the delivery foundation. ### Generation commands and checks @@ -262,7 +262,7 @@ Every request, job, Gateway call, and domain transaction receives a correlation logs use stable event names and include release identity, process role, duration, outcome, and safe identifiers. They do not serialize arbitrary request bodies or command environments. -The current production web runtime requires one process logger, installs it as the only Effect +The production web runtime requires one process logger, installs it as the only Effect logger in the application scope, and reuses that exact instance at ordinary HTTP/tRPC boundaries. The Dashboard composition root coordinates that application `ManagedRuntime` with a separate, retained database `ManagedRuntime`. The database-backed realtime layer and all repositories receive @@ -270,10 +270,9 @@ the same SQLite/Drizzle service through narrow ports. After the listener settles scope finalizes realtime and authentication work before the database scope performs its passive checkpoint and strict close; the synchronous log sink flushes last. Its serializer emits bounded NDJSON from event-specific allowlisted fields, and a sink failure emits one constant direct-stderr -fallback without recursive logging. The future executable web/worker composition roots still own -creation of the stdout/stderr sink, release/config identity, and startup/shutdown events; this slice -does not claim that absent process entrypoint. Replacement production units must bind both streams, -including the direct-stderr fallback, to project-derived files beneath +fallback without recursive logging. The executable web and worker composition roots own creation +of the project-file sink, release/config identity, and startup/shutdown events. Their production +units bind both stdout and stderr, including the direct-stderr fallback, to files beneath `/production/state/logs`; default journald persistence, `LogsDirectory=`, and a configurable external log root are forbidden. Transient job units route their streams beneath `/production/state/job-output` instead. @@ -405,31 +404,32 @@ Keep the host-native deployment. Dashboard needs controlled access to systemd, l Docker, OpenClaw, Git worktrees, and host databases; putting the application itself in a container would add mounts and privilege plumbing without isolating the important child jobs. -The future repository root must ship new `systemd/` web and worker units as part of the delivery -slice. The legacy units are deliberately not copied into `greenfield/`: they change into a -`backend` working directory, execute legacy `dist/*Start.js` entrypoints through the legacy -release wrapper, and retain pre-measurement multi-gigabyte limits. Add the replacement units only -after the rewritten executable roots and immutable release wrapper exist, then validate every -referenced path and the measured resource limits in CI. Until then, the absence of -`greenfield/systemd/` is an explicit incomplete delivery item rather than a compatibility link. +The future repository root ships new `systemd/` web and worker units as part of the immutable +release. The legacy units were deliberately not copied: they change into a `backend` working +directory, execute retired `dist/*Start.js` entrypoints, and retain pre-measurement multi-gigabyte +limits. The replacement units invoke the exact project-local Bun runtime and release pointers, +bind logs beneath project state, and enforce the measured web/worker resource ceilings. Persistent state remains inside the existing Dashboard project layout at `/production/state`, but outside every immutable release directory. Production composition derives that path from `MIRA_DASHBOARD_PROJECT_ROOT`; neither configuration nor a -systemd `StateDirectory=` may select a separate state root. The future greenfield bootstrap/release -boundary must create that directory as current-user-owned `0700` and protect its existing ancestor +systemd `StateDirectory=` may select a separate state root. The greenfield bootstrap/release +boundary creates that directory as current-user-owned `0700` and protects its existing ancestor chain before activation. For a non-sticky ancestor owned by the managed UID, preparation may only clear group/other write bits through a no-follow directory descriptor, preserve every other permission, verify device/inode before and after, and then revalidate the whole chain. It must fail closed for symlinks, ownership drift, or a writable foreign-owned ancestor; application runtime startup never repairs permissions. On the current host, first cutover therefore requires `chmod go-w /home/ubuntu/projects` (currently `0775` to `0755`) without moving any project data. -The real bootstrap/release caller, the production-path composition tests, and the replacement-unit -assertions remain blocking Phase 1 delivery items. Unit source remains under -`/production/checkout/systemd` or the active development worktree. Only installed -copies of systemd unit files may live outside `/development` or -`/production`; all Dashboard state, logs, backups, runtime binaries, checkouts, and -release artifacts remain inside those project directories. +Unit source remains under `/production/checkout/systemd` or the active development +worktree, and the exact two unit files are copied into and hashed by every immutable release. The +installer accepts only those manifest artifacts, atomically replaces current-user-owned regular +unit files, and performs only `systemctl --user daemon-reload`; enabling or service control is a +separate activation action. Activation prepares and reloads the verified units for the currently +running release—or the candidate on an empty host—before its first stop, so first deployment does +not depend on pre-existing unit files. Only installed copies of systemd unit files may live outside +`/development` or `/production`; all Dashboard state, logs, backups, +runtime binaries, checkouts, and release artifacts remain inside those project directories. Recommended layout: @@ -442,7 +442,8 @@ Recommended layout: browser/ migrations/ docs/generated/ - scripts/ + metadata/ + systemd/ release-manifest.json releases/current -> releases/previous -> @@ -470,13 +471,17 @@ Deployment flow: 2. Transfer or materialize it into a new immutable release directory and verify every hash. 3. Prepare and verify `/production/state` plus its protected ancestor chain before changing the active release pointer. -4. Acquire the deployment lease, drain active jobs, enter maintenance mode, and quiesce all - database writers. +4. Acquire the deployment lease, install/reload the verified stop-owner units, drain active jobs, + enter maintenance mode, durably journal the exact stop intent, and only then quiesce all + database writers. Recovery treats this pre-snapshot phase as database-unmodified and + idempotently restores the previous service owner before clearing the journal. 5. Snapshot and verify the current database while writers remain stopped. 6. Apply migrations to a copy, run schema/preflight checks, then atomically promote the database state. -7. Let one deployment-held initializer create or promote the database, then start worker in - `validate-only` mode and web against the candidate, with readiness deadlines. +7. Reinstall the candidate release's manifest-verified user units, reload user systemd, then let + the deployment-held activation start worker in `validate-only` mode before web, with readiness + deadlines. A rollback reinstalls the previous release's units before restarting its paired + release/database state. 8. Run authenticated smoke checks, including tRPC, SSE, Gateway, docs, and one safe queued job. 9. Atomically record current/previous and prune only releases whose manifests verify. diff --git a/greenfield/docs/development/testing-and-prs.md b/greenfield/docs/development/testing-and-prs.md index c2a7a5e6f..c8ca33e80 100644 --- a/greenfield/docs/development/testing-and-prs.md +++ b/greenfield/docs/development/testing-and-prs.md @@ -68,6 +68,12 @@ and additionally fails an otherwise green suite when output contains a React mis warning, an unconfigured React act environment warning, or a Bun panic/crash banner. Do not bypass that runner in repository test scripts. +The browser suite preloads only the Happy DOM globals and React act-environment marker it needs; +the tests themselves remain in the browser TypeScript graph. The product-shell test renders the +real QueryClient, router, accessible route, and error-boundary composition. Build tests separately +exercise the actual HTML entrypoint, React Compiler, Tailwind, code splitting, compression, CSP +policy, and bundle budgets. + ## Lint and boundaries Oxlint applies its baseline strict rules to tests as well as production source. Some diff --git a/greenfield/package.json b/greenfield/package.json index d0fa7e6f0..672af6289 100644 --- a/greenfield/package.json +++ b/greenfield/package.json @@ -5,10 +5,16 @@ "type": "module", "scripts": { "check:boundaries": "bun scripts/checkSourceBoundaries.ts", + "build:browser": "bun scripts/delivery/buildBrowser.ts", + "build:processes": "bun scripts/delivery/buildProcesses.ts", + "build:release": "bun scripts/delivery/buildRelease.ts", "db:check": "bun scripts/checkDatabaseSchema.ts", "db:generate": "drizzle-kit generate --config drizzle.config.ts --output json", "docs:check": "bun scripts/generateDocs.ts --check", "docs:generate": "bun scripts/generateDocs.ts", + "delivery:prepare-state": "bun scripts/delivery/prepareProductionState.ts", + "delivery:install-units": "bun scripts/delivery/installProductionSystemdUnits.ts", + "delivery:activate": "bun scripts/delivery/activateProductionRelease.ts", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "bun run lint:bun && bun run lint:browser", @@ -16,14 +22,15 @@ "lint:bun": "oxlint . --tsconfig tsconfig.bun.json --ignore-pattern 'src/browser/**'", "lint:fix": "oxlint . --fix --tsconfig tsconfig.bun.json --ignore-pattern 'src/browser/**' && oxlint src/browser --fix --tsconfig tsconfig.browser.json --no-error-on-unmatched-pattern", "evidence:resources:sse": "bun src/test/integration/resources/runSseMemoryEvidence.ts", - "test": "bun run test:boundaries && bun run test:browser && bun run test:integration && bun run test:parity && bun run test:server && bun run test:tooling", + "test": "bun run test:boundaries && bun run test:browser && bun run test:delivery && bun run test:integration && bun run test:parity && bun run test:server && bun run test:tooling", "test:boundaries": "bun scripts/runTestSuite.ts scripts/sourceBoundaries", - "test:browser": "bun scripts/runTestSuite.ts --pass-with-no-tests src/browser", + "test:browser": "bun scripts/runTestSuite.ts --preload ./src/browser/testSupport/browserTestPreload.ts src/browser", "test:coverage": "bun scripts/runCoverage.ts", + "test:delivery": "bun scripts/runTestSuite.ts scripts/delivery", "test:integration": "bun scripts/runTestSuite.ts src/test/integration src/test/support", "test:parity": "bun scripts/runTestSuite.ts src/test/parity", "test:server": "bun scripts/runTestSuite.ts src/app src/server src/shared src/contracts", - "test:tooling": "bun scripts/runTestSuite.ts scripts/documentation scripts/buildSourceIdentity.test.ts scripts/checkDatabaseSchema.test.ts scripts/checkCoverage.test.ts scripts/runTestSuite.test.ts scripts/testOutputPolicy.test.ts", + "test:tooling": "bun scripts/runTestSuite.ts scripts/documentation scripts/buildSourceIdentity.test.ts scripts/checkDatabaseSchema.test.ts scripts/checkCoverage.test.ts scripts/packageIdentity.test.ts scripts/runCoverage.test.ts scripts/runTestSuite.test.ts scripts/testOutputPolicy.test.ts", "typecheck": "bun run typecheck:browser && bun run typecheck:bun", "typecheck:browser": "bun node_modules/typescript/bin/tsc -p tsconfig.browser.json --noEmit", "typecheck:bun": "bun node_modules/typescript/bin/tsc -p tsconfig.bun.json --noEmit" diff --git a/greenfield/scripts/delivery/activateProductionRelease.test.ts b/greenfield/scripts/delivery/activateProductionRelease.test.ts new file mode 100644 index 000000000..2f1001867 --- /dev/null +++ b/greenfield/scripts/delivery/activateProductionRelease.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { + parseActivateProductionReleaseArguments, + runActivateProductionReleaseCli, +} from "./activateProductionRelease.ts"; + +const releaseId = "a".repeat(40); +const runtimeRevision = "b".repeat(40); +const transitionId = "019fd974-54a2-74dd-a64b-d4186f8d8828"; +const validArguments = Object.freeze([ + "--project-root=/srv/mira-dashboard", + "--release-root=/srv/mira-dashboard-build/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "--readiness-url=http://127.0.0.1:3100/api/health/ready", + "--runtime-source=/opt/bun/candidate/bun", +]); + +describe("production release activation CLI", () => { + test("parses an exact order-independent activation request", () => { + expect(parseActivateProductionReleaseArguments(validArguments)).toEqual({ + projectRoot: "/srv/mira-dashboard", + readinessUrl: "http://127.0.0.1:3100/api/health/ready", + releaseRoot: + "/srv/mira-dashboard-build/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + runtimeSource: "/opt/bun/candidate/bun", + }); + expect( + parseActivateProductionReleaseArguments(validArguments.toReversed()) + ).toEqual(parseActivateProductionReleaseArguments(validArguments)); + }); + + test("rejects unknown, duplicate, external-readiness, and relative inputs", () => { + const invalidArguments = [ + [...validArguments, "--unknown=value"], + [...validArguments.slice(0, 3), "--runtime-soruce=/opt/bun/candidate/bun"], + [...validArguments, validArguments[0]!], + validArguments.map((argument) => + argument.startsWith("--readiness-url=") + ? "--readiness-url=https://dashboard.example.test/api/health/ready" + : argument + ), + validArguments.map((argument) => + argument.startsWith("--readiness-url=") + ? "--readiness-url=http://[::1]:3100/api/health/ready" + : argument + ), + validArguments.map((argument) => + argument.startsWith("--release-root=") + ? "--release-root=dist/releases/candidate" + : argument + ), + ]; + for (const arguments_ of invalidArguments) { + expect(() => parseActivateProductionReleaseArguments(arguments_)).toThrow( + "Usage: bun run delivery:activate" + ); + } + }); + + test("returns only the committed public activation identity", async () => { + const observed: unknown[] = []; + const result = await runActivateProductionReleaseCli(validArguments, { + activate: (options) => { + observed.push(options); + return Promise.resolve({ + current: { releaseId, runtimeRevision }, + formatVersion: 1, + previous: null, + transitionId, + }); + }, + }); + expect(observed).toEqual([ + parseActivateProductionReleaseArguments(validArguments), + ]); + expect(result).toEqual({ releaseId, status: "ACTIVATED", transitionId }); + + const failure = await rejectionError( + runActivateProductionReleaseCli(validArguments, { + activate: () => Promise.reject(new Error("private failure")), + }) + ); + expect(failure.message).toBe("private failure"); + }); +}); diff --git a/greenfield/scripts/delivery/activateProductionRelease.ts b/greenfield/scripts/delivery/activateProductionRelease.ts new file mode 100644 index 000000000..0719a00fd --- /dev/null +++ b/greenfield/scripts/delivery/activateProductionRelease.ts @@ -0,0 +1,198 @@ +import path from "node:path"; + +import { Effect } from "effect"; +import * as v from "valibot"; + +import { healthReadinessPath } from "../../src/contracts/system.ts"; +import type { ProductionActivationRecord } from "../../src/shared/productionActivationRecord.ts"; +import { fullCommitShaSchema } from "../../src/shared/validation.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { activatePublishedProductionRelease } from "./productionReleaseActivation.ts"; +import { publishProductionRelease } from "./productionReleasePublication.ts"; +import { installProductionRuntime } from "./productionRuntime.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; +import { verifyReleaseArtifactIdentity } from "./releaseIdentity.ts"; +import { createSystemdProductionServiceController } from "./systemdProductionServices.ts"; + +const activationCliFailureMessage = "Production release activation failed"; +const activationCliUsage = + "Usage: bun run delivery:activate --project-root=/absolute/project --release-root=/absolute/release --readiness-url=http://127.0.0.1:PORT/api/health/ready [--runtime-source=/absolute/bun]"; +const absolutePathSchema = v.pipe( + v.string(), + v.maxLength(4096), + v.check( + (input) => + path.isAbsolute(input) && + path.resolve(input) === input && + path.parse(input).root !== input && + !input.includes("\0"), + activationCliUsage + ) +); +const readinessUrlSchema = v.pipe( + v.string(), + v.url(), + v.check((input) => { + try { + const url = new URL(input); + return ( + url.protocol === "http:" && + url.hostname === "127.0.0.1" && + url.pathname === healthReadinessPath && + url.username.length === 0 && + url.password.length === 0 && + url.search.length === 0 && + url.hash.length === 0 + ); + } catch { + return false; + } + }, activationCliUsage) +); +const activateProductionReleaseArgumentsSchema = v.strictObject({ + projectRoot: absolutePathSchema, + readinessUrl: readinessUrlSchema, + releaseRoot: absolutePathSchema, + runtimeSource: v.optional(absolutePathSchema), +}); +const activationCliResultSchema = v.strictObject({ + releaseId: fullCommitShaSchema(activationCliFailureMessage), + status: v.literal("ACTIVATED"), + transitionId: v.pipe(v.string(), v.uuid()), +}); +const activationArgumentNames = new Set([ + "project-root", + "readiness-url", + "release-root", + "runtime-source", +]); + +/** Explicit immutable-release activation command. */ +export type ActivateProductionReleaseArguments = Readonly< + v.InferOutput +>; + +/** Safe machine-readable result from production activation. */ +export type ActivateProductionReleaseResult = Readonly< + v.InferOutput +>; + +/** Injectable orchestration boundary used by the CLI contract test. */ +export interface ActivateProductionReleaseCliDependencies { + readonly activate?: ( + options: ActivateProductionReleaseArguments + ) => Promise; +} + +function readNamedArguments(arguments_: readonly string[]): Record { + const values = Object.create(null) as Record; + for (const argument of arguments_) { + const separator = argument.indexOf("="); + if (separator <= 2 || !argument.startsWith("--")) { + throw new TypeError(activationCliUsage); + } + const name = argument.slice(2, separator); + const value = argument.slice(separator + 1); + if (!value || Object.hasOwn(values, name)) { + throw new TypeError(activationCliUsage); + } + values[name] = value; + } + return values; +} + +/** + * Parses the exact production activation CLI surface without ambient defaults. + * @param arguments_ Arguments after the Bun entrypoint. + * @returns Frozen project, release, runtime, and readiness inputs. + */ +export function parseActivateProductionReleaseArguments( + arguments_: readonly string[] +): ActivateProductionReleaseArguments { + if (arguments_.length < 3 || arguments_.length > 4) { + throw new TypeError(activationCliUsage); + } + const named = readNamedArguments(arguments_); + if (Object.keys(named).some((name) => !activationArgumentNames.has(name))) { + throw new TypeError(activationCliUsage); + } + const candidate: unknown = { + projectRoot: named["project-root"], + readinessUrl: named["readiness-url"], + releaseRoot: named["release-root"], + runtimeSource: named["runtime-source"], + }; + const parsed = v.safeParse(activateProductionReleaseArgumentsSchema, candidate, { + abortEarly: true, + }); + if (!parsed.success) throw new TypeError(activationCliUsage); + return Object.freeze(parsed.output); +} + +async function activateProductionRelease( + options: ActivateProductionReleaseArguments +): Promise { + const state = await prepareProtectedProductionStatePath(options.projectRoot); + const sourceManifest = await verifyReleaseArtifactIdentity(options.releaseRoot); + return withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const runtime = await installProductionRuntime( + lease, + paths, + sourceManifest.runtime, + options.runtimeSource === undefined + ? undefined + : { sourceExecutable: options.runtimeSource } + ); + const release = await publishProductionRelease( + lease, + paths, + options.releaseRoot, + sourceManifest.runtime + ); + const services = createSystemdProductionServiceController(lease, paths, { + readinessUrl: options.readinessUrl, + }); + return Effect.runPromise( + activatePublishedProductionRelease(lease, paths, release, runtime, { + services, + }) + ); + }); +} + +/** + * Prepares state, installs the pinned runtime, publishes, and atomically activates one release. + * @param arguments_ Arguments after the Bun entrypoint. + * @param dependencies Injectable complete activation boundary. + * @returns Redacted machine-readable activation identity. + */ +export async function runActivateProductionReleaseCli( + arguments_: readonly string[], + dependencies: ActivateProductionReleaseCliDependencies = {} +): Promise { + const options = parseActivateProductionReleaseArguments(arguments_); + const activation = await (dependencies.activate ?? activateProductionRelease)( + options + ); + return Object.freeze( + v.parse(activationCliResultSchema, { + releaseId: activation.current.releaseId, + status: "ACTIVATED", + transitionId: activation.transitionId, + }) + ); +} + +if (import.meta.main) { + try { + const result = await runActivateProductionReleaseCli(Bun.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + const message = + error instanceof TypeError ? error.message : activationCliFailureMessage; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/greenfield/scripts/delivery/buildAdmission.test.ts b/greenfield/scripts/delivery/buildAdmission.test.ts new file mode 100644 index 000000000..2c0830a3e --- /dev/null +++ b/greenfield/scripts/delivery/buildAdmission.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { withBunBuildAdmission } from "./buildAdmission.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function repositoryFixture(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "mira-build-admission-")); + temporaryDirectories.push(root); + return root; +} + +describe("Bun build admission", () => { + test("runs competing operations one at a time", async () => { + const repositoryRoot = await repositoryFixture(); + const events: string[] = []; + let releaseFirst!: () => void; + const firstMayFinish = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withBunBuildAdmission(repositoryRoot, async () => { + events.push("first-start"); + await firstMayFinish; + events.push("first-end"); + }); + await Bun.sleep(20); + const second = withBunBuildAdmission(repositoryRoot, () => { + events.push("second"); + return Promise.resolve(); + }); + await Bun.sleep(20); + + expect(events).toEqual(["first-start"]); + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual(["first-start", "first-end", "second"]); + }); + + test("recovers a validated dead-owner lock and rejects malformed lock data", async () => { + const recoveredRoot = await repositoryFixture(); + const recoveredDist = path.join(recoveredRoot, "dist"); + await mkdir(recoveredDist); + await writeFile( + path.join(recoveredDist, ".bun-build.lock"), + `${JSON.stringify({ + pid: 1_999_999_999, + token: Bun.randomUUIDv7(), + })}\n`, + { mode: 0o600 } + ); + let ran = false; + await withBunBuildAdmission(recoveredRoot, () => { + ran = true; + return Promise.resolve(); + }); + expect(ran).toBeTrue(); + + const malformedRoot = await repositoryFixture(); + const malformedDist = path.join(malformedRoot, "dist"); + await mkdir(malformedDist); + await writeFile(path.join(malformedDist, ".bun-build.lock"), "not-json\n", { + mode: 0o600, + }); + const failure = await rejectionError( + withBunBuildAdmission(malformedRoot, () => Promise.resolve()) + ); + expect(failure.message).toBe("Bun build admission failed"); + }); +}); diff --git a/greenfield/scripts/delivery/buildAdmission.ts b/greenfield/scripts/delivery/buildAdmission.ts new file mode 100644 index 000000000..8ec44f180 --- /dev/null +++ b/greenfield/scripts/delivery/buildAdmission.ts @@ -0,0 +1,37 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { resolveRepositoryBuildPath } from "./buildPaths.ts"; +import { withExclusiveProcessLock } from "./exclusiveProcessLock.ts"; + +const buildAdmissionDeadlineMs = 2 * 60 * 1000; +const buildAdmissionRetryMs = 10; +const buildLockFileName = ".bun-build.lock"; +const buildAdmissionFailureMessage = "Bun build admission failed"; + +/** + * Serializes Bun builds across test workers and processes for one repository. + * @param repositoryRoot Canonical future-root checkout owning the build. + * @param operation One complete build plus its artifact post-processing. + * @returns The operation result after prior builds have released admission. + */ +export async function withBunBuildAdmission( + repositoryRoot: string, + operation: () => Promise +): Promise { + const lockPath = resolveRepositoryBuildPath( + repositoryRoot, + path.join(repositoryRoot, "dist", buildLockFileName), + buildAdmissionFailureMessage + ).output; + await mkdir(path.dirname(lockPath), { mode: 0o700, recursive: true }); + return withExclusiveProcessLock( + { + deadlineMs: buildAdmissionDeadlineMs, + failureMessage: buildAdmissionFailureMessage, + lockPath, + retryMs: buildAdmissionRetryMs, + }, + operation + ); +} diff --git a/greenfield/scripts/delivery/buildBrowser.test.ts b/greenfield/scripts/delivery/buildBrowser.test.ts new file mode 100644 index 000000000..bb1e2fac4 --- /dev/null +++ b/greenfield/scripts/delivery/buildBrowser.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFile, readdir, rm } from "node:fs/promises"; +import path from "node:path"; + +const repositoryRoot = path.resolve(import.meta.dir, "../.."); +const scriptPath = path.join(import.meta.dir, "buildBrowser.ts"); +const outputDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + outputDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function relativeFiles(directory: string): Promise { + const files: string[] = []; + const pending = [directory]; + while (pending.length > 0) { + const current = pending.pop(); + if (!current) continue; + for (const entry of await readdir(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(entryPath); + else if (entry.isFile()) { + files.push(path.relative(directory, entryPath).replaceAll("\\", "/")); + } + } + } + return files.toSorted(); +} + +async function runBuild(outputDirectory: string) { + const child = Bun.spawn( + [process.execPath, scriptPath, `--output=${outputDirectory}`], + { + cwd: repositoryRoot, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + } + ); + const [exitCode, stderr, stdout] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + new Response(child.stdout).text(), + ]); + return { exitCode, stderr, stdout }; +} + +describe("Dashboard browser artifact", () => { + test("builds the product entry with budgets, hashes and precompression", async () => { + const outputDirectory = path.join( + repositoryRoot, + `dist/test-browser-${Bun.randomUUIDv7()}` + ); + outputDirectories.push(outputDirectory); + + const execution = await runBuild(outputDirectory); + const result = JSON.parse(execution.stdout) as { + compressedFileCount: number; + outputDirectory: string; + status: string; + }; + const files = await relativeFiles(outputDirectory); + const html = await readFile(path.join(outputDirectory, "index.html"), "utf8"); + const metrics = JSON.parse( + await readFile(path.join(outputDirectory, "bundle-metrics.json"), "utf8") + ) as { formatVersion: number }; + + expect(execution).toMatchObject({ exitCode: 0, stderr: "" }); + expect(result).toMatchObject({ outputDirectory, status: "BUILT" }); + expect(result.compressedFileCount).toBeGreaterThan(0); + expect(metrics.formatVersion).toBe(1); + expect(html).toContain("Mira Dashboard"); + expect(html).toMatch( + /]*\bsrc="\/assets\/.+-[a-z\d]{8}\.js"[^>]*><\/script>/u + ); + expect(files).toContain("bundle-metrics.json"); + expect(files.some((file) => file.endsWith(".br"))).toBeTrue(); + expect(files.some((file) => file.endsWith(".gz"))).toBeTrue(); + expect(files.filter((file) => file.endsWith(".js")).length).toBeGreaterThan(1); + expect( + files + .filter((file) => /\.(?:css|js)$/u.test(file)) + .every((file) => /^assets\/.+-[a-z\d]{8}\.(?:css|js)$/u.test(file)) + ).toBeTrue(); + }, 60_000); + + test("rejects output outside the repository build boundary", async () => { + const execution = await runBuild(path.join(repositoryRoot, "dist")); + + expect(execution.exitCode).toBe(1); + expect(execution.stdout).toBe(""); + expect(execution.stderr).toBe("Browser build paths are invalid\n"); + }); +}); diff --git a/greenfield/scripts/delivery/buildBrowser.ts b/greenfield/scripts/delivery/buildBrowser.ts new file mode 100644 index 000000000..80a915ef7 --- /dev/null +++ b/greenfield/scripts/delivery/buildBrowser.ts @@ -0,0 +1,130 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import tailwindPlugin from "bun-plugin-tailwind"; + +import { + assertFrontendBundleBudgets, + assertSelfHostedFrontendHtml, + measureFrontendBundle, + type FrontendBundleMetrics, + writeFrontendHtmlAppEntrypoint, + writePrecompressedFrontendAssets, +} from "../frontendBuildArtifacts.ts"; +import reactCompilerPlugin from "../reactCompilerPlugin.ts"; +import { withBunBuildAdmission } from "./buildAdmission.ts"; +import { parseBuildOutputArgument } from "./buildCli.ts"; +import { resolveRepositoryBuildPath } from "./buildPaths.ts"; + +const browserAppInput = "src/browser/main.tsx"; +const browserHtmlEntrypoint = "src/browser/index.html"; +const browserMetricsFileName = "bundle-metrics.json"; + +/** Deterministic browser artifact evidence returned to release orchestration. */ +export interface BrowserBuildResult { + readonly compressedFileCount: number; + readonly metrics: FrontendBundleMetrics; + readonly outputDirectory: string; +} + +function validatedBuildOutputDirectory( + repositoryRoot: string, + outputDirectory: string +): string { + return resolveRepositoryBuildPath( + repositoryRoot, + outputDirectory, + "Browser build paths are invalid" + ).output; +} + +/** + * Builds the actual Dashboard browser entry with the compiler-first production pipeline. + * The output path must be a strict child of this repository's ignored `dist` directory. + * @param repositoryRoot Canonical future-root checkout. + * @param outputDirectory Explicit contained output directory. + * @returns Browser artifact metrics and compression evidence. + */ +export async function buildBrowserArtifact( + repositoryRoot: string, + outputDirectory: string +): Promise { + const output = validatedBuildOutputDirectory(repositoryRoot, outputDirectory); + const entrypoint = path.join(repositoryRoot, browserHtmlEntrypoint); + + return withBunBuildAdmission(repositoryRoot, async () => { + await rm(output, { force: true, recursive: true }); + await mkdir(output, { recursive: true }); + + const result = await Bun.build({ + define: { "process.env.NODE_ENV": JSON.stringify("production") }, + entrypoints: [entrypoint], + metafile: true, + minify: true, + naming: { + asset: "assets/[name]-[hash].[ext]", + chunk: "assets/[name]-[hash].[ext]", + }, + outdir: output, + plugins: [reactCompilerPlugin, tailwindPlugin], + publicPath: "/", + sourcemap: "none", + splitting: true, + target: "browser", + }); + if (!result.success) { + throw new AggregateError(result.logs, "Dashboard browser build failed"); + } + if (!result.metafile) { + throw new Error("Dashboard browser build did not produce metadata"); + } + + await writeFrontendHtmlAppEntrypoint(result.metafile, output, browserAppInput); + await assertSelfHostedFrontendHtml(path.join(output, "index.html")); + const metrics = await measureFrontendBundle( + result.metafile, + output, + browserAppInput + ); + assertFrontendBundleBudgets(metrics.measurements); + const compressedFileCount = await writePrecompressedFrontendAssets( + result.outputs.map(({ path: outputPath }) => outputPath) + ); + await writeFile( + path.join(output, browserMetricsFileName), + `${JSON.stringify(metrics, null, 2)}\n`, + { encoding: "utf8", flag: "wx" } + ); + + return Object.freeze({ + compressedFileCount, + metrics: Object.freeze(metrics), + outputDirectory: output, + }); + }); +} + +if (import.meta.main) { + try { + const repositoryRoot = path.resolve(import.meta.dir, "../.."); + const result = await buildBrowserArtifact( + repositoryRoot, + parseBuildOutputArgument( + process.argv.slice(2), + path.join(repositoryRoot, "dist/browser") + ) + ); + process.stdout.write( + `${JSON.stringify({ + compressedFileCount: result.compressedFileCount, + outputDirectory: result.outputDirectory, + status: "BUILT", + })}\n` + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Dashboard browser build failed"; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/greenfield/scripts/delivery/buildCli.ts b/greenfield/scripts/delivery/buildCli.ts new file mode 100644 index 000000000..eb4b668f9 --- /dev/null +++ b/greenfield/scripts/delivery/buildCli.ts @@ -0,0 +1,35 @@ +import path from "node:path"; + +import * as v from "valibot"; + +const invalidBuildArgumentsMessage = "Build arguments are invalid"; +const outputArgumentSchema = v.pipe( + v.string(), + v.minLength(10), + v.maxLength(4105), + v.regex(/^--output=[^\0]+$/u) +); + +/** + * Parses the sole optional delivery-build CLI argument without shell interpretation. + * @param arguments_ Raw arguments following the script path. + * @param defaultOutput Canonical script-owned output used by package commands. + * @returns Normalized absolute output directory. + */ +export function parseBuildOutputArgument( + arguments_: readonly string[], + defaultOutput: string +): string { + if (arguments_.length === 0) return defaultOutput; + const parsed = v.safeParse(outputArgumentSchema, arguments_[0], { + abortEarly: true, + }); + if (arguments_.length !== 1 || !parsed.success) { + throw new TypeError(invalidBuildArgumentsMessage); + } + const output = parsed.output.slice("--output=".length); + if (!path.isAbsolute(output) || path.resolve(output) !== output) { + throw new TypeError(invalidBuildArgumentsMessage); + } + return output; +} diff --git a/greenfield/scripts/delivery/buildPaths.ts b/greenfield/scripts/delivery/buildPaths.ts new file mode 100644 index 000000000..8f5e2a1cd --- /dev/null +++ b/greenfield/scripts/delivery/buildPaths.ts @@ -0,0 +1,37 @@ +import path from "node:path"; + +/** Canonical lexical paths for one repository-contained build output. */ +export interface RepositoryBuildPath { + readonly distRoot: string; + readonly output: string; + readonly repositoryRoot: string; +} + +/** + * Resolves one explicit build output as a strict child of the repository `dist` tree. + * @param repositoryRoot Normalized absolute future-root checkout. + * @param outputDirectory Normalized absolute output directory. + * @param errorMessage Fixed caller-owned validation failure. + * @returns Canonical lexical repository, dist, and output paths. + */ +export function resolveRepositoryBuildPath( + repositoryRoot: string, + outputDirectory: string, + errorMessage: string +): RepositoryBuildPath { + const root = path.resolve(repositoryRoot); + const output = path.resolve(outputDirectory); + const distRoot = path.join(root, "dist"); + if ( + !path.isAbsolute(repositoryRoot) || + !path.isAbsolute(outputDirectory) || + repositoryRoot.includes("\0") || + outputDirectory.includes("\0") || + root !== repositoryRoot || + output !== outputDirectory || + !output.startsWith(`${distRoot}${path.sep}`) + ) { + throw new TypeError(errorMessage); + } + return Object.freeze({ distRoot, output, repositoryRoot: root }); +} diff --git a/greenfield/scripts/delivery/buildProcesses.test.ts b/greenfield/scripts/delivery/buildProcesses.test.ts new file mode 100644 index 000000000..8e051c285 --- /dev/null +++ b/greenfield/scripts/delivery/buildProcesses.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFile, readdir, rm } from "node:fs/promises"; +import path from "node:path"; + +const repositoryRoot = path.resolve(import.meta.dir, "../.."); +const scriptPath = path.join(import.meta.dir, "buildProcesses.ts"); +const outputDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + outputDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function runBuild(outputDirectory: string) { + const child = Bun.spawn( + [process.execPath, scriptPath, `--output=${outputDirectory}`], + { + cwd: repositoryRoot, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + } + ); + const [exitCode, stderr, stdout] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + new Response(child.stdout).text(), + ]); + return { exitCode, stderr, stdout }; +} + +describe("Dashboard process artifacts", () => { + test("bundles only executable web and worker roots without source maps", async () => { + const outputDirectory = path.join( + repositoryRoot, + `dist/test-processes-${Bun.randomUUIDv7()}` + ); + outputDirectories.push(outputDirectory); + + const execution = await runBuild(outputDirectory); + const result = JSON.parse(execution.stdout) as { + databaseMaintenanceGzipBytes: number; + databaseMaintenanceRawBytes: number; + outputDirectory: string; + status: string; + webGzipBytes: number; + webRawBytes: number; + workerGzipBytes: number; + workerRawBytes: number; + }; + const directoryEntries = await readdir(outputDirectory); + const files = directoryEntries.toSorted(); + const [databaseMaintenance, web, worker] = await Promise.all([ + readFile(path.join(outputDirectory, "databaseMaintenance.js"), "utf8"), + readFile(path.join(outputDirectory, "web.js"), "utf8"), + readFile(path.join(outputDirectory, "worker.js"), "utf8"), + ]); + + expect(execution).toMatchObject({ exitCode: 0, stderr: "" }); + expect(files).toEqual(["databaseMaintenance.js", "web.js", "worker.js"]); + expect(result).toMatchObject({ outputDirectory, status: "BUILT" }); + expect(result.databaseMaintenanceGzipBytes).toBeGreaterThan(0); + expect(result.databaseMaintenanceRawBytes).toBeGreaterThan( + result.databaseMaintenanceGzipBytes + ); + expect(result.webGzipBytes).toBeGreaterThan(0); + expect(result.workerGzipBytes).toBeGreaterThan(0); + expect(result.webRawBytes).toBeGreaterThan(result.webGzipBytes); + expect(result.workerRawBytes).toBeGreaterThan(result.workerGzipBytes); + expect(web).toContain("Mira Dashboard web startup failed"); + expect(databaseMaintenance).toContain("Dashboard database maintenance failed"); + expect(worker).toContain("Mira Dashboard worker startup failed"); + expect(web).not.toContain("sourceMappingURL"); + expect(databaseMaintenance).not.toContain("sourceMappingURL"); + expect(worker).not.toContain("sourceMappingURL"); + }, 60_000); + + test("rejects output outside the repository dist boundary", async () => { + const execution = await runBuild(path.join(repositoryRoot, "build")); + + expect(execution.exitCode).toBe(1); + expect(execution.stdout).toBe(""); + expect(execution.stderr).toBe("Process build paths are invalid\n"); + }); +}); diff --git a/greenfield/scripts/delivery/buildProcesses.ts b/greenfield/scripts/delivery/buildProcesses.ts new file mode 100644 index 000000000..d071456ef --- /dev/null +++ b/greenfield/scripts/delivery/buildProcesses.ts @@ -0,0 +1,153 @@ +import { mkdir, readFile, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; + +import { withBunBuildAdmission } from "./buildAdmission.ts"; +import { parseBuildOutputArgument } from "./buildCli.ts"; +import { resolveRepositoryBuildPath } from "./buildPaths.ts"; + +const webEntrypoint = "src/app/dashboardServer.ts"; +const workerEntrypoint = "src/app/worker.ts"; +const databaseMaintenanceEntrypoint = "src/app/databaseMaintenance.ts"; +const maximumDatabaseMaintenanceGzipBytes = 2 * 1024 * 1024; +const maximumWebGzipBytes = 4 * 1024 * 1024; +const maximumWorkerGzipBytes = 2 * 1024 * 1024; + +/** Deterministic bundled process measurements used by release verification. */ +export interface ProcessBuildResult { + readonly databaseMaintenance: Readonly<{ + gzipBytes: number; + rawBytes: number; + }>; + readonly outputDirectory: string; + readonly web: Readonly<{ gzipBytes: number; rawBytes: number }>; + readonly worker: Readonly<{ gzipBytes: number; rawBytes: number }>; +} + +function validatedOutputDirectory( + repositoryRoot: string, + outputDirectory: string +): string { + return resolveRepositoryBuildPath( + repositoryRoot, + outputDirectory, + "Process build paths are invalid" + ).output; +} + +async function measurements( + filePath: string, + maximumGzipBytes: number, + role: "database-maintenance" | "web" | "worker" +): Promise> { + const contents = await readFile(filePath); + const gzipBytes = gzipSync(contents, { level: 9 }).byteLength; + if (contents.byteLength === 0 || gzipBytes > maximumGzipBytes) { + throw new Error(`Dashboard ${role} process bundle exceeds its byte budget`); + } + return Object.freeze({ gzipBytes, rawBytes: contents.byteLength }); +} + +/** + * Bundles the exact executable web and worker roots for the selected Bun runtime. + * @param repositoryRoot Canonical future-root checkout. + * @param outputDirectory Explicit contained `dist` child. + * @returns Bounded bundle measurements. + */ +export async function buildProcessArtifacts( + repositoryRoot: string, + outputDirectory: string +): Promise { + const output = validatedOutputDirectory(repositoryRoot, outputDirectory); + return withBunBuildAdmission(repositoryRoot, async () => { + await rm(output, { force: true, recursive: true }); + await mkdir(output, { recursive: true }); + + const result = await Bun.build({ + allowUnresolved: [], + conditions: ["production"], + entrypoints: [ + path.join(repositoryRoot, databaseMaintenanceEntrypoint), + path.join(repositoryRoot, webEntrypoint), + path.join(repositoryRoot, workerEntrypoint), + ], + format: "esm", + metafile: true, + minify: true, + naming: { entry: "[name].js" }, + outdir: output, + packages: "bundle", + root: repositoryRoot, + sourcemap: "none", + splitting: false, + target: "bun", + }); + if (!result.success) { + throw new AggregateError(result.logs, "Dashboard process build failed"); + } + const emittedNames = result.outputs + .map(({ path: outputPath }) => path.basename(outputPath)) + .toSorted(); + if ( + emittedNames.length !== 3 || + emittedNames[0] !== "dashboardServer.js" || + emittedNames[1] !== "databaseMaintenance.js" || + emittedNames[2] !== "worker.js" + ) { + throw new Error("Dashboard process build emitted an unexpected artifact set"); + } + await rename( + path.join(output, "dashboardServer.js"), + path.join(output, "web.js") + ); + const [databaseMaintenance, web, worker] = await Promise.all([ + measurements( + path.join(output, "databaseMaintenance.js"), + maximumDatabaseMaintenanceGzipBytes, + "database-maintenance" + ), + measurements(path.join(output, "web.js"), maximumWebGzipBytes, "web"), + measurements( + path.join(output, "worker.js"), + maximumWorkerGzipBytes, + "worker" + ), + ]); + return Object.freeze({ + databaseMaintenance, + outputDirectory: output, + web, + worker, + }); + }); +} + +if (import.meta.main) { + try { + const repositoryRoot = path.resolve(import.meta.dir, "../.."); + const result = await buildProcessArtifacts( + repositoryRoot, + parseBuildOutputArgument( + process.argv.slice(2), + path.join(repositoryRoot, "dist/processes") + ) + ); + process.stdout.write( + `${JSON.stringify({ + outputDirectory: result.outputDirectory, + status: "BUILT", + databaseMaintenanceGzipBytes: result.databaseMaintenance.gzipBytes, + databaseMaintenanceRawBytes: result.databaseMaintenance.rawBytes, + webGzipBytes: result.web.gzipBytes, + webRawBytes: result.web.rawBytes, + workerGzipBytes: result.worker.gzipBytes, + workerRawBytes: result.worker.rawBytes, + })}\n` + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Dashboard process build failed"; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/greenfield/scripts/delivery/buildRelease.test.ts b/greenfield/scripts/delivery/buildRelease.test.ts new file mode 100644 index 000000000..8b65c6106 --- /dev/null +++ b/greenfield/scripts/delivery/buildRelease.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + cp, + mkdir, + mkdtemp, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { releaseBuildCommands } from "../../src/shared/releaseManifest.ts"; +import type { BuildSourceIdentity } from "../buildSourceIdentity.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { buildDashboardRelease, type ReleaseBuildCommand } from "./buildRelease.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; + +const sourceProjectRoot = path.resolve(import.meta.dir, "../.."); +const temporaryDirectories: string[] = []; +const commitSha = "b".repeat(40); +const cleanSource: BuildSourceIdentity = Object.freeze({ + commitSha, + state: "clean", +}); +const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ + revision: "a".repeat(40), + version: "1.4.0", +}); + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => {}); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + await restoreOwnerWrite(path.join(directory, entry.name)); + } + } +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function repositoryFixture(): Promise { + const repositoryRoot = await mkdtemp(path.join(tmpdir(), "mira-release-build-")); + temporaryDirectories.push(repositoryRoot); + await Promise.all([ + cp( + path.join(sourceProjectRoot, "docs/generated"), + path.join(repositoryRoot, "docs/generated"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, "migrations"), + path.join(repositoryRoot, "migrations"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, "systemd"), + path.join(repositoryRoot, "systemd"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, ".bun-version"), + path.join(repositoryRoot, ".bun-version") + ), + cp( + path.join(sourceProjectRoot, "bun.lock"), + path.join(repositoryRoot, "bun.lock") + ), + cp( + path.join(sourceProjectRoot, "package.json"), + path.join(repositoryRoot, "package.json") + ), + ]); + return repositoryRoot; +} + +async function materializeCommandOutput( + command: ReleaseBuildCommand, + repositoryRoot: string +): Promise { + if (command === "bun run build:browser") { + await mkdir(path.join(repositoryRoot, "dist/browser/assets"), { + recursive: true, + }); + await Promise.all([ + writeFile(path.join(repositoryRoot, "dist/browser/index.html"), "dashboard"), + writeFile( + path.join(repositoryRoot, "dist/browser/assets/app-a1b2c3d4.js"), + "app" + ), + ]); + } + if (command === "bun run build:processes") { + await mkdir(path.join(repositoryRoot, "dist/processes"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(repositoryRoot, "dist/processes/databaseMaintenance.js"), + "database-maintenance" + ), + writeFile(path.join(repositoryRoot, "dist/processes/web.js"), "web"), + writeFile(path.join(repositoryRoot, "dist/processes/worker.js"), "worker"), + ]); + } +} + +describe("Dashboard release build", () => { + test("runs every represented command and publishes one frozen commit artifact", async () => { + const repositoryRoot = await repositoryFixture(); + const commands: ReleaseBuildCommand[] = []; + const result = await buildDashboardRelease(repositoryRoot, { + resolveSourceIdentity: () => cleanSource, + runCommand: async (command, root) => { + commands.push(command); + await materializeCommandOutput(command, root); + }, + runtimeIdentity, + }); + + expect(commands).toEqual(releaseBuildCommands); + expect(result.releaseRoot).toBe( + path.join(repositoryRoot, "dist/releases", commitSha) + ); + expect(result.manifest.source.commitSha).toBe(commitSha); + const releaseStatus = await stat(result.releaseRoot); + const manifestStatus = await stat( + path.join(result.releaseRoot, "release-manifest.json") + ); + const releaseEntries = await readdir(path.dirname(result.releaseRoot)); + expect(releaseStatus.mode & 0o777).toBe(0o500); + expect(manifestStatus.mode & 0o777).toBe(0o400); + expect(releaseEntries).toEqual([commitSha]); + }); + + test("rejects dirty or changing source and removes its staging tree", async () => { + const dirtyRoot = await repositoryFixture(); + let dirtyCommandRan = false; + const dirtyFailure = await rejectionError( + buildDashboardRelease(dirtyRoot, { + resolveSourceIdentity: () => ({ commitSha, state: "dirty" }), + runCommand: () => { + dirtyCommandRan = true; + return Promise.resolve(); + }, + runtimeIdentity, + }) + ); + expect(dirtyFailure.message).toBe("Dashboard release build failed"); + expect(dirtyCommandRan).toBeFalse(); + + const changingRoot = await repositoryFixture(); + let sourceReadCount = 0; + const changingFailure = await rejectionError( + buildDashboardRelease(changingRoot, { + resolveSourceIdentity: () => { + sourceReadCount += 1; + return sourceReadCount === 1 + ? cleanSource + : { commitSha, state: "dirty" }; + }, + runCommand: materializeCommandOutput, + runtimeIdentity, + }) + ); + const changingEntries = await readdir(path.join(changingRoot, "dist/releases")); + expect(changingFailure.message).toBe("Dashboard release build failed"); + expect(changingEntries).toEqual([]); + }); + + test("does not overwrite an existing commit artifact or retain failed staging", async () => { + const existingRoot = await repositoryFixture(); + const finalRoot = path.join(existingRoot, "dist/releases", commitSha); + await mkdir(finalRoot, { recursive: true }); + let existingCommandRan = false; + const existingFailure = await rejectionError( + buildDashboardRelease(existingRoot, { + resolveSourceIdentity: () => cleanSource, + runCommand: () => { + existingCommandRan = true; + return Promise.resolve(); + }, + runtimeIdentity, + }) + ); + expect(existingFailure.message).toBe("Dashboard release build failed"); + expect(existingCommandRan).toBeFalse(); + + const failedRoot = await repositoryFixture(); + const commandFailure = await rejectionError( + buildDashboardRelease(failedRoot, { + resolveSourceIdentity: () => cleanSource, + runCommand: async (command, root) => { + if (command === "bun run docs:check") throw new Error("failed"); + await materializeCommandOutput(command, root); + }, + runtimeIdentity, + }) + ); + const failedEntries = await readdir(path.join(failedRoot, "dist/releases")); + expect(commandFailure.message).toBe("Dashboard release build failed"); + expect(failedEntries).toEqual([]); + }); +}); diff --git a/greenfield/scripts/delivery/buildRelease.ts b/greenfield/scripts/delivery/buildRelease.ts new file mode 100644 index 000000000..327359ee9 --- /dev/null +++ b/greenfield/scripts/delivery/buildRelease.ts @@ -0,0 +1,204 @@ +import { lstat, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { + type ReleaseManifest, + releaseBuildCommands, +} from "../../src/shared/releaseManifest.ts"; +import { + type BuildSourceIdentity, + resolveBuildSourceIdentity, +} from "../buildSourceIdentity.ts"; +import { + type ReleaseRuntimeIdentity, + verifyReleaseIdentity, + writeReleaseIdentity, +} from "./releaseIdentity.ts"; +import { + createReleaseStagingPaths, + discardReleaseTree, + makeReleaseTreeImmutable, + promoteStagedRelease, + stageReleaseArtifacts, +} from "./releaseStaging.ts"; + +const releaseBuildDeadlineMs = 3 * 60 * 1000; +const releaseBuildFailureMessage = "Dashboard release build failed"; + +/** One exact package command represented in the release manifest. */ +export type ReleaseBuildCommand = (typeof releaseBuildCommands)[number]; + +/** Injectable command/source boundaries used by focused build orchestration tests. */ +export interface DashboardReleaseBuildDependencies { + readonly resolveSourceIdentity?: (repositoryRoot: string) => BuildSourceIdentity; + readonly runCommand?: ( + command: ReleaseBuildCommand, + repositoryRoot: string + ) => Promise; + readonly runtimeIdentity?: ReleaseRuntimeIdentity; +} + +/** Complete immutable local build ready for later production publication. */ +export interface DashboardReleaseBuild { + readonly manifest: ReleaseManifest; + readonly releaseRoot: string; +} + +type CleanBuildSourceIdentity = Readonly<{ + commitSha: string; + state: "clean"; +}>; + +function releaseBuildFailure(): Error { + return new Error(releaseBuildFailureMessage); +} + +function commandArguments(command: ReleaseBuildCommand): readonly string[] { + switch (command) { + case "bun run build:browser": { + return [process.execPath, "run", "build:browser"]; + } + case "bun run build:processes": { + return [process.execPath, "run", "build:processes"]; + } + case "bun run docs:check": { + return [process.execPath, "run", "docs:check"]; + } + case "bun run db:check": { + return [process.execPath, "run", "db:check"]; + } + } +} + +async function runReleaseBuildCommand( + command: ReleaseBuildCommand, + repositoryRoot: string +): Promise { + const child = Bun.spawn([...commandArguments(command)], { + cwd: repositoryRoot, + env: { ...process.env, CI: "1", NODE_ENV: "production" }, + signal: AbortSignal.timeout(releaseBuildDeadlineMs), + stderr: "inherit", + stdin: "ignore", + stdout: "inherit", + }); + if ((await child.exited) !== 0) throw releaseBuildFailure(); +} + +async function assertRepositoryRoot(repositoryRoot: string): Promise { + if ( + !path.isAbsolute(repositoryRoot) || + repositoryRoot.includes("\0") || + path.resolve(repositoryRoot) !== repositoryRoot || + typeof process.getuid !== "function" + ) { + throw releaseBuildFailure(); + } + const [canonical, status] = await Promise.all([ + realpath(repositoryRoot), + lstat(repositoryRoot, { bigint: true }), + ]); + if ( + canonical !== repositoryRoot || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) + ) { + throw releaseBuildFailure(); + } +} + +function requireCleanSource( + source: BuildSourceIdentity +): asserts source is CleanBuildSourceIdentity { + if (source.state !== "clean") throw releaseBuildFailure(); +} + +function requireSameCleanSource( + expected: CleanBuildSourceIdentity, + actual: BuildSourceIdentity +): void { + requireCleanSource(actual); + if (actual.commitSha !== expected.commitSha) throw releaseBuildFailure(); +} + +/** + * Builds, stages, manifests, freezes, and atomically publishes one local release artifact. + * This never mutates production state or release pointers. + * @param repositoryRoot Canonical clean future-root checkout. + * @param dependencies Focused command/source boundaries, defaulting to real Git and Bun. + * @returns Commit-addressed immutable release artifact below `dist/releases`. + */ +export async function buildDashboardRelease( + repositoryRoot: string, + dependencies: DashboardReleaseBuildDependencies = {} +): Promise { + const sourceResolver = + dependencies.resolveSourceIdentity ?? resolveBuildSourceIdentity; + const commandRunner = dependencies.runCommand ?? runReleaseBuildCommand; + let candidateRoot: string | undefined; + try { + await assertRepositoryRoot(repositoryRoot); + const source = sourceResolver(repositoryRoot); + requireCleanSource(source); + const paths = await createReleaseStagingPaths(repositoryRoot, source.commitSha); + candidateRoot = paths.stagingRoot; + + for (const command of releaseBuildCommands) { + await commandRunner(command, repositoryRoot); + } + requireSameCleanSource(source, sourceResolver(repositoryRoot)); + + await stageReleaseArtifacts({ + browserRoot: path.join(repositoryRoot, "dist/browser"), + processRoot: path.join(repositoryRoot, "dist/processes"), + repositoryRoot, + stagingRoot: paths.stagingRoot, + }); + requireSameCleanSource(source, sourceResolver(repositoryRoot)); + const manifest = await writeReleaseIdentity({ + releaseRoot: paths.stagingRoot, + repositoryRoot, + runtimeIdentity: dependencies.runtimeIdentity, + sourceIdentity: source, + }); + await verifyReleaseIdentity(paths.stagingRoot, manifest.runtime); + requireSameCleanSource(source, sourceResolver(repositoryRoot)); + + await makeReleaseTreeImmutable(repositoryRoot, paths.stagingRoot); + await promoteStagedRelease(repositoryRoot, paths); + candidateRoot = paths.finalRoot; + const verified = await verifyReleaseIdentity(paths.finalRoot, manifest.runtime); + if (JSON.stringify(verified) !== JSON.stringify(manifest)) { + throw releaseBuildFailure(); + } + return Object.freeze({ manifest: verified, releaseRoot: paths.finalRoot }); + } catch { + if (candidateRoot !== undefined) { + try { + await discardReleaseTree(repositoryRoot, candidateRoot); + } catch { + // Preserve the fixed release-build failure while leaving evidence for inspection. + } + } + throw releaseBuildFailure(); + } +} + +if (import.meta.main) { + try { + const repositoryRoot = path.resolve(import.meta.dir, "../.."); + const result = await buildDashboardRelease(repositoryRoot); + process.stdout.write( + `${JSON.stringify({ + commitSha: result.manifest.source.commitSha, + status: "BUILT", + })}\n` + ); + } catch (error) { + const message = + error instanceof Error ? error.message : releaseBuildFailureMessage; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/greenfield/scripts/delivery/databaseMaintenanceProcess.ts b/greenfield/scripts/delivery/databaseMaintenanceProcess.ts new file mode 100644 index 000000000..7823238f3 --- /dev/null +++ b/greenfield/scripts/delivery/databaseMaintenanceProcess.ts @@ -0,0 +1,323 @@ +import path from "node:path"; + +import * as v from "valibot"; + +import { + databaseSnapshotManifestSchema, + parseDatabaseSnapshotManifest, + type DatabaseSnapshotManifest, +} from "../../src/shared/databaseSnapshotManifest.ts"; +import { lowercaseUuidV7Schema } from "../../src/shared/validation.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; +import type { PublishedProductionRelease } from "./productionReleasePublication.ts"; +import { + type InstalledProductionRuntime, + type ProductionRuntimeVerificationDependencies, + verifyInstalledProductionRuntime, +} from "./productionRuntime.ts"; +import { verifyReleaseIdentity } from "./releaseIdentity.ts"; + +const databaseMaintenanceProcessFailureMessage = "Database maintenance process failed"; +const databaseMaintenanceDeadlineMs = 5 * 60 * 1000; +const maximumProcessOutputBytes = 128 * 1024; +const maintainedOutputSchema = v.strictObject({ status: v.literal("MAINTAINED") }); +const absentSnapshotOutputSchema = v.strictObject({ + state: v.literal("absent"), + status: v.literal("SNAPSHOT"), + transitionId: lowercaseUuidV7Schema(databaseMaintenanceProcessFailureMessage), +}); +const sourceDatabaseIdentitySchema = v.strictObject({ + ctimeNs: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d{0,39})$/u)), + device: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d{0,39})$/u)), + inode: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d{0,39})$/u)), + mtimeNs: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d{0,39})$/u)), + size: v.pipe(v.string(), v.regex(/^[1-9]\d{0,39}$/u)), +}); +const presentSnapshotOutputSchema = v.strictObject({ + manifest: databaseSnapshotManifestSchema, + snapshotDirectory: v.string(), + snapshotFile: v.string(), + sourceDatabase: sourceDatabaseIdentitySchema, + state: v.literal("present"), + status: v.literal("SNAPSHOT"), +}); + +/** Bounded result from one isolated child process invocation. */ +export interface DatabaseMaintenanceProcessOutput { + readonly exitCode: number; + readonly stderr: Uint8Array; + readonly stdout: Uint8Array; +} + +/** Injectable execution/runtime boundaries used by focused delivery tests. */ +export interface DatabaseMaintenanceProcessDependencies { + readonly execute?: ( + command: readonly string[], + releaseRoot: string + ) => Promise; + readonly runtimeVerification?: ProductionRuntimeVerificationDependencies; +} + +/** Verified present snapshot returned by the immutable maintenance process. */ +export interface PublishedDatabaseSnapshot { + readonly manifest: DatabaseSnapshotManifest; + readonly snapshotDirectory: string; + readonly snapshotFile: string; + readonly sourceDatabase: Readonly<{ + ctimeNs: string; + device: string; + inode: string; + mtimeNs: string; + size: string; + }>; + readonly state: "present"; +} + +export type PublishedDatabaseSnapshotResult = + | Readonly<{ state: "absent"; transitionId: string }> + | PublishedDatabaseSnapshot; + +function processFailure(): Error { + return new Error(databaseMaintenanceProcessFailureMessage); +} + +async function readBoundedStream( + stream: ReadableStream +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + total += result.value.byteLength; + if (total > maximumProcessOutputBytes) throw processFailure(); + chunks.push(result.value); + } + } finally { + reader.releaseLock(); + } + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +async function executeMaintenanceProcess( + command: readonly string[], + releaseRoot: string +): Promise { + const child = Bun.spawn([...command], { + cwd: releaseRoot, + env: { NODE_ENV: "production", PATH: "/usr/bin:/bin" }, + signal: AbortSignal.timeout(databaseMaintenanceDeadlineMs), + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + readBoundedStream(child.stdout), + readBoundedStream(child.stderr), + ]); + return Object.freeze({ exitCode, stderr, stdout }); + } catch { + child.kill(); + await child.exited.catch(() => null); + throw processFailure(); + } +} + +function decodeOutput(output: Uint8Array): unknown { + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(output); + if (!text.endsWith("\n") || text.trim().split("\n").length !== 1) { + throw processFailure(); + } + return JSON.parse(text) as unknown; + } catch { + throw processFailure(); + } +} + +async function verifyExecutionInputs( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime, + dependencies: DatabaseMaintenanceProcessDependencies +): Promise { + try { + const commitSha = release.manifest.source.commitSha; + if ( + lease.stateDirectory !== paths.stateDirectory || + release.releaseRoot !== path.join(paths.releasesDirectory, commitSha) + ) { + throw processFailure(); + } + const [verifiedManifest] = await Promise.all([ + verifyReleaseIdentity(release.releaseRoot, runtime.identity), + verifyInstalledProductionRuntime( + paths, + runtime, + dependencies.runtimeVerification + ), + ]); + if (JSON.stringify(verifiedManifest) !== JSON.stringify(release.manifest)) { + throw processFailure(); + } + } catch { + throw processFailure(); + } +} + +async function runProcess( + command: readonly string[], + releaseRoot: string, + dependencies: DatabaseMaintenanceProcessDependencies +): Promise { + const output = await (dependencies.execute ?? executeMaintenanceProcess)( + command, + releaseRoot + ); + if (output.exitCode !== 0 || output.stderr.byteLength !== 0) { + throw processFailure(); + } + return decodeOutput(output.stdout); +} + +function maintenanceCommand( + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime, + arguments_: readonly string[] +): readonly string[] { + return Object.freeze([ + runtime.executable, + path.join(release.releaseRoot, "server/databaseMaintenance.js"), + ...arguments_, + ]); +} + +/** + * Runs the candidate release's migration process against one isolated transition directory. + * @param lease Active deployment transition lease. + * @param paths Exact project-local production paths. + * @param release Verified immutable candidate release. + * @param runtime Exact runtime named by the candidate manifest. + * @param transitionId Canonical transition identifier. + * @param candidateStateDirectory Exact private candidate state directory. + * @param dependencies Injectable process/probe boundaries. + */ +export async function runDatabaseCandidateMaintenance( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime, + transitionId: string, + candidateStateDirectory: string, + dependencies: DatabaseMaintenanceProcessDependencies = {} +): Promise { + await verifyExecutionInputs(lease, paths, release, runtime, dependencies); + const expectedCandidate = path.join( + paths.stateDirectory, + `.database-transition-${transitionId}`, + "candidate" + ); + if ( + !v.is(lowercaseUuidV7Schema(), transitionId) || + candidateStateDirectory !== expectedCandidate + ) { + throw processFailure(); + } + const result = await runProcess( + maintenanceCommand(release, runtime, [ + "--operation=migrate-candidate", + `--migrations=${path.join(release.releaseRoot, "migrations")}`, + `--release=${release.manifest.source.commitSha}`, + `--state=${candidateStateDirectory}`, + ]), + release.releaseRoot, + dependencies + ); + if (!v.is(maintainedOutputSchema, result)) throw processFailure(); +} + +/** + * Snapshots the expected live state through the release that currently owns its schema. + * For first activation, the candidate executable may prove that the live database is absent. + * @param lease Active deployment transition lease. + * @param paths Exact project-local production paths. + * @param release Release whose maintenance executable owns the expected live schema. + * @param runtime Exact runtime named by that release. + * @param transitionId Canonical transition identifier. + * @param expectedState Whether live state must be absent or schema-current for this release. + * @param dependencies Injectable process/probe boundaries. + * @returns Verified absent marker or immutable snapshot artifact. + */ +export async function runDatabaseSnapshotMaintenance( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime, + transitionId: string, + expectedState: "absent" | "present", + dependencies: DatabaseMaintenanceProcessDependencies = {} +): Promise { + await verifyExecutionInputs(lease, paths, release, runtime, dependencies); + if (!v.is(lowercaseUuidV7Schema(), transitionId)) throw processFailure(); + const stateArguments = + expectedState === "present" + ? [ + `--migrations=${path.join(release.releaseRoot, "migrations")}`, + `--release=${release.manifest.source.commitSha}`, + ] + : []; + const value = await runProcess( + maintenanceCommand(release, runtime, [ + "--operation=snapshot", + `--expected-state=${expectedState}`, + ...stateArguments, + `--state=${paths.stateDirectory}`, + `--transition=${transitionId}`, + ]), + release.releaseRoot, + dependencies + ); + if (expectedState === "absent") { + const parsed = v.safeParse(absentSnapshotOutputSchema, value, { + abortEarly: true, + }); + if (!parsed.success || parsed.output.transitionId !== transitionId) { + throw processFailure(); + } + return Object.freeze({ state: "absent", transitionId }); + } + const parsed = v.safeParse(presentSnapshotOutputSchema, value, { + abortEarly: true, + }); + if (!parsed.success) throw processFailure(); + const expectedDirectory = path.join(paths.stateDirectory, "backups", transitionId); + const expectedFile = path.join(expectedDirectory, "mira-dashboard.db"); + const manifest = parseDatabaseSnapshotManifest(parsed.output.manifest); + if ( + parsed.output.snapshotDirectory !== expectedDirectory || + parsed.output.snapshotFile !== expectedFile || + manifest.transitionId !== transitionId || + manifest.releaseId !== release.manifest.source.commitSha + ) { + throw processFailure(); + } + return Object.freeze({ + manifest, + snapshotDirectory: expectedDirectory, + snapshotFile: expectedFile, + sourceDatabase: Object.freeze(parsed.output.sourceDatabase), + state: "present" as const, + }); +} diff --git a/greenfield/scripts/delivery/databaseTransitionFilesystem.test.ts b/greenfield/scripts/delivery/databaseTransitionFilesystem.test.ts new file mode 100644 index 000000000..a4f658ed0 --- /dev/null +++ b/greenfield/scripts/delivery/databaseTransitionFilesystem.test.ts @@ -0,0 +1,283 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdtemp, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { Effect, ManagedRuntime } from "effect"; + +import { + databaseCandidateMigrationLayer, + databaseRuntimeLayer, +} from "../../src/server/database/runtime/databaseService.ts"; +import { + createVerifiedDatabaseSnapshot, + type DatabaseSnapshotResult, +} from "../../src/server/database/runtime/databaseSnapshot.ts"; +import { parseProductionActivationTransition } from "../../src/shared/productionActivationTransition.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { + discardDatabaseTransitionWorkspace, + inspectDatabaseTransitionRecovery, + prepareDatabaseTransitionWorkspace, + prepareDatabaseRollbackCandidate, + promoteDatabaseTransitionCandidate, + restorePromotedDatabaseState, + verifyDatabaseTransitionCandidate, +} from "./databaseTransitionFilesystem.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; + +const migrationsDirectory = path.resolve(import.meta.dir, "../../migrations"); +const initialReleaseId = "a".repeat(40); +const candidateReleaseId = "b".repeat(40); +const temporaryDirectories: string[] = []; +const runtimes: Array<{ dispose(): Promise }> = []; + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600); + } + } +} + +afterEach(async () => { + await Promise.allSettled(runtimes.splice(0).map((runtime) => runtime.dispose())); + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function fixture() { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-db-transition-")); + temporaryDirectories.push(projectRoot); + const state = await prepareProtectedProductionStatePath(projectRoot); + const paths = await prepareProductionDeliveryDirectories(state); + return { paths, state }; +} + +async function initializeLiveDatabase(stateDirectory: string): Promise { + const runtime = ManagedRuntime.make( + databaseRuntimeLayer({ + migrationsDirectory, + releaseId: initialReleaseId, + startupMode: "initialize-empty", + stateDirectory, + }) + ); + runtimes.push(runtime); + await runtime.context(); + await runtime.dispose(); +} + +async function maintainCandidate( + stateDirectory: string, + releaseId = candidateReleaseId +): Promise { + const runtime = ManagedRuntime.make( + databaseCandidateMigrationLayer({ + migrationsDirectory, + releaseId, + stateDirectory, + }) + ); + runtimes.push(runtime); + await runtime.context(); + await runtime.dispose(); +} + +async function snapshot( + stateDirectory: string, + transitionId: string, + expectedState: "absent" | "present" +): Promise { + return Effect.runPromise( + createVerifiedDatabaseSnapshot( + expectedState === "absent" + ? { expectedState, stateDirectory, transitionId } + : { + expectedState, + migrationsDirectory, + releaseId: initialReleaseId, + stateDirectory, + transitionId, + } + ) + ); +} + +describe("database transition filesystem", () => { + test("promotes a newly initialized candidate over expected absent live state", async () => { + const { paths } = await fixture(); + const transitionId = Bun.randomUUIDv7(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + const previous = await snapshot(paths.stateDirectory, transitionId, "absent"); + const workspace = await prepareDatabaseTransitionWorkspace( + lease, + paths, + transitionId, + previous + ); + await maintainCandidate(workspace.candidateDirectory); + const candidate = await verifyDatabaseTransitionCandidate(workspace); + const promoted = await promoteDatabaseTransitionCandidate( + lease, + paths, + candidate + ); + expect(promoted.previous).toEqual(previous); + await discardDatabaseTransitionWorkspace(lease, paths, workspace); + }); + + const liveDatabase = path.join(paths.stateDirectory, "mira-dashboard.db"); + const database = new Database(liveDatabase, { readonly: true, strict: true }); + try { + expect( + database + .query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM schema_migrations" + ) + .get() + ).toEqual({ count: 1 }); + } finally { + database.close(true); + } + const stateEntries = await readdir(paths.stateDirectory); + expect( + stateEntries.filter((entry) => entry.startsWith(".database-transition-")) + ).toEqual([]); + }); + + test("copies a verified snapshot and rejects live identity drift before promotion", async () => { + const { paths } = await fixture(); + await initializeLiveDatabase(paths.stateDirectory); + const transitionId = Bun.randomUUIDv7(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + const previous = await snapshot( + paths.stateDirectory, + transitionId, + "present" + ); + if (previous.state !== "present") throw new Error("Expected snapshot"); + const workspace = await prepareDatabaseTransitionWorkspace( + lease, + paths, + transitionId, + previous + ); + await maintainCandidate(workspace.candidateDirectory); + const candidate = await verifyDatabaseTransitionCandidate(workspace); + const liveDatabase = path.join(paths.stateDirectory, "mira-dashboard.db"); + await chmod(liveDatabase, 0o400); + + const failure = await rejectionError( + promoteDatabaseTransitionCandidate(lease, paths, candidate) + ); + expect(failure.message).toBe( + "Database transition filesystem operation failed" + ); + expect(await stat(workspace.candidateDatabase)).toBeDefined(); + await chmod(liveDatabase, 0o600); + await discardDatabaseTransitionWorkspace(lease, paths, workspace); + }); + }); + + test("restores the previous release database from its immutable snapshot", async () => { + const { paths } = await fixture(); + await initializeLiveDatabase(paths.stateDirectory); + const transitionId = Bun.randomUUIDv7(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + const previous = await snapshot( + paths.stateDirectory, + transitionId, + "present" + ); + if (previous.state !== "present") throw new Error("Expected snapshot"); + const workspace = await prepareDatabaseTransitionWorkspace( + lease, + paths, + transitionId, + previous + ); + await maintainCandidate(workspace.candidateDirectory); + const candidate = await verifyDatabaseTransitionCandidate(workspace); + const promoted = await promoteDatabaseTransitionCandidate( + lease, + paths, + candidate + ); + + await prepareDatabaseRollbackCandidate(promoted, workspace); + const recovery = await inspectDatabaseTransitionRecovery( + lease, + paths, + parseProductionActivationTransition({ + candidate: { + releaseId: candidateReleaseId, + runtimeRevision: "c".repeat(40), + }, + formatVersion: 1, + phase: "rollback-required", + previousActivation: { + current: { + releaseId: initialReleaseId, + runtimeRevision: "c".repeat(40), + }, + formatVersion: 1, + previous: null, + transitionId: Bun.randomUUIDv7(), + }, + previousDatabase: { + manifest: previous.manifest, + sourceDatabase: previous.sourceDatabase, + state: "present", + }, + transitionId, + }) + ); + if (recovery.state !== "promoted") { + throw new Error("Expected promoted recovery state"); + } + await prepareDatabaseRollbackCandidate(recovery.promoted, recovery.workspace); + await maintainCandidate( + recovery.workspace.candidateDirectory, + initialReleaseId + ); + const rollbackCandidate = await verifyDatabaseTransitionCandidate( + recovery.workspace + ); + await restorePromotedDatabaseState( + lease, + paths, + recovery.promoted, + rollbackCandidate + ); + await discardDatabaseTransitionWorkspace(lease, paths, recovery.workspace); + }); + + const restored = new Database( + path.join(paths.stateDirectory, "mira-dashboard.db"), + { readonly: true, strict: true } + ); + try { + expect( + restored + .query<{ releaseId: string }, []>( + "SELECT release_id AS releaseId FROM schema_migrations" + ) + .get() + ).toEqual({ releaseId: initialReleaseId }); + } finally { + restored.close(true); + } + }); +}); diff --git a/greenfield/scripts/delivery/databaseTransitionFilesystem.ts b/greenfield/scripts/delivery/databaseTransitionFilesystem.ts new file mode 100644 index 000000000..87e5054dc --- /dev/null +++ b/greenfield/scripts/delivery/databaseTransitionFilesystem.ts @@ -0,0 +1,1081 @@ +import { constants, type BigIntStats } from "node:fs"; +import { + lstat, + mkdir, + open, + readdir, + realpath, + rename, + rmdir, + unlink, + type FileHandle, +} from "node:fs/promises"; +import path from "node:path"; + +import * as v from "valibot"; + +import { + parseDatabaseSnapshotManifest, + type DatabaseSnapshotManifest, +} from "../../src/shared/databaseSnapshotManifest.ts"; +import type { ProductionActivationTransition } from "../../src/shared/productionActivationTransition.ts"; +import { lowercaseUuidV7Schema } from "../../src/shared/validation.ts"; +import type { PublishedDatabaseSnapshotResult } from "./databaseMaintenanceProcess.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; + +const transitionFilesystemFailureMessage = + "Database transition filesystem operation failed"; +const databaseFileName = "mira-dashboard.db"; +const snapshotManifestFileName = "snapshot-manifest.json"; +const maximumDatabaseBytes = 64 * 1024 * 1024 * 1024; +const maximumManifestBytes = 64 * 1024; +const copyBufferBytes = 1024 * 1024; +const privateDirectoryMode = 0o700; +const privateFileMode = 0o600; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const sourceFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +const destinationFlags = + constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR; +const workspaceBrand: unique symbol = Symbol("DatabaseTransitionWorkspace"); +const candidateBrand: unique symbol = Symbol("VerifiedDatabaseCandidate"); +const promotedBrand: unique symbol = Symbol("PromotedDatabaseState"); +const allowedCandidateFiles = new Set([ + databaseFileName, + `${databaseFileName}-journal`, + `${databaseFileName}-shm`, + `${databaseFileName}-wal`, +]); + +interface FileIdentity { + readonly ctimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; + readonly mtimeNs: bigint; + readonly size: bigint; + readonly uid: bigint; +} + +interface DirectoryIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + +interface OpenedDirectory { + readonly handle: FileHandle; + readonly identity: DirectoryIdentity; + readonly lookupPath: string; + readonly path: string; +} + +/** Private candidate workspace created below project-local production state. */ +export interface DatabaseTransitionWorkspace { + readonly [workspaceBrand]: true; + readonly candidateDatabase: string; + readonly candidateDirectory: string; + readonly previous: PublishedDatabaseSnapshotResult; + readonly root: string; + readonly transitionId: string; +} + +/** Candidate whose file identity was checked after the maintenance process exited. */ +export interface VerifiedDatabaseCandidate { + readonly [candidateBrand]: true; + readonly fileIdentity: FileIdentity; + readonly workspace: DatabaseTransitionWorkspace; +} + +/** Live database identity returned after one atomic candidate promotion. */ +export interface PromotedDatabaseState { + readonly [promotedBrand]: true; + readonly fileIdentity: FileIdentity; + readonly previous: PublishedDatabaseSnapshotResult; + readonly transitionId: string; +} + +/** Recovery inspection result for a crash-interrupted prepared journal. */ +export type DatabaseTransitionRecovery = + | Readonly<{ state: "not-promoted"; transitionId: string }> + | Readonly<{ + promoted: PromotedDatabaseState; + state: "promoted"; + workspace: DatabaseTransitionWorkspace; + }>; + +function transitionFailure(): Error { + return new Error(transitionFilesystemFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function directoryIdentity(status: BigIntStats): DirectoryIdentity { + return Object.freeze({ dev: status.dev, ino: status.ino }); +} + +function fileIdentity(status: BigIntStats): FileIdentity { + if ( + typeof process.getuid !== "function" || + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + (status.mode & 0o7777n) !== 0o600n || + status.size <= 0n || + status.size > BigInt(maximumDatabaseBytes) + ) { + throw transitionFailure(); + } + return Object.freeze({ + ctimeNs: status.ctimeNs, + dev: status.dev, + ino: status.ino, + mtimeNs: status.mtimeNs, + size: status.size, + uid: status.uid, + }); +} + +function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { + return ( + left.ctimeNs === right.ctimeNs && + left.dev === right.dev && + left.ino === right.ino && + left.mtimeNs === right.mtimeNs && + left.size === right.size && + left.uid === right.uid + ); +} + +function sameFileObject(left: FileIdentity, right: FileIdentity): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.uid === right.uid + ); +} + +function sameDirectoryIdentity( + status: BigIntStats, + expected: DirectoryIdentity +): boolean { + return status.dev === expected.dev && status.ino === expected.ino; +} + +function validPrivateDirectory(status: BigIntStats, userId: number): boolean { + return ( + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(userId) && + (status.mode & 0o7777n) === 0o700n + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function openPrivateDirectory( + directory: string, + expectedDevice?: bigint, + expectedCanonicalPath = directory +): Promise { + if (process.platform !== "linux" || typeof process.getuid !== "function") { + throw transitionFailure(); + } + let handle: FileHandle | undefined; + let result: OpenedDirectory | undefined; + try { + handle = await open(directory, directoryFlags); + const [held, after, canonical] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(directory, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + const expected = directoryIdentity(held); + if ( + canonical !== expectedCanonicalPath || + !validPrivateDirectory(held, process.getuid()) || + !validPrivateDirectory(after, process.getuid()) || + !sameDirectoryIdentity(after, expected) || + (expectedDevice !== undefined && held.dev !== expectedDevice) + ) { + throw transitionFailure(); + } + result = Object.freeze({ + handle, + identity: expected, + lookupPath: directory, + path: expectedCanonicalPath, + }); + } catch { + await closeHandle(handle); + throw transitionFailure(); + } + return result; +} + +async function revalidateDirectory(directory: OpenedDirectory): Promise { + if (typeof process.getuid !== "function") throw transitionFailure(); + const [held, current, canonical] = await Promise.all([ + directory.handle.stat({ bigint: true }), + lstat(directory.lookupPath, { bigint: true }), + realpath(`/proc/self/fd/${directory.handle.fd}`), + ]); + if ( + canonical !== directory.path || + !validPrivateDirectory(held, process.getuid()) || + !validPrivateDirectory(current, process.getuid()) || + !sameDirectoryIdentity(held, directory.identity) || + !sameDirectoryIdentity(current, directory.identity) + ) { + throw transitionFailure(); + } +} + +async function clearOwnedCandidateFiles(candidate: OpenedDirectory): Promise { + if (typeof process.getuid !== "function") throw transitionFailure(); + const descriptorRoot = `/proc/self/fd/${candidate.handle.fd}`; + const entries = await readdir(descriptorRoot); + if ( + entries.length > allowedCandidateFiles.size || + entries.some((entry) => !allowedCandidateFiles.has(entry)) + ) { + throw transitionFailure(); + } + for (const entry of entries) { + const anchoredEntry = path.join(descriptorRoot, entry); + const status = await lstat(anchoredEntry, { bigint: true }); + if ( + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.dev !== candidate.identity.dev || + (status.mode & 0o7777n) !== 0o600n + ) { + throw transitionFailure(); + } + await unlink(anchoredEntry); + } + await candidate.handle.sync(); + const remainingEntries = await readdir(descriptorRoot); + if (remainingEntries.length > 0) throw transitionFailure(); + await revalidateDirectory(candidate); +} + +async function requireMissing(candidate: string): Promise { + try { + await lstat(candidate); + } catch (error) { + if (errorCode(error) === "ENOENT") return; + throw transitionFailure(); + } + throw transitionFailure(); +} + +async function requireSidecarsAbsent(databaseFile: string): Promise { + for (const suffix of ["-journal", "-shm", "-wal"] as const) { + await requireMissing(`${databaseFile}${suffix}`); + } +} + +async function hashFile(handle: FileHandle, expectedBytes: number): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + const buffer = Buffer.alloc(Math.min(copyBufferBytes, expectedBytes)); + let offset = 0; + while (offset < expectedBytes) { + const length = Math.min(buffer.byteLength, expectedBytes - offset); + const read = await handle.read(buffer, 0, length, offset); + if (read.bytesRead <= 0) throw transitionFailure(); + hasher.update(buffer.subarray(0, read.bytesRead)); + offset += read.bytesRead; + } + return hasher.digest("hex"); +} + +async function readAndValidateSnapshotManifest( + snapshotDirectory: string, + expected: DatabaseSnapshotManifest +): Promise { + const manifestFile = path.join(snapshotDirectory, snapshotManifestFileName); + let handle: FileHandle | undefined; + let failed = false; + try { + if (typeof process.getuid !== "function") throw transitionFailure(); + handle = await open(manifestFile, sourceFlags); + const held = await handle.stat({ bigint: true }); + const canonical = await realpath(`/proc/self/fd/${handle.fd}`); + if ( + canonical !== manifestFile || + !held.isFile() || + held.isSymbolicLink() || + held.nlink !== 1n || + held.uid !== BigInt(process.getuid()) || + (held.mode & 0o7777n) !== 0o400n || + held.size <= 0n || + held.size > BigInt(maximumManifestBytes) + ) { + throw transitionFailure(); + } + const bytes = Buffer.alloc(Number(held.size) + 1); + let offset = 0; + while (offset < bytes.byteLength) { + const read = await handle.read( + bytes, + offset, + bytes.byteLength - offset, + offset + ); + if (read.bytesRead === 0) break; + offset += read.bytesRead; + } + const [heldAfter, pathAfter] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(manifestFile, { bigint: true }), + ]); + if ( + offset !== Number(held.size) || + heldAfter.dev !== held.dev || + heldAfter.ino !== held.ino || + heldAfter.size !== held.size || + heldAfter.ctimeNs !== held.ctimeNs || + heldAfter.mtimeNs !== held.mtimeNs || + pathAfter.dev !== held.dev || + pathAfter.ino !== held.ino || + pathAfter.size !== held.size || + pathAfter.ctimeNs !== held.ctimeNs || + pathAfter.mtimeNs !== held.mtimeNs + ) { + throw transitionFailure(); + } + const text = new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray(0, offset) + ); + const value: unknown = JSON.parse(text); + const parsed = parseDatabaseSnapshotManifest(value); + if (JSON.stringify(parsed) !== JSON.stringify(expected)) { + throw transitionFailure(); + } + } catch { + failed = true; + } + const closed = await closeHandle(handle); + if (failed || !closed) throw transitionFailure(); +} + +async function copyVerifiedSnapshot( + snapshot: Extract, + destination: string, + expectedDevice: bigint +): Promise { + if (typeof process.getuid !== "function") throw transitionFailure(); + const snapshotDirectoryStatus = await lstat(snapshot.snapshotDirectory, { + bigint: true, + }); + const entries = await readdir(snapshot.snapshotDirectory); + if ( + !snapshotDirectoryStatus.isDirectory() || + snapshotDirectoryStatus.isSymbolicLink() || + snapshotDirectoryStatus.uid !== BigInt(process.getuid()) || + snapshotDirectoryStatus.dev !== expectedDevice || + (snapshotDirectoryStatus.mode & 0o7777n) !== 0o500n || + entries.length !== 2 || + entries.toSorted().join("\0") !== + [databaseFileName, snapshotManifestFileName].toSorted().join("\0") + ) { + throw transitionFailure(); + } + await readAndValidateSnapshotManifest(snapshot.snapshotDirectory, snapshot.manifest); + + let source: FileHandle | undefined; + let target: FileHandle | undefined; + let failed = false; + try { + source = await open(snapshot.snapshotFile, sourceFlags); + const sourceHeld = await source.stat({ bigint: true }); + const canonicalSource = await realpath(`/proc/self/fd/${source.fd}`); + if ( + canonicalSource !== snapshot.snapshotFile || + !sourceHeld.isFile() || + sourceHeld.isSymbolicLink() || + sourceHeld.nlink !== 1n || + sourceHeld.uid !== BigInt(process.getuid()) || + sourceHeld.dev !== expectedDevice || + sourceHeld.size !== BigInt(snapshot.manifest.database.bytes) || + (sourceHeld.mode & 0o7777n) !== 0o400n + ) { + throw transitionFailure(); + } + target = await open(destination, destinationFlags, privateFileMode); + const bytes = Number(sourceHeld.size); + const buffer = Buffer.alloc(Math.min(copyBufferBytes, bytes)); + const sourceHasher = new Bun.CryptoHasher("sha256"); + let offset = 0; + while (offset < bytes) { + const length = Math.min(buffer.byteLength, bytes - offset); + const read = await source.read(buffer, 0, length, offset); + if (read.bytesRead <= 0) throw transitionFailure(); + sourceHasher.update(buffer.subarray(0, read.bytesRead)); + let written = 0; + while (written < read.bytesRead) { + const write = await target.write( + buffer, + written, + read.bytesRead - written, + offset + written + ); + if (write.bytesWritten <= 0) throw transitionFailure(); + written += write.bytesWritten; + } + offset += read.bytesRead; + } + await target.sync(); + const [sourceAfter, sourcePathAfter, targetAfter] = await Promise.all([ + source.stat({ bigint: true }), + lstat(snapshot.snapshotFile, { bigint: true }), + target.stat({ bigint: true }), + ]); + if ( + sourceAfter.dev !== sourceHeld.dev || + sourceAfter.ino !== sourceHeld.ino || + sourceAfter.size !== sourceHeld.size || + sourcePathAfter.dev !== sourceHeld.dev || + sourcePathAfter.ino !== sourceHeld.ino || + sourcePathAfter.size !== sourceHeld.size || + targetAfter.size !== sourceHeld.size || + targetAfter.nlink !== 1n || + targetAfter.uid !== BigInt(process.getuid()) || + (targetAfter.mode & 0o7777n) !== 0o600n + ) { + throw transitionFailure(); + } + const sourceHash = sourceHasher.digest("hex"); + const targetHash = await hashFile(target, bytes); + if ( + sourceHash !== snapshot.manifest.database.sha256 || + targetHash !== sourceHash + ) { + throw transitionFailure(); + } + } catch { + failed = true; + } + const [sourceClosed, targetClosed] = await Promise.all([ + closeHandle(source), + closeHandle(target), + ]); + if (failed || !sourceClosed || !targetClosed) throw transitionFailure(); +} + +async function removeOwnedWorkspace( + stateDirectory: string, + transitionId: string +): Promise { + const expectedName = `.database-transition-${transitionId}`; + const workspaceRoot = path.join(stateDirectory, expectedName); + if ( + path.basename(workspaceRoot) !== expectedName || + !v.is(lowercaseUuidV7Schema(), transitionId) + ) { + throw transitionFailure(); + } + if (typeof process.getuid !== "function") throw transitionFailure(); + const state = await openPrivateDirectory(stateDirectory); + const anchoredRoot = path.join(`/proc/self/fd/${state.handle.fd}`, expectedName); + let root: OpenedDirectory | undefined; + let candidate: OpenedDirectory | undefined; + let failed = false; + let rootMissing = false; + try { + try { + await lstat(anchoredRoot, { bigint: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") { + rootMissing = true; + } else { + throw transitionFailure(); + } + } + if (!rootMissing) { + root = await openPrivateDirectory( + anchoredRoot, + state.identity.dev, + workspaceRoot + ); + const rootEntries = await readdir(`/proc/self/fd/${root.handle.fd}`); + if ( + rootEntries.length > 1 || + (rootEntries.length === 1 && rootEntries[0] !== "candidate") + ) { + throw transitionFailure(); + } + if (rootEntries.length === 1) { + const candidatePath = path.join(workspaceRoot, "candidate"); + const anchoredCandidate = path.join( + `/proc/self/fd/${root.handle.fd}`, + "candidate" + ); + candidate = await openPrivateDirectory( + anchoredCandidate, + state.identity.dev, + candidatePath + ); + await clearOwnedCandidateFiles(candidate); + if (!(await closeHandle(candidate.handle))) { + throw transitionFailure(); + } + candidate = undefined; + await rmdir(anchoredCandidate); + await root.handle.sync(); + } + if (!(await closeHandle(root.handle))) throw transitionFailure(); + root = undefined; + await rmdir(anchoredRoot); + await state.handle.sync(); + } + } catch { + failed = true; + } finally { + const [candidateClosed, rootClosed, stateClosed] = await Promise.all([ + closeHandle(candidate?.handle), + closeHandle(root?.handle), + closeHandle(state.handle), + ]); + if (!candidateClosed || !rootClosed || !stateClosed) failed = true; + } + if (failed) throw transitionFailure(); +} + +/** + * Creates one private candidate workspace and copies a verified pre-activation snapshot. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param transitionId Canonical transition identifier. + * @param previous Verified absent marker or immutable pre-activation snapshot. + * @returns Branded private candidate workspace. + */ +export async function prepareDatabaseTransitionWorkspace( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + transitionId: string, + previous: PublishedDatabaseSnapshotResult +): Promise { + if ( + lease.stateDirectory !== paths.stateDirectory || + !v.is(lowercaseUuidV7Schema(), transitionId) || + (previous.state === "absent" && previous.transitionId !== transitionId) || + (previous.state === "present" && previous.manifest.transitionId !== transitionId) + ) { + throw transitionFailure(); + } + const state = await openPrivateDirectory(paths.stateDirectory); + let root: OpenedDirectory | undefined; + let candidate: OpenedDirectory | undefined; + let result: DatabaseTransitionWorkspace | undefined; + let failed = false; + try { + const rootName = `.database-transition-${transitionId}`; + const stateDescriptor = `/proc/self/fd/${state.handle.fd}`; + const rootDescriptor = path.join(stateDescriptor, rootName); + await requireMissing(rootDescriptor); + await mkdir(rootDescriptor, { mode: privateDirectoryMode }); + const rootPath = path.join(paths.stateDirectory, rootName); + root = await openPrivateDirectory(rootPath, state.identity.dev); + await mkdir(path.join(`/proc/self/fd/${root.handle.fd}`, "candidate"), { + mode: privateDirectoryMode, + }); + const candidateDirectory = path.join(rootPath, "candidate"); + candidate = await openPrivateDirectory(candidateDirectory, state.identity.dev); + const candidateDatabase = path.join(candidateDirectory, databaseFileName); + if (previous.state === "present") { + await copyVerifiedSnapshot(previous, candidateDatabase, state.identity.dev); + } + await revalidateDirectory(candidate); + await revalidateDirectory(root); + await revalidateDirectory(state); + result = Object.freeze({ + [workspaceBrand]: true as const, + candidateDatabase, + candidateDirectory, + previous, + root: rootPath, + transitionId, + }); + } catch { + failed = true; + } + const [candidateClosed, rootClosed, stateClosed] = await Promise.all([ + closeHandle(candidate?.handle), + closeHandle(root?.handle), + closeHandle(state.handle), + ]); + if (failed || !candidateClosed || !rootClosed || !stateClosed || !result) { + try { + await removeOwnedWorkspace(paths.stateDirectory, transitionId); + } catch { + // Preserve the fixed failure and leave bounded private evidence. + } + throw transitionFailure(); + } + return result; +} + +/** + * Revalidates the candidate file after the isolated maintenance process exits. + * @param workspace Branded candidate workspace. + * @returns Branded candidate plus its stable file identity. + */ +export async function verifyDatabaseTransitionCandidate( + workspace: DatabaseTransitionWorkspace +): Promise { + try { + if (workspace[workspaceBrand] !== true) throw transitionFailure(); + const directory = await openPrivateDirectory(workspace.candidateDirectory); + let verified: VerifiedDatabaseCandidate | undefined; + let failed = false; + try { + const entries = await readdir(workspace.candidateDirectory); + if (entries.length !== 1 || entries[0] !== databaseFileName) { + throw transitionFailure(); + } + await requireSidecarsAbsent(workspace.candidateDatabase); + const before = fileIdentity( + await lstat(workspace.candidateDatabase, { bigint: true }) + ); + await revalidateDirectory(directory); + const after = fileIdentity( + await lstat(workspace.candidateDatabase, { bigint: true }) + ); + if (!sameFileIdentity(before, after)) throw transitionFailure(); + verified = Object.freeze({ + [candidateBrand]: true as const, + fileIdentity: after, + workspace, + }); + } catch { + failed = true; + } + const closed = await closeHandle(directory.handle); + if (failed || !closed || !verified) throw transitionFailure(); + return verified; + } catch { + throw transitionFailure(); + } +} + +function sourceIdentityMatches( + status: BigIntStats, + expected: Extract< + PublishedDatabaseSnapshotResult, + { state: "present" } + >["sourceDatabase"] +): boolean { + return ( + status.ctimeNs.toString() === expected.ctimeNs && + status.dev.toString() === expected.device && + status.ino.toString() === expected.inode && + status.mtimeNs.toString() === expected.mtimeNs && + status.size.toString() === expected.size + ); +} + +/** + * Atomically replaces the live database entry with one verified candidate on the same device. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param candidate Candidate verified after maintenance completion. + * @returns Promoted live file identity and matching pre-activation state. + */ +export async function promoteDatabaseTransitionCandidate( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + candidate: VerifiedDatabaseCandidate +): Promise { + const { workspace } = candidate; + if ( + candidate[candidateBrand] !== true || + workspace[workspaceBrand] !== true || + lease.stateDirectory !== paths.stateDirectory || + workspace.root !== + path.join( + paths.stateDirectory, + `.database-transition-${workspace.transitionId}` + ) + ) { + throw transitionFailure(); + } + const state = await openPrivateDirectory(paths.stateDirectory); + const candidateDirectory = await openPrivateDirectory( + workspace.candidateDirectory, + state.identity.dev + ); + let result: PromotedDatabaseState | undefined; + let failed = false; + try { + const candidateBefore = fileIdentity( + await lstat(workspace.candidateDatabase, { bigint: true }) + ); + if (!sameFileIdentity(candidate.fileIdentity, candidateBefore)) { + throw transitionFailure(); + } + const liveDatabase = path.join(paths.stateDirectory, databaseFileName); + await requireSidecarsAbsent(liveDatabase); + if (workspace.previous.state === "absent") { + await requireMissing(liveDatabase); + } else { + const liveStatus = await lstat(liveDatabase, { bigint: true }); + fileIdentity(liveStatus); + if (!sourceIdentityMatches(liveStatus, workspace.previous.sourceDatabase)) { + throw transitionFailure(); + } + } + await revalidateDirectory(candidateDirectory); + await revalidateDirectory(state); + await rename( + path.join(`/proc/self/fd/${candidateDirectory.handle.fd}`, databaseFileName), + path.join(`/proc/self/fd/${state.handle.fd}`, databaseFileName) + ); + await state.handle.sync(); + const liveIdentity = fileIdentity(await lstat(liveDatabase, { bigint: true })); + if (!sameFileObject(candidateBefore, liveIdentity)) { + throw transitionFailure(); + } + result = Object.freeze({ + [promotedBrand]: true as const, + fileIdentity: liveIdentity, + previous: workspace.previous, + transitionId: workspace.transitionId, + }); + } catch { + failed = true; + } + const [candidateClosed, stateClosed] = await Promise.all([ + closeHandle(candidateDirectory.handle), + closeHandle(state.handle), + ]); + if (failed || !candidateClosed || !stateClosed || !result) { + throw transitionFailure(); + } + return result; +} + +/** + * Copies the immutable pre-activation snapshot back into the emptied workspace. + * The previous release must validate this copy before it can be atomically restored. + * @param promoted Branded result from candidate promotion. + * @param workspace Original transition workspace whose candidate file was promoted. + * @returns Completion after the restore candidate is durable and private. + */ +export async function prepareDatabaseRollbackCandidate( + promoted: PromotedDatabaseState, + workspace: DatabaseTransitionWorkspace +): Promise { + if ( + promoted[promotedBrand] !== true || + workspace[workspaceBrand] !== true || + promoted.transitionId !== workspace.transitionId || + promoted.previous !== workspace.previous || + workspace.previous.state !== "present" + ) { + throw transitionFailure(); + } + const candidate = await openPrivateDirectory(workspace.candidateDirectory); + let failed = false; + try { + await clearOwnedCandidateFiles(candidate); + await copyVerifiedSnapshot( + workspace.previous, + workspace.candidateDatabase, + candidate.identity.dev + ); + await revalidateDirectory(candidate); + } catch { + failed = true; + } + const closed = await closeHandle(candidate.handle); + if (failed || !closed) throw transitionFailure(); +} + +function samePromotedLiveObject(status: BigIntStats, promoted: FileIdentity): boolean { + return ( + status.isFile() && + !status.isSymbolicLink() && + status.nlink === 1n && + status.dev === promoted.dev && + status.ino === promoted.ino && + status.uid === promoted.uid && + (status.mode & 0o7777n) === 0o600n + ); +} + +async function discardLiveSidecars( + state: OpenedDirectory, + liveDatabase: string +): Promise { + if (typeof process.getuid !== "function") throw transitionFailure(); + for (const suffix of ["-journal", "-shm", "-wal"] as const) { + const sidecarName = `${databaseFileName}${suffix}`; + const sidecar = `${liveDatabase}${suffix}`; + try { + const status = await lstat(sidecar, { bigint: true }); + if ( + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.dev !== state.identity.dev || + (status.mode & 0o7777n) !== 0o600n + ) { + throw transitionFailure(); + } + await unlink(path.join(`/proc/self/fd/${state.handle.fd}`, sidecarName)); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw transitionFailure(); + } + } +} + +/** + * Restores the exact pre-activation database state after candidate failure. + * Callers must stop candidate processes before discarding their private WAL sidecars. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param promoted Branded candidate promotion result. + * @param rollbackCandidate Previous-release-validated restore copy when state was present. + * @returns Completion after the previous database state is durable at the live entry. + */ +export async function restorePromotedDatabaseState( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + promoted: PromotedDatabaseState, + rollbackCandidate?: VerifiedDatabaseCandidate +): Promise { + if ( + promoted[promotedBrand] !== true || + lease.stateDirectory !== paths.stateDirectory || + (promoted.previous.state === "absent" && rollbackCandidate !== undefined) || + (promoted.previous.state === "present" && + (rollbackCandidate?.[candidateBrand] !== true || + rollbackCandidate.workspace.transitionId !== promoted.transitionId)) + ) { + throw transitionFailure(); + } + const state = await openPrivateDirectory(paths.stateDirectory); + let candidateDirectory: OpenedDirectory | undefined; + let failed = false; + try { + const liveDatabase = path.join(paths.stateDirectory, databaseFileName); + const liveStatus = await lstat(liveDatabase, { bigint: true }); + if (!samePromotedLiveObject(liveStatus, promoted.fileIdentity)) { + throw transitionFailure(); + } + if (rollbackCandidate) { + candidateDirectory = await openPrivateDirectory( + rollbackCandidate.workspace.candidateDirectory, + state.identity.dev + ); + const restoreIdentity = fileIdentity( + await lstat(rollbackCandidate.workspace.candidateDatabase, { + bigint: true, + }) + ); + if (!sameFileIdentity(restoreIdentity, rollbackCandidate.fileIdentity)) { + throw transitionFailure(); + } + } + await revalidateDirectory(state); + await discardLiveSidecars(state, liveDatabase); + if (rollbackCandidate && candidateDirectory) { + await revalidateDirectory(candidateDirectory); + await rename( + path.join( + `/proc/self/fd/${candidateDirectory.handle.fd}`, + databaseFileName + ), + path.join(`/proc/self/fd/${state.handle.fd}`, databaseFileName) + ); + const restored = fileIdentity(await lstat(liveDatabase, { bigint: true })); + if (!sameFileObject(rollbackCandidate.fileIdentity, restored)) { + throw transitionFailure(); + } + } else { + await unlink(path.join(`/proc/self/fd/${state.handle.fd}`, databaseFileName)); + await requireMissing(liveDatabase); + } + await state.handle.sync(); + } catch { + failed = true; + } + const [candidateClosed, stateClosed] = await Promise.all([ + closeHandle(candidateDirectory?.handle), + closeHandle(state.handle), + ]); + if (failed || !candidateClosed || !stateClosed) throw transitionFailure(); +} + +/** + * Removes one private transition workspace after commit or rollback completes. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param workspace Branded workspace owned by this transition. + * @returns Completion after the bounded workspace is absent. + */ +export async function discardDatabaseTransitionWorkspace( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + workspace: DatabaseTransitionWorkspace +): Promise { + if ( + workspace[workspaceBrand] !== true || + lease.stateDirectory !== paths.stateDirectory || + workspace.root !== + path.join( + paths.stateDirectory, + `.database-transition-${workspace.transitionId}` + ) + ) { + throw transitionFailure(); + } + await removeOwnedWorkspace(paths.stateDirectory, workspace.transitionId); +} + +/** + * Removes the exact deterministic workspace named by a durable recovery journal. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param transitionId Canonical journal transition identifier. + * @returns Completion after the workspace is absent. + */ +export async function discardOrphanDatabaseTransitionWorkspace( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + transitionId: string +): Promise { + if ( + lease.stateDirectory !== paths.stateDirectory || + !v.is(lowercaseUuidV7Schema(), transitionId) + ) { + throw transitionFailure(); + } + await removeOwnedWorkspace(paths.stateDirectory, transitionId); +} + +function previousFromJournal( + paths: PreparedProductionDeliveryPaths, + journal: ProductionActivationTransition +): PublishedDatabaseSnapshotResult { + if (journal.previousDatabase.state === "unrecorded") { + throw transitionFailure(); + } + if (journal.previousDatabase.state === "absent") { + return Object.freeze({ + state: "absent" as const, + transitionId: journal.transitionId, + }); + } + const snapshotDirectory = path.join( + paths.stateDirectory, + "backups", + journal.transitionId + ); + return Object.freeze({ + manifest: journal.previousDatabase.manifest, + snapshotDirectory, + snapshotFile: path.join(snapshotDirectory, databaseFileName), + sourceDatabase: journal.previousDatabase.sourceDatabase, + state: "present" as const, + }); +} + +async function pathPresence(candidate: string): Promise { + try { + await lstat(candidate); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw transitionFailure(); + } +} + +/** + * Inspects a crash-interrupted journal and reconstructs only filesystem-proven state. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param journal Durable snapshot-paired activation journal. + * @returns Whether promotion occurred, with branded recovery tokens when it did. + */ +export async function inspectDatabaseTransitionRecovery( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + journal: ProductionActivationTransition +): Promise { + if ( + lease.stateDirectory !== paths.stateDirectory || + !v.is(lowercaseUuidV7Schema(), journal.transitionId) + ) { + throw transitionFailure(); + } + const previous = previousFromJournal(paths, journal); + const root = path.join( + paths.stateDirectory, + `.database-transition-${journal.transitionId}` + ); + const candidateDirectory = path.join(root, "candidate"); + const candidateDatabase = path.join(candidateDirectory, databaseFileName); + const liveDatabase = path.join(paths.stateDirectory, databaseFileName); + const [rootPresent, livePresent] = await Promise.all([ + pathPresence(root), + pathPresence(liveDatabase), + ]); + const liveStatus = livePresent + ? await lstat(liveDatabase, { bigint: true }) + : undefined; + const previousStillLive = + previous.state === "absent" + ? !livePresent + : liveStatus !== undefined && + sourceIdentityMatches(liveStatus, previous.sourceDatabase); + if (previousStillLive) { + return Object.freeze({ + state: "not-promoted" as const, + transitionId: journal.transitionId, + }); + } + if (!rootPresent || !liveStatus) { + throw transitionFailure(); + } + const promotedIdentity = fileIdentity(liveStatus); + const workspace = Object.freeze({ + [workspaceBrand]: true as const, + candidateDatabase, + candidateDirectory, + previous, + root, + transitionId: journal.transitionId, + }); + return Object.freeze({ + promoted: Object.freeze({ + [promotedBrand]: true as const, + fileIdentity: promotedIdentity, + previous, + transitionId: journal.transitionId, + }), + state: "promoted" as const, + workspace, + }); +} diff --git a/greenfield/scripts/delivery/deploymentLease.test.ts b/greenfield/scripts/delivery/deploymentLease.test.ts new file mode 100644 index 000000000..29d589eea --- /dev/null +++ b/greenfield/scripts/delivery/deploymentLease.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + open, + rename, + rm, + symlink, + unlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function stateFixture(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "mira-deployment-lease-")); + temporaryDirectories.push(root); + const state = path.join(root, "state"); + await mkdir(state, { mode: 0o700 }); + return state; +} + +describe("Dashboard deployment lease", () => { + test("runs competing production transitions one at a time", async () => { + const state = await stateFixture(); + const events: string[] = []; + const firstMayFinish = Promise.withResolvers(); + + const first = withDeploymentLease(state, async () => { + events.push("first-start"); + await firstMayFinish.promise; + events.push("first-end"); + }); + await Bun.sleep(30); + const second = withDeploymentLease(state, () => { + events.push("second"); + return Promise.resolve(); + }); + await Bun.sleep(30); + + expect(events).toEqual(["first-start"]); + firstMayFinish.resolve(); + await Promise.all([first, second]); + expect(events).toEqual(["first-start", "first-end", "second"]); + }); + + test("waits while a competing process is publishing its lock record", async () => { + const state = await stateFixture(); + const lockPath = path.join(state, ".deployment.lock"); + const initializingLock = await open(lockPath, "wx", 0o600); + let entered = false; + + const transition = withDeploymentLease(state, () => { + entered = true; + return Promise.resolve(); + }); + await Bun.sleep(50); + expect(entered).toBe(false); + + await initializingLock.close(); + await unlink(lockPath); + await transition; + expect(entered).toBe(true); + }); + + test("rejects permissive, replaced and linked state directories", async () => { + const permissive = await stateFixture(); + await chmod(permissive, 0o755); + const permissionFailure = await rejectionError( + withDeploymentLease(permissive, () => Promise.resolve()) + ); + expect(permissionFailure.message).toBe("Dashboard deployment lease failed"); + + const replaced = await stateFixture(); + const displaced = `${replaced}.displaced`; + const replacement = `${replaced}.replacement`; + await mkdir(replacement, { mode: 0o700 }); + const replacementFailure = await rejectionError( + withDeploymentLease(replaced, async () => { + await rename(replaced, displaced); + await rename(replacement, replaced); + }) + ); + expect(replacementFailure.message).toBe("Dashboard deployment lease failed"); + + const symlinkRoot = await mkdtemp( + path.join(tmpdir(), "mira-deployment-lease-link-") + ); + temporaryDirectories.push(symlinkRoot); + const target = path.join(symlinkRoot, "target"); + const link = path.join(symlinkRoot, "state"); + await mkdir(target, { mode: 0o700 }); + await symlink(target, link); + const symlinkFailure = await rejectionError( + withDeploymentLease(link, () => Promise.resolve()) + ); + expect(symlinkFailure.message).toBe("Dashboard deployment lease failed"); + }); +}); diff --git a/greenfield/scripts/delivery/deploymentLease.ts b/greenfield/scripts/delivery/deploymentLease.ts new file mode 100644 index 000000000..42b9192e3 --- /dev/null +++ b/greenfield/scripts/delivery/deploymentLease.ts @@ -0,0 +1,119 @@ +import type { BigIntStats } from "node:fs"; +import { lstat, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { withExclusiveProcessLock } from "./exclusiveProcessLock.ts"; + +const deploymentLeaseDeadlineMs = 2 * 60 * 1000; +const deploymentLeaseRetryMs = 25; +const deploymentLockFileName = ".deployment.lock"; +const deploymentLeaseFailureMessage = "Dashboard deployment lease failed"; +const deploymentLeaseBrand: unique symbol = Symbol("DashboardDeploymentLease"); + +/** Unforgeable proof that one callback currently owns the project deployment lease. */ +export interface DashboardDeploymentLease { + readonly [deploymentLeaseBrand]: true; + readonly stateDirectory: string; +} + +interface StateDirectorySnapshot { + readonly dev: bigint; + readonly ino: bigint; + readonly mode: bigint; + readonly uid: bigint; +} + +function deploymentLeaseFailure(): Error { + return new Error(deploymentLeaseFailureMessage); +} + +function sameStateDirectory( + expected: StateDirectorySnapshot, + actual: StateDirectorySnapshot +): boolean { + return ( + expected.dev === actual.dev && + expected.ino === actual.ino && + expected.mode === actual.mode && + expected.uid === actual.uid + ); +} + +function stateDirectorySnapshot(status: BigIntStats): StateDirectorySnapshot { + if ( + typeof process.getuid !== "function" || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) || + (status.mode & 0o7777n) !== 0o700n + ) { + throw deploymentLeaseFailure(); + } + return Object.freeze({ + dev: status.dev, + ino: status.ino, + mode: status.mode, + uid: status.uid, + }); +} + +async function validateStateDirectory( + stateDirectory: string +): Promise { + if ( + !path.isAbsolute(stateDirectory) || + stateDirectory.includes("\0") || + path.resolve(stateDirectory) !== stateDirectory || + path.parse(stateDirectory).root === stateDirectory + ) { + throw deploymentLeaseFailure(); + } + try { + const [canonical, status] = await Promise.all([ + realpath(stateDirectory), + lstat(stateDirectory, { bigint: true }), + ]); + if (canonical !== stateDirectory) throw deploymentLeaseFailure(); + return stateDirectorySnapshot(status); + } catch { + throw deploymentLeaseFailure(); + } +} + +/** + * Serializes one complete production release/database transition below private state. + * @param stateDirectory Canonical current-user-owned `0700` production state directory. + * @param operation Complete transition; callers must keep services stopped until it settles. + * @returns Operation result after the lease and state identity are revalidated. + */ +export async function withDeploymentLease( + stateDirectory: string, + operation: (lease: DashboardDeploymentLease) => Promise +): Promise { + const initial = await validateStateDirectory(stateDirectory); + const lockPath = path.join(stateDirectory, deploymentLockFileName); + return withExclusiveProcessLock( + { + deadlineMs: deploymentLeaseDeadlineMs, + failureMessage: deploymentLeaseFailureMessage, + lockPath, + retryMs: deploymentLeaseRetryMs, + }, + async () => { + const before = await validateStateDirectory(stateDirectory); + if (!sameStateDirectory(initial, before)) { + throw deploymentLeaseFailure(); + } + const lease = Object.freeze({ + [deploymentLeaseBrand]: true as const, + stateDirectory, + }); + const result = await operation(lease); + const after = await validateStateDirectory(stateDirectory); + if (!sameStateDirectory(initial, after)) { + throw deploymentLeaseFailure(); + } + return result; + } + ); +} diff --git a/greenfield/scripts/delivery/exclusiveProcessLock.ts b/greenfield/scripts/delivery/exclusiveProcessLock.ts new file mode 100644 index 000000000..f6f21e5af --- /dev/null +++ b/greenfield/scripts/delivery/exclusiveProcessLock.ts @@ -0,0 +1,302 @@ +import { constants, type BigIntStats } from "node:fs"; +import { lstat, open, unlink, type FileHandle } from "node:fs/promises"; +import path from "node:path"; + +import * as v from "valibot"; + +const maximumProcessLockBytes = 512; +const processLockInitializationGraceMs = 5000; +const lockOpenFlags = + constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR; +const lockReadFlags = constants.O_NOFOLLOW | constants.O_RDONLY; +const processLockOwnerSchema = v.strictObject({ + pid: v.pipe(v.number(), v.integer(), v.minValue(1)), + token: v.pipe(v.string(), v.uuid()), +}); + +/** Bounded cross-process lock policy for one already protected parent directory. */ +export interface ExclusiveProcessLockOptions { + readonly deadlineMs: number; + readonly failureMessage: string; + readonly lockPath: string; + readonly retryMs: number; +} + +interface ProcessLockSnapshot { + readonly ctimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; + readonly size: bigint; +} + +interface OwnedProcessLock { + readonly path: string; + readonly snapshot: ProcessLockSnapshot; +} + +function processLockFailure(message: string): Error { + return new Error(message); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function validateOptions(options: ExclusiveProcessLockOptions): void { + if ( + !path.isAbsolute(options.lockPath) || + options.lockPath.includes("\0") || + path.resolve(options.lockPath) !== options.lockPath || + path.parse(options.lockPath).root === options.lockPath || + !Number.isSafeInteger(options.deadlineMs) || + options.deadlineMs <= 0 || + !Number.isSafeInteger(options.retryMs) || + options.retryMs <= 0 || + options.retryMs > options.deadlineMs || + options.failureMessage.length === 0 + ) { + throw processLockFailure(options.failureMessage); + } +} + +function snapshot(status: BigIntStats, failureMessage: string): ProcessLockSnapshot { + if ( + typeof process.getuid !== "function" || + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + (status.mode & 0o022n) !== 0n || + status.size <= 0n || + status.size > BigInt(maximumProcessLockBytes) + ) { + throw processLockFailure(failureMessage); + } + return Object.freeze({ + ctimeNs: status.ctimeNs, + dev: status.dev, + ino: status.ino, + size: status.size, + }); +} + +function sameSnapshot( + expected: ProcessLockSnapshot, + actual: ProcessLockSnapshot +): boolean { + return ( + expected.ctimeNs === actual.ctimeNs && + expected.dev === actual.dev && + expected.ino === actual.ino && + expected.size === actual.size + ); +} + +async function processLockMayStillBeInitializing( + lockPath: string, + failureMessage: string, + contents: string | undefined +): Promise { + // O_EXCL publishes the pathname before the owner can finish its bounded record. + // A newline terminates every complete record, so completed malformed data must + // fail immediately while only a secure incomplete publication receives grace. + if (contents?.endsWith("\n")) return false; + let status: BigIntStats; + try { + status = await lstat(lockPath, { bigint: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") return true; + throw processLockFailure(failureMessage); + } + if ( + typeof process.getuid !== "function" || + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + (status.mode & 0o022n) !== 0n || + status.size < 0n || + status.size > BigInt(maximumProcessLockBytes) + ) { + return false; + } + const ageMs = Date.now() - Number(status.ctimeMs); + return ageMs >= 0 && ageMs <= processLockInitializationGraceMs; +} + +async function createProcessLock( + options: ExclusiveProcessLockOptions +): Promise { + let handle: FileHandle; + try { + handle = await open(options.lockPath, lockOpenFlags, 0o600); + } catch (error) { + if (errorCode(error) === "EEXIST") return undefined; + throw processLockFailure(options.failureMessage); + } + try { + await handle.writeFile( + `${JSON.stringify({ pid: process.pid, token: Bun.randomUUIDv7() })}\n`, + "utf8" + ); + await handle.sync(); + const owned = Object.freeze({ + path: options.lockPath, + snapshot: snapshot( + await handle.stat({ bigint: true }), + options.failureMessage + ), + }); + await handle.close(); + return owned; + } catch { + await handle.close().catch(() => {}); + await unlink(options.lockPath).catch(() => {}); + throw processLockFailure(options.failureMessage); + } +} + +async function readProcessLock(options: ExclusiveProcessLockOptions): Promise< + | { + owner: v.InferOutput; + snapshot: ProcessLockSnapshot; + } + | undefined +> { + let handle: FileHandle; + let contents: string | undefined; + try { + handle = await open(options.lockPath, lockReadFlags); + } catch (error) { + if (errorCode(error) === "ENOENT") return undefined; + throw processLockFailure(options.failureMessage); + } + try { + const before = snapshot( + await handle.stat({ bigint: true }), + options.failureMessage + ); + contents = await handle.readFile("utf8"); + const after = snapshot( + await handle.stat({ bigint: true }), + options.failureMessage + ); + if ( + !sameSnapshot(before, after) || + Buffer.byteLength(contents) > maximumProcessLockBytes + ) { + throw processLockFailure(options.failureMessage); + } + const parsed: unknown = JSON.parse(contents); + return Object.freeze({ + owner: v.parse(processLockOwnerSchema, parsed), + snapshot: after, + }); + } catch { + if ( + await processLockMayStillBeInitializing( + options.lockPath, + options.failureMessage, + contents + ) + ) { + return undefined; + } + throw processLockFailure(options.failureMessage); + } finally { + await handle.close(); + } +} + +function isProcessAlive(pid: number, failureMessage: string): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (errorCode(error) === "ESRCH") return false; + if (errorCode(error) === "EPERM") return true; + throw processLockFailure(failureMessage); + } +} + +async function recoverStaleProcessLock( + options: ExclusiveProcessLockOptions +): Promise { + const observed = await readProcessLock(options); + if ( + observed === undefined || + isProcessAlive(observed.owner.pid, options.failureMessage) + ) { + return; + } + let currentStatus: BigIntStats; + try { + currentStatus = await lstat(options.lockPath, { bigint: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") return; + throw processLockFailure(options.failureMessage); + } + const current = snapshot(currentStatus, options.failureMessage); + if (!sameSnapshot(observed.snapshot, current)) { + throw processLockFailure(options.failureMessage); + } + try { + await unlink(options.lockPath); + } catch (error) { + if (errorCode(error) !== "ENOENT") { + throw processLockFailure(options.failureMessage); + } + } +} + +async function acquireProcessLock( + options: ExclusiveProcessLockOptions +): Promise { + const deadline = Date.now() + options.deadlineMs; + while (Date.now() < deadline) { + const owned = await createProcessLock(options); + if (owned !== undefined) return owned; + await recoverStaleProcessLock(options); + await Bun.sleep(options.retryMs); + } + throw processLockFailure(options.failureMessage); +} + +async function releaseProcessLock( + lock: OwnedProcessLock, + failureMessage: string +): Promise { + try { + const current = snapshot( + await lstat(lock.path, { bigint: true }), + failureMessage + ); + if (!sameSnapshot(lock.snapshot, current)) { + throw processLockFailure(failureMessage); + } + await unlink(lock.path); + } catch { + throw processLockFailure(failureMessage); + } +} + +/** + * Runs one operation under a bounded cross-process lock with dead-owner recovery. + * @param options Exact lock path and admission policy below an already protected directory. + * @param operation Complete operation that must never overlap another lock owner. + * @returns Operation result after the owned lock is released. + */ +export async function withExclusiveProcessLock( + options: ExclusiveProcessLockOptions, + operation: () => Promise +): Promise { + validateOptions(options); + const lock = await acquireProcessLock(options); + try { + return await operation(); + } finally { + await releaseProcessLock(lock, options.failureMessage); + } +} diff --git a/greenfield/scripts/delivery/installProductionSystemdUnits.test.ts b/greenfield/scripts/delivery/installProductionSystemdUnits.test.ts new file mode 100644 index 000000000..9f8acd046 --- /dev/null +++ b/greenfield/scripts/delivery/installProductionSystemdUnits.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rename, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + createLocalReleaseFixture, + removeProductionDeliveryFixtures, +} from "../testSupport/productionDeliveryFixture.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { + installPublishedProductionSystemdUnits, + parseInstallProductionSystemdUnitsArguments, +} from "./installProductionSystemdUnits.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { publishProductionRelease } from "./productionReleasePublication.ts"; +import { installProductionRuntime } from "./productionRuntime.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; +import type { SystemctlProcessResult } from "./systemctlProcess.ts"; + +const sourceProjectRoot = path.resolve(import.meta.dir, "../.."); +const releaseId = "a".repeat(40); +const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ + revision: "b".repeat(40), + version: "1.4.0", +}); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await removeProductionDeliveryFixtures(temporaryDirectories); +}); + +function successfulSystemctl(): SystemctlProcessResult { + return Object.freeze({ + exitCode: 0, + stderr: new Uint8Array(), + stdout: new Uint8Array(), + }); +} + +async function installationFixture() { + const homeDirectory = await mkdtemp(path.join(tmpdir(), "mira-systemd-home-")); + temporaryDirectories.push(homeDirectory); + const projectRoot = path.join(homeDirectory, "projects/mira-dashboard"); + await mkdir(projectRoot, { recursive: true, mode: 0o700 }); + const sourceRelease = await createLocalReleaseFixture( + sourceProjectRoot, + releaseId, + runtimeIdentity, + temporaryDirectories + ); + const runtimeRoot = await mkdtemp(path.join(tmpdir(), "mira-systemd-runtime-")); + temporaryDirectories.push(runtimeRoot); + const runtimeSource = path.join(runtimeRoot, "bun"); + await writeFile(runtimeSource, "test-bun-runtime", { mode: 0o500 }); + const state = await prepareProtectedProductionStatePath(projectRoot); + const userUnitDirectory = path.join(homeDirectory, ".config/systemd/user"); + return { + homeDirectory, + projectRoot, + runtimeSource, + sourceRelease, + state, + userUnitDirectory, + }; +} + +describe("production systemd unit installation", () => { + test("installs only manifest units atomically and reloads without service mutation", async () => { + const fixture = await installationFixture(); + const commands: string[][] = []; + await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(fixture.state); + const runtime = await installProductionRuntime( + lease, + paths, + runtimeIdentity, + { + probeRuntime: () => Promise.resolve(runtimeIdentity), + sourceExecutable: fixture.runtimeSource, + } + ); + const release = await publishProductionRelease( + lease, + paths, + fixture.sourceRelease, + runtime.identity + ); + const execute = (_executable: string, arguments_: readonly string[]) => { + commands.push([...arguments_]); + return Promise.resolve(successfulSystemctl()); + }; + const dependencies = { + execute, + homeDirectory: fixture.homeDirectory, + userUnitDirectory: fixture.userUnitDirectory, + }; + + await installPublishedProductionSystemdUnits( + lease, + paths, + release, + dependencies + ); + await installPublishedProductionSystemdUnits( + lease, + paths, + release, + dependencies + ); + const reloadFailure = await rejectionError( + installPublishedProductionSystemdUnits(lease, paths, release, { + ...dependencies, + execute: (_executable, arguments_) => { + commands.push([...arguments_]); + return Promise.resolve({ + exitCode: 1, + stderr: new Uint8Array(), + stdout: new Uint8Array(), + }); + }, + }) + ); + expect(reloadFailure.message).toBe( + "Production systemd unit installation failed" + ); + + for (const fileName of [ + "mira-dashboard-web.service", + "mira-dashboard-worker.service", + ]) { + const installedPath = path.join(fixture.userUnitDirectory, fileName); + const sourcePath = path.join(release.releaseRoot, "systemd", fileName); + expect(await readFile(installedPath)).toEqual(await readFile(sourcePath)); + const installedStatus = await stat(installedPath); + expect(installedStatus.mode & 0o7777).toBe(0o600); + expect(installedStatus.nlink).toBe(1); + } + }); + + expect(commands).toEqual([ + ["--user", "daemon-reload"], + ["--user", "daemon-reload"], + ["--user", "daemon-reload"], + ]); + expect(commands.flat()).not.toContain("start"); + expect(commands.flat()).not.toContain("restart"); + expect(commands.flat()).not.toContain("enable"); + }); + + test("rejects an untrusted destination and a destination identity swap", async () => { + const fixture = await installationFixture(); + await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(fixture.state); + const runtime = await installProductionRuntime( + lease, + paths, + runtimeIdentity, + { + probeRuntime: () => Promise.resolve(runtimeIdentity), + sourceExecutable: fixture.runtimeSource, + } + ); + const release = await publishProductionRelease( + lease, + paths, + fixture.sourceRelease, + runtime.identity + ); + const dependencies = { + execute: () => Promise.resolve(successfulSystemctl()), + homeDirectory: fixture.homeDirectory, + userUnitDirectory: fixture.userUnitDirectory, + }; + await installPublishedProductionSystemdUnits( + lease, + paths, + release, + dependencies + ); + const webUnit = path.join( + fixture.userUnitDirectory, + "mira-dashboard-web.service" + ); + const displaced = `${webUnit}.displaced`; + const swapFailure = await rejectionError( + installPublishedProductionSystemdUnits(lease, paths, release, { + ...dependencies, + filesystemTestHooks: { + async beforeRename(fileName) { + if (fileName !== "mira-dashboard-web.service") return; + await rename(webUnit, displaced); + await symlink(displaced, webUnit); + }, + }, + }) + ); + expect(swapFailure.message).toBe( + "Production systemd unit installation failed" + ); + + await chmod(fixture.userUnitDirectory, 0o733); + const permissionFailure = await rejectionError( + installPublishedProductionSystemdUnits( + lease, + paths, + release, + dependencies + ) + ); + expect(permissionFailure.message).toBe( + "Production systemd unit installation failed" + ); + }); + }); + + test("parses only the exact project, release, runtime, and user-unit arguments", () => { + const homeDirectory = "/home/dashboard"; + const projectRoot = `${homeDirectory}/projects/mira-dashboard`; + const userUnitDirectory = `${homeDirectory}/.config/systemd/user`; + expect( + parseInstallProductionSystemdUnitsArguments([ + `--project-root=${projectRoot}`, + `--release-id=${releaseId}`, + `--runtime-revision=${runtimeIdentity.revision}`, + `--user-unit-directory=${userUnitDirectory}`, + ]) + ).toEqual({ + projectRoot, + releaseId, + runtimeRevision: runtimeIdentity.revision, + userUnitDirectory, + }); + expect(() => + parseInstallProductionSystemdUnitsArguments([ + `--project-root=${projectRoot}`, + `--release-id=${releaseId}`, + `--release-id=${releaseId}`, + `--user-unit-directory=${userUnitDirectory}`, + ]) + ).toThrow("Usage:"); + }); +}); diff --git a/greenfield/scripts/delivery/installProductionSystemdUnits.ts b/greenfield/scripts/delivery/installProductionSystemdUnits.ts new file mode 100644 index 000000000..e69233b0b --- /dev/null +++ b/greenfield/scripts/delivery/installProductionSystemdUnits.ts @@ -0,0 +1,315 @@ +import { userInfo } from "node:os"; +import path from "node:path"; + +import * as v from "valibot"; + +import { fullCommitShaSchema } from "../../src/shared/validation.ts"; +import { readBoundedRegularFile } from "../files/boundedFile.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { + prepareProductionDeliveryDirectories, + type PreparedProductionDeliveryPaths, +} from "./productionDeliveryFilesystem.ts"; +import { + loadPublishedProductionRelease, + type PublishedProductionRelease, +} from "./productionReleasePublication.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; +import { + installProductionSystemdUnitFiles, + type ProductionSystemdUnitFile, + type ProductionSystemdUnitFilesystemTestHooks, +} from "./productionSystemdUnitFilesystem.ts"; +import { + productionProjectHomeRelativePath, + productionSystemdUnits, +} from "./productionSystemdUnitPolicy.ts"; +import { + executeSystemctlProcess, + requireSuccessfulSystemctlProcess, + type SystemctlExecutor, +} from "./systemctlProcess.ts"; + +const unitInstallFailureMessage = "Production systemd unit installation failed"; +const unitInstallUsage = + "Usage: bun run delivery:install-units --project-root=/absolute/project --release-id=<40-hex> --runtime-revision=<40-hex> --user-unit-directory=/absolute/home/.config/systemd/user"; +const maximumUnitBytes = 64 * 1024; +const systemctlExecutableDefault = "/usr/bin/systemctl"; +const absolutePathSchema = v.pipe( + v.string(), + v.maxLength(4096), + v.check( + (input) => + path.isAbsolute(input) && + path.resolve(input) === input && + path.parse(input).root !== input && + !input.includes("\0"), + unitInstallUsage + ) +); +const installArgumentsSchema = v.strictObject({ + projectRoot: absolutePathSchema, + releaseId: fullCommitShaSchema(unitInstallUsage), + runtimeRevision: fullCommitShaSchema(unitInstallUsage), + userUnitDirectory: absolutePathSchema, +}); +const installResultSchema = v.strictObject({ + releaseId: fullCommitShaSchema(unitInstallFailureMessage), + status: v.literal("INSTALLED"), +}); + +/** Exact explicit systemd-unit installation CLI inputs. */ +export type InstallProductionSystemdUnitsArguments = Readonly< + v.InferOutput +>; + +/** Redacted machine-readable unit installation result. */ +export type InstallProductionSystemdUnitsResult = Readonly< + v.InferOutput +>; + +/** Injectable host boundaries used by focused unit-installation tests. */ +export interface ProductionSystemdUnitInstallDependencies { + readonly execute?: SystemctlExecutor; + readonly filesystemTestHooks?: ProductionSystemdUnitFilesystemTestHooks; + readonly homeDirectory?: string; + readonly systemctlExecutable?: string; + readonly userUnitDirectory?: string; +} + +function unitInstallFailure(): Error { + return new Error(unitInstallFailureMessage); +} + +function currentUserHomeDirectory(): string { + if (typeof process.getuid !== "function") throw unitInstallFailure(); + const identity = userInfo(); + if ( + identity.uid !== process.getuid() || + !path.isAbsolute(identity.homedir) || + path.resolve(identity.homedir) !== identity.homedir || + path.parse(identity.homedir).root === identity.homedir || + identity.homedir.includes("\0") + ) { + throw unitInstallFailure(); + } + return identity.homedir; +} + +function sha256(bytes: Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); +} + +function sameRelease( + left: PublishedProductionRelease, + right: PublishedProductionRelease +): boolean { + return ( + left.releaseRoot === right.releaseRoot && + JSON.stringify(left.manifest) === JSON.stringify(right.manifest) + ); +} + +function validateProjectBinding( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + homeDirectory: string, + userUnitDirectory: string +): void { + const projectRoot = path.join(homeDirectory, productionProjectHomeRelativePath); + if ( + lease.stateDirectory !== paths.stateDirectory || + paths.productionDirectory !== path.join(projectRoot, "production") || + paths.releasesDirectory !== path.join(projectRoot, "production/releases") || + paths.runtimesDirectory !== path.join(projectRoot, "production/runtimes") || + userUnitDirectory !== path.join(homeDirectory, ".config/systemd/user") + ) { + throw unitInstallFailure(); + } +} + +async function readManifestUnits( + release: PublishedProductionRelease +): Promise { + const systemdArtifacts = release.manifest.artifacts.filter(({ path: artifactPath }) => + artifactPath.startsWith("systemd/") + ); + if ( + systemdArtifacts.length !== productionSystemdUnits.length || + productionSystemdUnits.some( + ({ artifactPath }, index) => systemdArtifacts[index]?.path !== artifactPath + ) + ) { + throw unitInstallFailure(); + } + const units: ProductionSystemdUnitFile[] = []; + for (const policy of productionSystemdUnits) { + const artifact = systemdArtifacts.find( + ({ path: artifactPath }) => artifactPath === policy.artifactPath + ); + if (!artifact || artifact.bytes > maximumUnitBytes) { + throw unitInstallFailure(); + } + const bytes = await readBoundedRegularFile( + path.join(release.releaseRoot, artifact.path), + release.releaseRoot, + maximumUnitBytes, + unitInstallFailureMessage + ); + if (bytes.byteLength !== artifact.bytes || sha256(bytes) !== artifact.sha256) { + throw unitInstallFailure(); + } + units.push( + Object.freeze({ + bytes, + fileName: policy.fileName, + sha256: artifact.sha256, + }) + ); + } + return Object.freeze(units); +} + +/** + * Installs the exact units from one immutable published release and reloads user systemd. + * It never starts, stops, restarts, enables, or disables a service. + * @param lease Active deployment lease guarding the complete delivery transition. + * @param paths Exact protected project-local production paths. + * @param release Candidate or rollback release whose unit bytes must become active. + * @param dependencies Explicit host and deterministic test boundaries. + */ +export async function installPublishedProductionSystemdUnits( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + release: PublishedProductionRelease, + dependencies: ProductionSystemdUnitInstallDependencies = {} +): Promise { + try { + const homeDirectory = dependencies.homeDirectory ?? currentUserHomeDirectory(); + const userUnitDirectory = + dependencies.userUnitDirectory ?? + path.join(homeDirectory, ".config/systemd/user"); + validateProjectBinding(lease, paths, homeDirectory, userUnitDirectory); + const verified = await loadPublishedProductionRelease( + paths, + release.manifest.source.commitSha, + release.manifest.runtime.revision + ); + if (!sameRelease(release, verified)) throw unitInstallFailure(); + const units = await readManifestUnits(verified); + await installProductionSystemdUnitFiles( + homeDirectory, + userUnitDirectory, + units, + dependencies.filesystemTestHooks + ); + const after = await loadPublishedProductionRelease( + paths, + release.manifest.source.commitSha, + release.manifest.runtime.revision + ); + if (!sameRelease(verified, after)) throw unitInstallFailure(); + await requireSuccessfulSystemctlProcess( + dependencies.execute ?? executeSystemctlProcess, + dependencies.systemctlExecutable ?? systemctlExecutableDefault, + ["--user", "daemon-reload"] + ); + } catch { + throw unitInstallFailure(); + } +} + +function readNamedArguments(arguments_: readonly string[]): Record { + const values = Object.create(null) as Record; + for (const argument of arguments_) { + const separator = argument.indexOf("="); + if (separator <= 2 || !argument.startsWith("--")) { + throw new TypeError(unitInstallUsage); + } + const name = argument.slice(2, separator); + const value = argument.slice(separator + 1); + if (!value || Object.hasOwn(values, name)) { + throw new TypeError(unitInstallUsage); + } + values[name] = value; + } + return values; +} + +/** + * Parses the exact unit-installation command without ambient path defaults. + * @param arguments_ Arguments after the Bun entrypoint. + * @returns Frozen project, release, runtime, and user-unit paths. + */ +export function parseInstallProductionSystemdUnitsArguments( + arguments_: readonly string[] +): InstallProductionSystemdUnitsArguments { + if (arguments_.length !== 4) throw new TypeError(unitInstallUsage); + const named = readNamedArguments(arguments_); + const parsed = v.safeParse( + installArgumentsSchema, + { + projectRoot: named["project-root"], + releaseId: named["release-id"], + runtimeRevision: named["runtime-revision"], + userUnitDirectory: named["user-unit-directory"], + }, + { abortEarly: true } + ); + if (!parsed.success) throw new TypeError(unitInstallUsage); + return Object.freeze(parsed.output); +} + +/** + * Revalidates project state and installs one already-published release's unit files. + * @param arguments_ Exact explicit unit-installation CLI arguments. + * @param dependencies Injectable host boundaries used by tests. + * @returns Redacted installation identity. + */ +export async function runInstallProductionSystemdUnitsCli( + arguments_: readonly string[], + dependencies: ProductionSystemdUnitInstallDependencies = {} +): Promise { + const parsed = parseInstallProductionSystemdUnitsArguments(arguments_); + const homeDirectory = dependencies.homeDirectory ?? currentUserHomeDirectory(); + if ( + parsed.projectRoot !== + path.join(homeDirectory, productionProjectHomeRelativePath) || + parsed.userUnitDirectory !== path.join(homeDirectory, ".config/systemd/user") + ) { + throw unitInstallFailure(); + } + const state = await prepareProtectedProductionStatePath(parsed.projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const release = await loadPublishedProductionRelease( + paths, + parsed.releaseId, + parsed.runtimeRevision + ); + await installPublishedProductionSystemdUnits(lease, paths, release, { + ...dependencies, + homeDirectory, + userUnitDirectory: parsed.userUnitDirectory, + }); + }); + return Object.freeze( + v.parse(installResultSchema, { + releaseId: parsed.releaseId, + status: "INSTALLED", + }) + ); +} + +if (import.meta.main) { + try { + const result = await runInstallProductionSystemdUnitsCli(Bun.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + const message = + error instanceof TypeError ? error.message : unitInstallFailureMessage; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/greenfield/scripts/delivery/prepareProductionState.test.ts b/greenfield/scripts/delivery/prepareProductionState.test.ts new file mode 100644 index 000000000..a1ca4e83f --- /dev/null +++ b/greenfield/scripts/delivery/prepareProductionState.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { + parsePrepareProductionStateCliArguments, + runPrepareProductionStateCli, +} from "./prepareProductionState.ts"; +import type { PreparedProductionStatePaths } from "./productionStateFilesystem.ts"; + +const projectRoot = "/srv/mira-dashboard"; + +function preparedPaths(root: string): PreparedProductionStatePaths { + return { + backupsDirectory: `${root}/production/state/backups`, + jobOutputDirectory: `${root}/production/state/job-output`, + logsDirectory: `${root}/production/state/logs`, + productionDirectory: `${root}/production`, + projectRoot: root, + stateDirectory: `${root}/production/state`, + }; +} + +describe("production state preparation CLI", () => { + test("accepts exactly one canonical absolute project root", () => { + expect( + parsePrepareProductionStateCliArguments([`--project-root=${projectRoot}`]) + ).toEqual({ projectRoot }); + + for (const arguments_ of [ + [], + [`--project-root=${projectRoot}`, "--extra"], + ["--project-root=relative"], + ["--project-root=/"], + [`--project-root=${projectRoot}/..`], + ["--other=/srv/mira-dashboard"], + ]) { + expect(() => parsePrepareProductionStateCliArguments(arguments_)).toThrow( + "Usage:" + ); + } + }); + + test("invokes the repair boundary once and returns only fixed status metadata", async () => { + const observedRoots: string[] = []; + + const result = await runPrepareProductionStateCli( + [`--project-root=${projectRoot}`], + (root) => { + observedRoots.push(root); + return Promise.resolve(preparedPaths(root)); + } + ); + + expect(observedRoots).toEqual([projectRoot]); + expect(result).toEqual({ status: "PREPARED" }); + expect(Object.isFrozen(result)).toBe(true); + }); + + test("propagates state-policy failures without retrying", async () => { + const failure = new Error("state rejected"); + let calls = 0; + + const observedFailure = await rejectionError( + runPrepareProductionStateCli([`--project-root=${projectRoot}`], () => { + calls += 1; + return Promise.reject(failure); + }) + ); + expect(observedFailure).toBe(failure); + expect(calls).toBe(1); + }); +}); diff --git a/greenfield/scripts/delivery/prepareProductionState.ts b/greenfield/scripts/delivery/prepareProductionState.ts new file mode 100644 index 000000000..d31b6ccec --- /dev/null +++ b/greenfield/scripts/delivery/prepareProductionState.ts @@ -0,0 +1,76 @@ +import path from "node:path"; + +import { + prepareProtectedProductionStatePath, + type PreparedProductionStatePaths, +} from "./productionStateFilesystem.ts"; + +const usage = + "Usage: bun run delivery:prepare-state --project-root=/absolute/dashboard/project/root"; + +/** Explicit state-preparation operation parsed from the delivery CLI. */ +export interface PrepareProductionStateCliArguments { + readonly projectRoot: string; +} + +/** Project-state preparation boundary injected by focused CLI tests. */ +export type PrepareProductionState = ( + projectRoot: string +) => Promise; + +function readProjectRoot(argument: string | undefined): string { + const prefix = "--project-root="; + const value = argument?.startsWith(prefix) ? argument.slice(prefix.length) : ""; + if ( + !value || + value.includes("\0") || + !path.isAbsolute(value) || + path.resolve(value) !== value || + path.parse(value).root === value + ) { + throw new TypeError(usage); + } + return value; +} + +/** + * Parses the single deliberately explicit state-preparation option. + * @param arguments_ Arguments after the Bun entrypoint. + * @returns Validated absolute project root. + */ +export function parsePrepareProductionStateCliArguments( + arguments_: readonly string[] +): PrepareProductionStateCliArguments { + if (arguments_.length !== 1) throw new TypeError(usage); + return Object.freeze({ projectRoot: readProjectRoot(arguments_[0]) }); +} + +/** + * Prepares project-local production state before release activation or runtime startup. + * The web and worker processes intentionally have no access to this repair boundary. + * @param arguments_ Arguments after the Bun entrypoint. + * @param prepare Injected state-preparation boundary. + * @returns Fixed safe status metadata. + */ +export async function runPrepareProductionStateCli( + arguments_: readonly string[], + prepare: PrepareProductionState = prepareProtectedProductionStatePath +): Promise> { + const { projectRoot } = parsePrepareProductionStateCliArguments(arguments_); + await prepare(projectRoot); + return Object.freeze({ status: "PREPARED" }); +} + +if (import.meta.main) { + try { + const result = await runPrepareProductionStateCli(Bun.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + const message = + error instanceof TypeError + ? error.message + : "Production state preparation failed"; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/greenfield/scripts/delivery/privateStateStageFile.ts b/greenfield/scripts/delivery/privateStateStageFile.ts new file mode 100644 index 000000000..d0ee6a368 --- /dev/null +++ b/greenfield/scripts/delivery/privateStateStageFile.ts @@ -0,0 +1,124 @@ +import { constants, type BigIntStats } from "node:fs"; +import { lstat, open, unlink, type FileHandle } from "node:fs/promises"; +import path from "node:path"; + +const privateFileMode = 0o600n; +const readFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + +interface RemoveStalePrivateStateStageOptions { + readonly directoryHandle: FileHandle; + readonly expectedDevice: bigint; + readonly maximumBytes: number; + readonly stageName: string; +} + +function cleanupFailure(): Error { + return new Error("Private state stage cleanup failed"); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function validStageFile( + status: BigIntStats, + expectedDevice: bigint, + maximumBytes: number +): boolean { + return ( + typeof process.getuid === "function" && + status.isFile() && + !status.isSymbolicLink() && + status.nlink === 1n && + status.uid === BigInt(process.getuid()) && + status.dev === expectedDevice && + (status.mode & 0o7777n) === privateFileMode && + status.size >= 0n && + status.size <= BigInt(maximumBytes) + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function requireMissing(candidate: string): Promise { + try { + await lstat(candidate); + } catch (error) { + if (errorCode(error) === "ENOENT") return; + throw cleanupFailure(); + } + throw cleanupFailure(); +} + +/** + * Removes only a private, descriptor-bound stage file left by an interrupted atomic replace. + * @param options Held state-directory identity and the exact deterministic stage name. + */ +export async function removeStalePrivateStateStage( + options: RemoveStalePrivateStateStageOptions +): Promise { + const { directoryHandle, expectedDevice, maximumBytes, stageName } = options; + if ( + process.platform !== "linux" || + maximumBytes <= 0 || + stageName.length === 0 || + stageName.length > 255 || + stageName.includes("\0") || + path.basename(stageName) !== stageName || + stageName === "." || + stageName === ".." + ) { + throw cleanupFailure(); + } + const stageFile = path.join(`/proc/self/fd/${directoryHandle.fd}`, stageName); + let handle: FileHandle | undefined; + let failed = false; + try { + try { + handle = await open(stageFile, readFlags); + } catch (error) { + if (errorCode(error) === "ENOENT") return; + throw cleanupFailure(); + } + const [held, current] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(stageFile, { bigint: true }), + ]); + if ( + !validStageFile(held, expectedDevice, maximumBytes) || + !validStageFile(current, expectedDevice, maximumBytes) || + held.dev !== current.dev || + held.ino !== current.ino || + held.size !== current.size + ) { + throw cleanupFailure(); + } + await unlink(stageFile); + await directoryHandle.sync(); + const unlinked = await handle.stat({ bigint: true }); + if ( + unlinked.dev !== held.dev || + unlinked.ino !== held.ino || + unlinked.nlink !== 0n || + unlinked.uid !== held.uid || + unlinked.size !== held.size + ) { + throw cleanupFailure(); + } + await requireMissing(stageFile); + } catch { + failed = true; + } + const closed = await closeHandle(handle); + if (failed || !closed) throw cleanupFailure(); +} diff --git a/greenfield/scripts/delivery/productionActivationJournal.test.ts b/greenfield/scripts/delivery/productionActivationJournal.test.ts new file mode 100644 index 000000000..47ac52926 --- /dev/null +++ b/greenfield/scripts/delivery/productionActivationJournal.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdtemp, readdir, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { ProductionActivationTransition } from "../../src/shared/productionActivationTransition.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { + clearProductionActivationJournal, + createProductionActivationJournal, + loadProductionActivationJournal, + markProductionDatabasePromoted, + markProductionRollbackRequired, + markProductionSnapshotPrepared, +} from "./productionActivationJournal.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; + +const temporaryDirectories: string[] = []; + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600); + } + } +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function fixture() { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-activation-journal-")); + temporaryDirectories.push(projectRoot); + const state = await prepareProtectedProductionStatePath(projectRoot); + const paths = await prepareProductionDeliveryDirectories(state); + return { paths, state }; +} + +function stopRequestedTransition(transitionId: string): ProductionActivationTransition { + return { + candidate: { + releaseId: "a".repeat(40), + runtimeRevision: "b".repeat(40), + }, + formatVersion: 1, + phase: "service-stop-requested", + previousActivation: null, + previousDatabase: { state: "unrecorded" }, + transitionId, + }; +} + +describe("production activation journal", () => { + test("durably advances service stop through rollback and clears exactly", async () => { + const { paths } = await fixture(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + expect(await loadProductionActivationJournal(lease, paths)).toBeUndefined(); + const requested = await createProductionActivationJournal( + lease, + paths, + stopRequestedTransition(Bun.randomUUIDv7()) + ); + const loadedRequested = await loadProductionActivationJournal(lease, paths); + expect(loadedRequested?.phase).toBe("service-stop-requested"); + const prepared = await markProductionSnapshotPrepared( + lease, + paths, + requested, + { state: "absent" } + ); + const loadedPrepared = await loadProductionActivationJournal(lease, paths); + expect(loadedPrepared?.phase).toBe("prepared"); + const promoted = await markProductionDatabasePromoted(lease, paths, prepared); + expect(promoted.phase).toBe("database-promoted"); + const staleStage = path.join( + paths.stateDirectory, + `.activation-transition-${promoted.transitionId}.json` + ); + await writeFile(staleStage, "partial", { mode: 0o600 }); + const rollback = await markProductionRollbackRequired(lease, paths, promoted); + expect(rollback.phase).toBe("rollback-required"); + expect(await stat(staleStage).catch(() => null)).toBeNull(); + await clearProductionActivationJournal(lease, paths, rollback); + expect(await loadProductionActivationJournal(lease, paths)).toBeUndefined(); + }); + }); + + test("rejects competing creation and stale phase updates", async () => { + const { paths } = await fixture(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + const first = await createProductionActivationJournal( + lease, + paths, + stopRequestedTransition(Bun.randomUUIDv7()) + ); + const competingFailure = await rejectionError( + createProductionActivationJournal( + lease, + paths, + stopRequestedTransition(Bun.randomUUIDv7()) + ) + ); + expect(competingFailure.message).toBe( + "Production activation journal update failed" + ); + const prepared = await markProductionSnapshotPrepared(lease, paths, first, { + state: "absent", + }); + const promoted = await markProductionDatabasePromoted(lease, paths, prepared); + const staleFailure = await rejectionError( + markProductionDatabasePromoted(lease, paths, prepared) + ); + expect(staleFailure.message).toBe( + "Production activation journal update failed" + ); + const rollback = await markProductionRollbackRequired(lease, paths, promoted); + const staleRollbackFailure = await rejectionError( + markProductionRollbackRequired(lease, paths, promoted) + ); + expect(staleRollbackFailure.message).toBe( + "Production activation journal update failed" + ); + await clearProductionActivationJournal(lease, paths, rollback); + }); + }); + + test("preserves an invalid stale stage and fails closed", async () => { + const { paths } = await fixture(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + const requested = await createProductionActivationJournal( + lease, + paths, + stopRequestedTransition(Bun.randomUUIDv7()) + ); + const staleStage = path.join( + paths.stateDirectory, + `.activation-transition-${requested.transitionId}.json` + ); + await writeFile(staleStage, "partial", { mode: 0o600 }); + await chmod(staleStage, 0o640); + + const failure = await rejectionError( + markProductionSnapshotPrepared(lease, paths, requested, { + state: "absent", + }) + ); + + expect(failure.message).toBe("Production activation journal update failed"); + const staleStatus = await stat(staleStage); + const journal = await loadProductionActivationJournal(lease, paths); + expect(staleStatus.mode & 0o777).toBe(0o640); + expect(journal?.phase).toBe("service-stop-requested"); + }); + }); + + test("fails closed when an opened journal entry disappears after reading", async () => { + const { paths } = await fixture(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + await createProductionActivationJournal( + lease, + paths, + stopRequestedTransition(Bun.randomUUIDv7()) + ); + const failure = await rejectionError( + loadProductionActivationJournal(lease, paths, { + afterRead: () => + unlink( + path.join(paths.stateDirectory, "activation-transition.json") + ), + }) + ); + expect(failure.message).toBe("Production activation journal update failed"); + }); + }); +}); diff --git a/greenfield/scripts/delivery/productionActivationJournal.ts b/greenfield/scripts/delivery/productionActivationJournal.ts new file mode 100644 index 000000000..bb9590d09 --- /dev/null +++ b/greenfield/scripts/delivery/productionActivationJournal.ts @@ -0,0 +1,398 @@ +import { constants, type BigIntStats } from "node:fs"; +import { lstat, open, realpath, rename, unlink, type FileHandle } from "node:fs/promises"; +import path from "node:path"; + +import { + parseProductionActivationTransition, + serializeProductionActivationTransition, + type ProductionActivationPreviousDatabase, + type ProductionActivationTransition, +} from "../../src/shared/productionActivationTransition.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import { removeStalePrivateStateStage } from "./privateStateStageFile.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; + +const activationJournalFailureMessage = "Production activation journal update failed"; +const journalFileName = "activation-transition.json"; +const maximumJournalBytes = 128 * 1024; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const readFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + +interface OpenedStateDirectory { + readonly device: bigint; + readonly handle: FileHandle; +} + +/** Deterministic post-read boundary used only by adversarial tests. */ +export interface ProductionActivationJournalTestHooks { + readonly afterRead?: () => Promise | void; +} + +function journalFailure(): Error { + return new Error(activationJournalFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function validJournalFile(status: BigIntStats, stateDevice: bigint): boolean { + return ( + typeof process.getuid === "function" && + status.isFile() && + !status.isSymbolicLink() && + status.nlink === 1n && + status.uid === BigInt(process.getuid()) && + status.dev === stateDevice && + (status.mode & 0o7777n) === 0o600n && + status.size > 0n && + status.size <= BigInt(maximumJournalBytes) + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function openStateDirectory( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths +): Promise { + if ( + process.platform !== "linux" || + typeof process.getuid !== "function" || + lease.stateDirectory !== paths.stateDirectory + ) { + throw journalFailure(); + } + let handle: FileHandle | undefined; + try { + handle = await open(paths.stateDirectory, directoryFlags); + const [held, after, canonical] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(paths.stateDirectory, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + if ( + canonical !== paths.stateDirectory || + !held.isDirectory() || + held.isSymbolicLink() || + held.uid !== BigInt(process.getuid()) || + (held.mode & 0o7777n) !== 0o700n || + after.dev !== held.dev || + after.ino !== held.ino + ) { + throw journalFailure(); + } + return Object.freeze({ device: held.dev, handle }); + } catch { + await closeHandle(handle); + throw journalFailure(); + } +} + +async function readJournal( + state: OpenedStateDirectory, + testHooks: ProductionActivationJournalTestHooks = {} +): Promise { + const journalFile = path.join(`/proc/self/fd/${state.handle.fd}`, journalFileName); + let handle: FileHandle | undefined; + let result: ProductionActivationTransition | undefined; + let missing = false; + let closed: boolean; + try { + handle = await open(journalFile, readFlags); + const heldBefore = await handle.stat({ bigint: true }); + if (!validJournalFile(heldBefore, state.device)) { + throw journalFailure(); + } + const text = await handle.readFile("utf8"); + const value: unknown = JSON.parse(text); + result = parseProductionActivationTransition(value); + await testHooks.afterRead?.(); + const [heldAfter, pathAfter] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(journalFile, { bigint: true }), + ]); + if ( + heldAfter.dev !== heldBefore.dev || + heldAfter.ino !== heldBefore.ino || + heldAfter.ctimeNs !== heldBefore.ctimeNs || + heldAfter.mtimeNs !== heldBefore.mtimeNs || + heldAfter.size !== heldBefore.size || + !validJournalFile(pathAfter, state.device) || + pathAfter.dev !== heldBefore.dev || + pathAfter.ino !== heldBefore.ino || + pathAfter.ctimeNs !== heldBefore.ctimeNs || + pathAfter.mtimeNs !== heldBefore.mtimeNs || + pathAfter.size !== heldBefore.size + ) { + throw journalFailure(); + } + } catch (error) { + if (!handle && errorCode(error) === "ENOENT") { + missing = true; + } else { + throw journalFailure(); + } + } finally { + closed = await closeHandle(handle); + } + if (!closed) throw journalFailure(); + return missing ? undefined : result; +} + +async function writeJournalStage( + stageFile: string, + transition: ProductionActivationTransition +): Promise { + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open( + stageFile, + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_WRONLY, + 0o600 + ); + const bytes = new TextEncoder().encode( + serializeProductionActivationTransition(transition) + ); + if (bytes.byteLength > maximumJournalBytes) throw journalFailure(); + await handle.writeFile(bytes); + await handle.sync(); + const status = await handle.stat({ bigint: true }); + if (!validJournalFile(status, status.dev)) throw journalFailure(); + } catch { + failed = true; + } + const closed = await closeHandle(handle); + if (failed || !closed) throw journalFailure(); +} + +async function replaceJournal( + state: OpenedStateDirectory, + expected: ProductionActivationTransition | undefined, + next: ProductionActivationTransition +): Promise { + const actual = await readJournal(state); + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw journalFailure(); + const descriptorRoot = `/proc/self/fd/${state.handle.fd}`; + const stageFile = path.join( + descriptorRoot, + `.activation-transition-${next.transitionId}.json` + ); + const stageName = path.basename(stageFile); + const finalFile = path.join(descriptorRoot, journalFileName); + let stageOwned = false; + try { + await removeStalePrivateStateStage({ + directoryHandle: state.handle, + expectedDevice: state.device, + maximumBytes: maximumJournalBytes, + stageName, + }); + await writeJournalStage(stageFile, next); + stageOwned = true; + await rename(stageFile, finalFile); + stageOwned = false; + await state.handle.sync(); + const stored = await readJournal(state); + if (JSON.stringify(stored) !== JSON.stringify(next)) throw journalFailure(); + } catch { + if (stageOwned) { + try { + await unlink(stageFile); + } catch { + // Preserve the fixed journal failure and bounded private evidence. + } + } + throw journalFailure(); + } +} + +/** + * Loads the durable activation recovery journal under the deployment lease. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @returns Parsed journal, or undefined when no transition is active. + */ +export async function loadProductionActivationJournal( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + testHooks: ProductionActivationJournalTestHooks = {} +): Promise { + const state = await openStateDirectory(lease, paths); + let journal: ProductionActivationTransition | undefined; + let failed = false; + try { + journal = await readJournal(state, testHooks); + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed) throw journalFailure(); + return journal; +} + +/** + * Records activation intent before any active service can be stopped. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param untrustedTransition Verified candidate and prior activation identity. + * @returns Parsed durable service-stop-requested journal. + */ +export async function createProductionActivationJournal( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + untrustedTransition: ProductionActivationTransition +): Promise> { + const transition = parseProductionActivationTransition(untrustedTransition); + if (transition.phase !== "service-stop-requested") throw journalFailure(); + const state = await openStateDirectory(lease, paths); + let failed = false; + try { + await replaceJournal(state, undefined, transition); + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed) throw journalFailure(); + return transition; +} + +/** + * Records the exact stopped-writer database snapshot before candidate preparation. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param expected Durable service-stop-requested journal. + * @param untrustedPreviousDatabase Verified absent state or immutable snapshot identity. + * @returns Durable prepared journal paired with the pre-activation database. + */ +export async function markProductionSnapshotPrepared( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expected: ProductionActivationTransition, + untrustedPreviousDatabase: ProductionActivationPreviousDatabase +): Promise> { + if (expected.phase !== "service-stop-requested") throw journalFailure(); + const next = parseProductionActivationTransition({ + ...expected, + phase: "prepared", + previousDatabase: untrustedPreviousDatabase, + }); + if (next.phase !== "prepared") throw journalFailure(); + const state = await openStateDirectory(lease, paths); + let failed = false; + try { + await replaceJournal(state, expected, next); + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed) throw journalFailure(); + return next; +} + +/** + * Advances the recovery journal after atomic database promotion. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param expected Durable prepared journal. + * @returns Durable database-promoted journal. + */ +export async function markProductionDatabasePromoted( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expected: ProductionActivationTransition +): Promise> { + if (expected.phase !== "prepared") throw journalFailure(); + const next = parseProductionActivationTransition({ + ...expected, + phase: "database-promoted", + }); + if (next.phase !== "database-promoted") throw journalFailure(); + const state = await openStateDirectory(lease, paths); + let failed = false; + try { + await replaceJournal(state, expected, next); + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed) throw journalFailure(); + return next; +} + +/** + * Durably records that a committed candidate failed to become ready and must roll back. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param expected Durable database-promoted journal. + * @returns Durable rollback-required journal. + */ +export async function markProductionRollbackRequired( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expected: ProductionActivationTransition +): Promise> { + if (expected.phase !== "database-promoted") throw journalFailure(); + const next = parseProductionActivationTransition({ + ...expected, + phase: "rollback-required", + }); + if (next.phase !== "rollback-required") throw journalFailure(); + const state = await openStateDirectory(lease, paths); + let failed = false; + try { + await replaceJournal(state, expected, next); + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed) throw journalFailure(); + return next; +} + +/** + * Deletes the exact completed or rolled-back journal and fsyncs state. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param expected Exact journal being finalized. + * @returns Completion after the journal entry is absent. + */ +export async function clearProductionActivationJournal( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expected: ProductionActivationTransition +): Promise { + const state = await openStateDirectory(lease, paths); + let failed = false; + try { + const actual = await readJournal(state); + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw journalFailure(); + await unlink(path.join(`/proc/self/fd/${state.handle.fd}`, journalFileName)); + await state.handle.sync(); + if ((await readJournal(state)) !== undefined) { + throw journalFailure(); + } + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed) throw journalFailure(); +} diff --git a/greenfield/scripts/delivery/productionActivationState.test.ts b/greenfield/scripts/delivery/productionActivationState.test.ts new file mode 100644 index 000000000..c78606c57 --- /dev/null +++ b/greenfield/scripts/delivery/productionActivationState.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdtemp, readdir, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { + commitProductionActivationState, + loadProductionActivationState, + restorePreviousProductionActivationState, +} from "./productionActivationState.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; + +const temporaryDirectories: string[] = []; + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600); + } + } +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function fixture() { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-activation-state-")); + temporaryDirectories.push(projectRoot); + const state = await prepareProtectedProductionStatePath(projectRoot); + const paths = await prepareProductionDeliveryDirectories(state); + return { paths, state }; +} + +describe("production activation state", () => { + test("atomically records initial and subsequent release/database pairs", async () => { + const { paths } = await fixture(); + const firstTransition = Bun.randomUUIDv7(); + const secondTransition = Bun.randomUUIDv7(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + const empty = await loadProductionActivationState(lease, paths); + expect(empty.record).toBeUndefined(); + const first = await commitProductionActivationState(lease, paths, empty, { + current: { + releaseId: "a".repeat(40), + runtimeRevision: "b".repeat(40), + }, + formatVersion: 1, + previous: null, + transitionId: firstTransition, + }); + expect(first.record?.previous).toBeNull(); + const second = await commitProductionActivationState(lease, paths, first, { + current: { + releaseId: "c".repeat(40), + runtimeRevision: "d".repeat(40), + }, + formatVersion: 1, + previous: { + databaseSnapshotTransitionId: secondTransition, + releaseId: "a".repeat(40), + runtimeRevision: "b".repeat(40), + }, + transitionId: secondTransition, + }); + + expect(second.record?.previous).toEqual({ + databaseSnapshotTransitionId: secondTransition, + releaseId: "a".repeat(40), + runtimeRevision: "b".repeat(40), + }); + const committedActivationStatus = await stat( + path.join(paths.stateDirectory, "activation.json") + ); + expect(committedActivationStatus.mode & 0o777).toBe(0o600); + if (!first.record) throw new Error("Expected first activation record"); + const staleRollbackStage = path.join( + paths.stateDirectory, + `.activation-rollback-${secondTransition}.json` + ); + await writeFile(staleRollbackStage, "partial", { mode: 0o600 }); + const restoredFirst = await restorePreviousProductionActivationState( + lease, + paths, + second, + first.record + ); + expect(restoredFirst.record).toEqual(first.record); + expect(await stat(staleRollbackStage).catch(() => null)).toBeNull(); + const restoredEmpty = await restorePreviousProductionActivationState( + lease, + paths, + restoredFirst, + null + ); + expect(restoredEmpty.record).toBeUndefined(); + const removedActivationStatus = await stat( + path.join(paths.stateDirectory, "activation.json") + ).catch(() => null); + expect(removedActivationStatus).toBeNull(); + const stateEntries = await readdir(paths.stateDirectory); + expect( + stateEntries.filter((entry) => entry.startsWith(".activation-")) + ).toEqual([]); + }); + }); + + test("rejects stale compare-and-swap state and invalid previous pairing", async () => { + const { paths } = await fixture(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + const empty = await loadProductionActivationState(lease, paths); + const transitionId = Bun.randomUUIDv7(); + const current = await commitProductionActivationState(lease, paths, empty, { + current: { + releaseId: "a".repeat(40), + runtimeRevision: "b".repeat(40), + }, + formatVersion: 1, + previous: null, + transitionId, + }); + const nextTransition = Bun.randomUUIDv7(); + const staleFailure = await rejectionError( + commitProductionActivationState(lease, paths, empty, { + current: { + releaseId: "c".repeat(40), + runtimeRevision: "d".repeat(40), + }, + formatVersion: 1, + previous: null, + transitionId: nextTransition, + }) + ); + expect(staleFailure.message).toBe( + "Production activation state update failed" + ); + + const pairingFailure = await rejectionError( + commitProductionActivationState(lease, paths, current, { + current: { + releaseId: "c".repeat(40), + runtimeRevision: "d".repeat(40), + }, + formatVersion: 1, + previous: { + databaseSnapshotTransitionId: transitionId, + releaseId: "f".repeat(40), + runtimeRevision: "b".repeat(40), + }, + transitionId: nextTransition, + }) + ); + expect(pairingFailure.message).toBe( + "Production activation state update failed" + ); + }); + }); + + test("fails closed when an opened activation entry disappears after reading", async () => { + const { paths } = await fixture(); + await withDeploymentLease(paths.stateDirectory, async (lease) => { + const empty = await loadProductionActivationState(lease, paths); + await commitProductionActivationState(lease, paths, empty, { + current: { + releaseId: "a".repeat(40), + runtimeRevision: "b".repeat(40), + }, + formatVersion: 1, + previous: null, + transitionId: Bun.randomUUIDv7(), + }); + const failure = await rejectionError( + loadProductionActivationState(lease, paths, { + afterRead: () => + unlink(path.join(paths.stateDirectory, "activation.json")), + }) + ); + expect(failure.message).toBe("Production activation state update failed"); + }); + }); +}); diff --git a/greenfield/scripts/delivery/productionActivationState.ts b/greenfield/scripts/delivery/productionActivationState.ts new file mode 100644 index 000000000..96c04d70a --- /dev/null +++ b/greenfield/scripts/delivery/productionActivationState.ts @@ -0,0 +1,500 @@ +import { constants, type BigIntStats } from "node:fs"; +import { lstat, open, realpath, rename, unlink, type FileHandle } from "node:fs/promises"; +import path from "node:path"; + +import { + parseProductionActivationRecord, + serializeProductionActivationRecord, + type ProductionActivationRecord, +} from "../../src/shared/productionActivationRecord.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import { removeStalePrivateStateStage } from "./privateStateStageFile.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; + +const activationStateFailureMessage = "Production activation state update failed"; +const activationFileName = "activation.json"; +const maximumActivationBytes = 64 * 1024; +const privateFileMode = 0o600; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const readFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +const activationStateBrand: unique symbol = Symbol("ProductionActivationState"); + +interface FileIdentity { + readonly ctimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; + readonly mtimeNs: bigint; + readonly size: bigint; + readonly uid: bigint; +} + +/** Stable compare-and-swap snapshot of the authoritative activation record. */ +export interface ProductionActivationState { + readonly [activationStateBrand]: true; + readonly fileIdentity?: FileIdentity; + readonly record?: ProductionActivationRecord; + readonly stateDirectory: string; +} + +/** Deterministic post-read boundary used only by adversarial tests. */ +export interface ProductionActivationStateTestHooks { + readonly afterRead?: () => Promise | void; +} + +function activationFailure(): Error { + return new Error(activationStateFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function snapshotFile(status: BigIntStats, expectedDevice: bigint): FileIdentity { + if ( + typeof process.getuid !== "function" || + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.dev !== expectedDevice || + (status.mode & 0o7777n) !== 0o600n || + status.size <= 0n || + status.size > BigInt(maximumActivationBytes) + ) { + throw activationFailure(); + } + return Object.freeze({ + ctimeNs: status.ctimeNs, + dev: status.dev, + ino: status.ino, + mtimeNs: status.mtimeNs, + size: status.size, + uid: status.uid, + }); +} + +function sameFile(left: FileIdentity, right: FileIdentity): boolean { + return ( + left.ctimeNs === right.ctimeNs && + left.dev === right.dev && + left.ino === right.ino && + left.mtimeNs === right.mtimeNs && + left.size === right.size && + left.uid === right.uid + ); +} + +function sameFileAcrossRename(left: FileIdentity, right: FileIdentity): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mtimeNs === right.mtimeNs && + left.size === right.size && + left.uid === right.uid + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function openStateDirectory( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths +): Promise<{ handle: FileHandle; identity: { dev: bigint; ino: bigint } }> { + if ( + process.platform !== "linux" || + typeof process.getuid !== "function" || + lease.stateDirectory !== paths.stateDirectory + ) { + throw activationFailure(); + } + let handle: FileHandle | undefined; + try { + handle = await open(paths.stateDirectory, directoryFlags); + const [held, after, canonical] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(paths.stateDirectory, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + if ( + canonical !== paths.stateDirectory || + !held.isDirectory() || + held.isSymbolicLink() || + held.uid !== BigInt(process.getuid()) || + (held.mode & 0o7777n) !== 0o700n || + after.dev !== held.dev || + after.ino !== held.ino + ) { + throw activationFailure(); + } + return { handle, identity: { dev: held.dev, ino: held.ino } }; + } catch { + await closeHandle(handle); + throw activationFailure(); + } +} + +async function readActivationFile( + stateHandle: FileHandle, + stateDevice: bigint, + testHooks: ProductionActivationStateTestHooks = {} +): Promise> { + const activationFile = path.join( + `/proc/self/fd/${stateHandle.fd}`, + activationFileName + ); + let handle: FileHandle | undefined; + let result: Pick | undefined; + try { + handle = await open(activationFile, readFlags); + const held = snapshotFile(await handle.stat({ bigint: true }), stateDevice); + const text = await handle.readFile("utf8"); + const value: unknown = JSON.parse(text); + const record = parseProductionActivationRecord(value); + await testHooks.afterRead?.(); + const [heldAfter, pathAfter] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(activationFile, { bigint: true }), + ]); + const after = snapshotFile(heldAfter, stateDevice); + const current = snapshotFile(pathAfter, stateDevice); + if (!sameFile(held, after) || !sameFile(held, current)) { + throw activationFailure(); + } + result = Object.freeze({ fileIdentity: current, record }); + } catch (error) { + if (!handle && errorCode(error) === "ENOENT") return Object.freeze({}); + throw activationFailure(); + } finally { + const closed = await closeHandle(handle); + if (!closed) result = undefined; + } + if (!result) throw activationFailure(); + return result; +} + +/** + * Reads one stable activation record under the active deployment lease. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @returns Branded absent or present compare-and-swap state. + */ +export async function loadProductionActivationState( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + testHooks: ProductionActivationStateTestHooks = {} +): Promise { + const state = await openStateDirectory(lease, paths); + let result: ProductionActivationState | undefined; + let failed = false; + try { + const observed = await readActivationFile( + state.handle, + state.identity.dev, + testHooks + ); + result = Object.freeze({ + [activationStateBrand]: true as const, + ...observed, + stateDirectory: paths.stateDirectory, + }); + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed || !result) throw activationFailure(); + return result; +} + +function validateTransition( + current: ProductionActivationState, + next: ProductionActivationRecord +): void { + if (current.record === undefined) { + if (next.previous !== null) throw activationFailure(); + return; + } + if ( + next.previous === null || + next.previous.databaseSnapshotTransitionId !== next.transitionId || + next.previous.releaseId !== current.record.current.releaseId || + next.previous.runtimeRevision !== current.record.current.runtimeRevision + ) { + throw activationFailure(); + } +} + +function stateMatches( + actual: Pick, + expected: ProductionActivationState +): boolean { + return ( + JSON.stringify(actual.record) === JSON.stringify(expected.record) && + (actual.fileIdentity === undefined) === (expected.fileIdentity === undefined) && + (actual.fileIdentity === undefined || + expected.fileIdentity === undefined || + sameFile(actual.fileIdentity, expected.fileIdentity)) + ); +} + +function validateRollback( + current: ProductionActivationState, + previous: ProductionActivationRecord | undefined +): void { + const currentRecord = current.record; + if (!currentRecord) throw activationFailure(); + if (previous === undefined) { + if (currentRecord.previous !== null) throw activationFailure(); + return; + } + if ( + currentRecord.previous === null || + currentRecord.previous.databaseSnapshotTransitionId !== + currentRecord.transitionId || + currentRecord.previous.releaseId !== previous.current.releaseId || + currentRecord.previous.runtimeRevision !== previous.current.runtimeRevision + ) { + throw activationFailure(); + } +} + +async function writeAndCommitStagedRecord( + stateHandle: FileHandle, + stageFile: string, + activationFile: string, + record: ProductionActivationRecord, + expectedDevice: bigint +): Promise { + const stageName = path.basename(stageFile); + let handle: FileHandle | undefined; + let stageOwned = false; + let failed = false; + try { + await removeStalePrivateStateStage({ + directoryHandle: stateHandle, + expectedDevice, + maximumBytes: maximumActivationBytes, + stageName, + }); + handle = await open( + stageFile, + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_RDWR, + privateFileMode + ); + stageOwned = true; + const bytes = new TextEncoder().encode( + serializeProductionActivationRecord(record) + ); + if (bytes.byteLength > maximumActivationBytes) throw activationFailure(); + await handle.writeFile(bytes); + await handle.sync(); + const storedBytes = Buffer.alloc(bytes.byteLength + 1); + let offset = 0; + while (offset < storedBytes.byteLength) { + const read = await handle.read( + storedBytes, + offset, + storedBytes.byteLength - offset, + offset + ); + if (read.bytesRead === 0) break; + offset += read.bytesRead; + } + const status = await handle.stat({ bigint: true }); + const [stateDirectory, descriptorPath] = await Promise.all([ + realpath(`/proc/self/fd/${stateHandle.fd}`), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + if ( + typeof process.getuid !== "function" || + descriptorPath !== path.join(stateDirectory, path.basename(stageFile)) || + offset !== bytes.byteLength || + !status.isFile() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.dev !== expectedDevice || + status.size !== BigInt(bytes.byteLength) || + (status.mode & 0o7777n) !== BigInt(privateFileMode) + ) { + throw activationFailure(); + } + const stagedIdentity = snapshotFile(status, expectedDevice); + const storedText = new TextDecoder("utf-8", { fatal: true }).decode( + storedBytes.subarray(0, offset) + ); + const stored: unknown = JSON.parse(storedText); + if ( + JSON.stringify(parseProductionActivationRecord(stored)) !== + JSON.stringify(record) + ) { + throw activationFailure(); + } + await rename(stageFile, activationFile); + stageOwned = false; + await stateHandle.sync(); + const [heldAfter, pathAfter] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(activationFile, { bigint: true }), + ]); + const heldIdentity = snapshotFile(heldAfter, expectedDevice); + const pathIdentity = snapshotFile(pathAfter, expectedDevice); + if ( + !sameFileAcrossRename(stagedIdentity, heldIdentity) || + !sameFile(heldIdentity, pathIdentity) + ) { + throw activationFailure(); + } + } catch { + failed = true; + } + if (stageOwned) { + try { + await unlink(stageFile); + } catch (error) { + if (errorCode(error) !== "ENOENT") failed = true; + } + } + const closed = await closeHandle(handle); + if (failed || !closed) throw activationFailure(); +} + +/** + * Atomically commits one current/previous release and database pairing. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param expected Previously loaded compare-and-swap state. + * @param untrustedNext Next activation record derived from the verified transition. + * @returns Newly loaded authoritative state after directory fsync. + */ +export async function commitProductionActivationState( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expected: ProductionActivationState, + untrustedNext: ProductionActivationRecord +): Promise { + const next = parseProductionActivationRecord(untrustedNext); + if ( + expected[activationStateBrand] !== true || + expected.stateDirectory !== paths.stateDirectory + ) { + throw activationFailure(); + } + validateTransition(expected, next); + const state = await openStateDirectory(lease, paths); + const stageName = `.activation-${next.transitionId}.json`; + const descriptorRoot = `/proc/self/fd/${state.handle.fd}`; + const stageFile = path.join(descriptorRoot, stageName); + const activationFile = path.join(descriptorRoot, activationFileName); + let committed: ProductionActivationState | undefined; + let failed = false; + try { + const actual = await readActivationFile(state.handle, state.identity.dev); + if (!stateMatches(actual, expected)) { + throw activationFailure(); + } + await writeAndCommitStagedRecord( + state.handle, + stageFile, + activationFile, + next, + state.identity.dev + ); + const observed = await readActivationFile(state.handle, state.identity.dev); + if (JSON.stringify(observed.record) !== JSON.stringify(next)) { + throw activationFailure(); + } + committed = Object.freeze({ + [activationStateBrand]: true as const, + ...observed, + stateDirectory: paths.stateDirectory, + }); + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed || !committed) throw activationFailure(); + return committed; +} + +/** + * Restores the immediate pre-transition activation state after candidate failure. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param expectedCurrent Exact committed candidate compare-and-swap state. + * @param untrustedPrevious Immediate predecessor from the durable transition journal. + * @returns Newly loaded authoritative predecessor state after directory fsync. + */ +export async function restorePreviousProductionActivationState( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expectedCurrent: ProductionActivationState, + untrustedPrevious: ProductionActivationRecord | null +): Promise { + const previous = + untrustedPrevious === null + ? undefined + : parseProductionActivationRecord(untrustedPrevious); + if ( + expectedCurrent[activationStateBrand] !== true || + expectedCurrent.stateDirectory !== paths.stateDirectory + ) { + throw activationFailure(); + } + validateRollback(expectedCurrent, previous); + const currentRecord = expectedCurrent.record; + if (!currentRecord) throw activationFailure(); + const state = await openStateDirectory(lease, paths); + const descriptorRoot = `/proc/self/fd/${state.handle.fd}`; + const activationFile = path.join(descriptorRoot, activationFileName); + let restored: ProductionActivationState | undefined; + let failed = false; + try { + const actual = await readActivationFile(state.handle, state.identity.dev); + if (!stateMatches(actual, expectedCurrent)) throw activationFailure(); + if (previous === undefined) { + await unlink(activationFile); + await state.handle.sync(); + } else { + await writeAndCommitStagedRecord( + state.handle, + path.join( + descriptorRoot, + `.activation-rollback-${currentRecord.transitionId}.json` + ), + activationFile, + previous, + state.identity.dev + ); + } + const observed = await readActivationFile(state.handle, state.identity.dev); + if (JSON.stringify(observed.record) !== JSON.stringify(previous)) { + throw activationFailure(); + } + restored = Object.freeze({ + [activationStateBrand]: true as const, + ...observed, + stateDirectory: paths.stateDirectory, + }); + } catch { + failed = true; + } + const closed = await closeHandle(state.handle); + if (failed || !closed || !restored) throw activationFailure(); + return restored; +} diff --git a/greenfield/scripts/delivery/productionDeliveryFilesystem.test.ts b/greenfield/scripts/delivery/productionDeliveryFilesystem.test.ts new file mode 100644 index 000000000..8eb5a662a --- /dev/null +++ b/greenfield/scripts/delivery/productionDeliveryFilesystem.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm, stat, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function projectFixture(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "mira-production-delivery-")); + temporaryDirectories.push(root); + return root; +} + +describe("production delivery filesystem", () => { + test("creates private project-local release and runtime roots idempotently", async () => { + const root = await projectFixture(); + const state = await prepareProtectedProductionStatePath(root); + const first = await prepareProductionDeliveryDirectories(state); + const second = await prepareProductionDeliveryDirectories(state); + + expect(first).toEqual({ + productionDirectory: path.join(root, "production"), + releasesDirectory: path.join(root, "production/releases"), + runtimesDirectory: path.join(root, "production/runtimes"), + stateDirectory: path.join(root, "production/state"), + }); + expect(second).toEqual(first); + const releasesStatus = await stat(first.releasesDirectory); + const runtimesStatus = await stat(first.runtimesDirectory); + expect(releasesStatus.mode & 0o777).toBe(0o700); + expect(runtimesStatus.mode & 0o777).toBe(0o700); + }); + + test("only narrows existing modes and rejects links or missing owner access", async () => { + const narrowedRoot = await projectFixture(); + const narrowedState = await prepareProtectedProductionStatePath(narrowedRoot); + const narrowedReleases = path.join(narrowedState.productionDirectory, "releases"); + await mkdir(narrowedReleases, { mode: 0o755 }); + const narrowed = await prepareProductionDeliveryDirectories(narrowedState); + const narrowedStatus = await stat(narrowed.releasesDirectory); + expect(narrowedStatus.mode & 0o777).toBe(0o700); + + const restrictiveRoot = await projectFixture(); + const restrictiveState = + await prepareProtectedProductionStatePath(restrictiveRoot); + await mkdir(path.join(restrictiveState.productionDirectory, "releases"), { + mode: 0o600, + }); + const restrictiveFailure = await rejectionError( + prepareProductionDeliveryDirectories(restrictiveState) + ); + expect(restrictiveFailure.message).toBe( + "Production delivery path violates the protected project-local filesystem policy" + ); + + const linkedRoot = await projectFixture(); + const linkedState = await prepareProtectedProductionStatePath(linkedRoot); + const target = path.join(linkedRoot, "linked-releases-target"); + await mkdir(target, { mode: 0o700 }); + await symlink(target, path.join(linkedState.productionDirectory, "releases")); + const linkedFailure = await rejectionError( + prepareProductionDeliveryDirectories(linkedState) + ); + expect(linkedFailure.message).toBe( + "Production delivery path violates the protected project-local filesystem policy" + ); + + await chmod(path.join(restrictiveState.productionDirectory, "releases"), 0o700); + }); +}); diff --git a/greenfield/scripts/delivery/productionDeliveryFilesystem.ts b/greenfield/scripts/delivery/productionDeliveryFilesystem.ts new file mode 100644 index 000000000..81bf8f6c5 --- /dev/null +++ b/greenfield/scripts/delivery/productionDeliveryFilesystem.ts @@ -0,0 +1,208 @@ +import { constants, type BigIntStats } from "node:fs"; +import { lstat, mkdir, open, realpath, type FileHandle } from "node:fs/promises"; +import path from "node:path"; + +import type { PreparedProductionStatePaths } from "./productionStateFilesystem.ts"; + +const deliveryFilesystemFailureMessage = + "Production delivery path violates the protected project-local filesystem policy"; +const privateDirectoryMode = 0o700; +const privateDirectoryModeBigInt = 0o700n; +const directoryOpenFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; + +/** Project-local release/runtime directories prepared below production. */ +export interface PreparedProductionDeliveryPaths { + readonly productionDirectory: string; + readonly releasesDirectory: string; + readonly runtimesDirectory: string; + readonly stateDirectory: string; +} + +interface DirectoryIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + +function deliveryFilesystemFailure(): Error { + return new Error(deliveryFilesystemFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function identity(status: BigIntStats): DirectoryIdentity { + return Object.freeze({ dev: status.dev, ino: status.ino }); +} + +function sameIdentity(status: BigIntStats, expected: DirectoryIdentity): boolean { + return status.dev === expected.dev && status.ino === expected.ino; +} + +function validPrivateDirectory(status: BigIntStats, userId: number): boolean { + return ( + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(userId) && + (status.mode & 0o7777n) === privateDirectoryModeBigInt + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function openPrivateDirectory( + directory: string, + expectedDevice?: bigint +): Promise<{ readonly handle: FileHandle; readonly identity: DirectoryIdentity }> { + if (typeof process.getuid !== "function") throw deliveryFilesystemFailure(); + let handle: FileHandle | undefined; + let result: + | { readonly handle: FileHandle; readonly identity: DirectoryIdentity } + | undefined; + let failed = false; + try { + handle = await open(directory, directoryOpenFlags); + const [held, after, canonical] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(directory, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + const observedIdentity = identity(held); + if ( + canonical !== directory || + !validPrivateDirectory(held, process.getuid()) || + !validPrivateDirectory(after, process.getuid()) || + !sameIdentity(after, observedIdentity) || + (expectedDevice !== undefined && held.dev !== expectedDevice) + ) { + throw deliveryFilesystemFailure(); + } + result = Object.freeze({ handle, identity: observedIdentity }); + } catch { + failed = true; + } + if (failed || !result) { + await closeHandle(handle); + throw deliveryFilesystemFailure(); + } + return result; +} + +async function preparePrivateChild( + parentPath: string, + parent: { readonly handle: FileHandle; readonly identity: DirectoryIdentity }, + childName: string +): Promise { + if (typeof process.getuid !== "function") throw deliveryFilesystemFailure(); + const childPath = path.join(parentPath, childName); + const anchoredChild = path.join(`/proc/self/fd/${parent.handle.fd}`, childName); + try { + await mkdir(anchoredChild, { mode: privateDirectoryMode }); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw deliveryFilesystemFailure(); + } + + let child: Awaited> | undefined; + let failed = false; + try { + await chmodThroughHandle(anchoredChild); + child = await openPrivateDirectory(childPath, parent.identity.dev); + const parentAfter = await parent.handle.stat({ bigint: true }); + if (!sameIdentity(parentAfter, parent.identity)) { + throw deliveryFilesystemFailure(); + } + } catch { + failed = true; + } + const closed = await closeHandle(child?.handle); + if (failed || !closed || !child) throw deliveryFilesystemFailure(); + return childPath; +} + +async function chmodThroughHandle(directory: string): Promise { + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open(directory, directoryOpenFlags); + const before = await handle.stat({ bigint: true }); + if ( + typeof process.getuid !== "function" || + !before.isDirectory() || + before.uid !== BigInt(process.getuid()) || + (before.mode & privateDirectoryModeBigInt) !== privateDirectoryModeBigInt + ) { + throw deliveryFilesystemFailure(); + } + await handle.chmod(privateDirectoryMode); + const after = await handle.stat({ bigint: true }); + if ( + !sameIdentity(after, identity(before)) || + !validPrivateDirectory(after, process.getuid()) + ) { + throw deliveryFilesystemFailure(); + } + } catch { + failed = true; + } + const closed = await closeHandle(handle); + if (failed || !closed) throw deliveryFilesystemFailure(); +} + +/** + * Creates or narrows the project-local release/runtime directories under prepared production. + * @param statePaths Result of the protected state preparation boundary. + * @returns Revalidated exact production delivery paths. + */ +export async function prepareProductionDeliveryDirectories( + statePaths: PreparedProductionStatePaths +): Promise { + const expectedProduction = path.join(statePaths.projectRoot, "production"); + const expectedState = path.join(expectedProduction, "state"); + if ( + statePaths.productionDirectory !== expectedProduction || + statePaths.stateDirectory !== expectedState + ) { + throw deliveryFilesystemFailure(); + } + + const production = await openPrivateDirectory(expectedProduction); + let prepared: PreparedProductionDeliveryPaths | undefined; + let failed = false; + try { + const releasesDirectory = await preparePrivateChild( + expectedProduction, + production, + "releases" + ); + const runtimesDirectory = await preparePrivateChild( + expectedProduction, + production, + "runtimes" + ); + prepared = Object.freeze({ + productionDirectory: expectedProduction, + releasesDirectory, + runtimesDirectory, + stateDirectory: expectedState, + }); + } catch { + failed = true; + } + const closed = await closeHandle(production.handle); + if (failed || !closed || !prepared) throw deliveryFilesystemFailure(); + return prepared; +} diff --git a/greenfield/scripts/delivery/productionReleaseActivation.test.ts b/greenfield/scripts/delivery/productionReleaseActivation.test.ts new file mode 100644 index 000000000..17ef4d1da --- /dev/null +++ b/greenfield/scripts/delivery/productionReleaseActivation.test.ts @@ -0,0 +1,704 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, test } from "bun:test"; +import { lstat, readdir } from "node:fs/promises"; +import path from "node:path"; + +import { Effect } from "effect"; + +import { parseProductionActivationTransition } from "../../src/shared/productionActivationTransition.ts"; +import { + createLocalReleaseFixture, + createProductionTargetFixture, + executeDatabaseMaintenanceFixture, + publishProductionDeliveryFixtures, + removeProductionDeliveryFixtures, +} from "../testSupport/productionDeliveryFixture.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { + runDatabaseCandidateMaintenance, + runDatabaseSnapshotMaintenance, +} from "./databaseMaintenanceProcess.ts"; +import { + prepareDatabaseTransitionWorkspace, + promoteDatabaseTransitionCandidate, + verifyDatabaseTransitionCandidate, +} from "./databaseTransitionFilesystem.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { + createProductionActivationJournal, + loadProductionActivationJournal, + markProductionDatabasePromoted, + markProductionRollbackRequired, + markProductionSnapshotPrepared, +} from "./productionActivationJournal.ts"; +import { + commitProductionActivationState, + loadProductionActivationState, +} from "./productionActivationState.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { + activatePublishedProductionRelease, + type ProductionServiceController, + type ProductionReleaseActivationTestHooks, +} from "./productionReleaseActivation.ts"; +import { type PublishedProductionRelease } from "./productionReleasePublication.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; + +const sourceProjectRoot = path.resolve(import.meta.dir, "../.."); +const firstReleaseId = "a".repeat(40); +const secondReleaseId = "b".repeat(40); +const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ + revision: "c".repeat(40), + version: "1.4.0", +}); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await removeProductionDeliveryFixtures(temporaryDirectories); +}); + +async function localReleaseFixture(commitSha: string): Promise { + return createLocalReleaseFixture( + sourceProjectRoot, + commitSha, + runtimeIdentity, + temporaryDirectories + ); +} + +function createProjectFixture() { + return createProductionTargetFixture(temporaryDirectories); +} + +class TestServiceController implements ProductionServiceController { + readonly events: string[] = []; + onStart: ((release: PublishedProductionRelease) => Promise | void) | undefined; + rejectReadyReleaseId: string | undefined; + rejectStartReleaseId: string | undefined; + + prepare(release: PublishedProductionRelease): Promise { + this.events.push(`prepare:${release.manifest.source.commitSha}`); + return Promise.resolve(); + } + + async start(release: PublishedProductionRelease): Promise { + const releaseId = release.manifest.source.commitSha; + this.events.push(`start:${releaseId}`); + await this.onStart?.(release); + if (releaseId === this.rejectStartReleaseId) { + throw new Error("candidate partially started"); + } + } + + stop(): Promise { + this.events.push("stop"); + return Promise.resolve(); + } + + verifyReady(release: PublishedProductionRelease): Promise { + const releaseId = release.manifest.source.commitSha; + this.events.push(`ready:${releaseId}`); + return releaseId === this.rejectReadyReleaseId + ? Promise.reject(new Error("candidate not ready")) + : Promise.resolve(); + } +} + +function activationDependencies( + services: TestServiceController, + probeRuntime: () => Promise, + testHooks?: ProductionReleaseActivationTestHooks +) { + return Object.freeze({ + maintenance: { + execute: executeDatabaseMaintenanceFixture, + runtimeVerification: { probeRuntime }, + }, + runtimeVerification: { probeRuntime }, + services, + testHooks, + }); +} + +function publishFixtures( + lease: Parameters[0], + paths: Parameters[1], + sourceReleases: readonly [string, string], + runtimeSource: string +) { + return publishProductionDeliveryFixtures( + lease, + paths, + sourceReleases, + runtimeSource, + runtimeIdentity + ); +} + +function readMigrationReleaseId(databaseFile: string): string { + const database = new Database(databaseFile, { readonly: true, strict: true }); + try { + const row = database + .query<{ releaseId: string }, []>( + "SELECT MIN(release_id) AS releaseId FROM schema_migrations" + ) + .get(); + if (!row) throw new Error("Missing migration ledger"); + return row.releaseId; + } finally { + database.close(false); + } +} + +describe("production release activation", () => { + test("commits initial and upgraded release/database pairs under one lease", async () => { + const sourceReleases = await Promise.all([ + localReleaseFixture(firstReleaseId), + localReleaseFixture(secondReleaseId), + ]); + const { projectRoot, runtimeSource } = await createProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishFixtures( + lease, + paths, + sourceReleases, + runtimeSource + ); + const services = new TestServiceController(); + const authoritativeAtStart: string[] = []; + services.onStart = async (release) => { + const observed = await loadProductionActivationState(lease, paths); + const releaseId = release.manifest.source.commitSha; + expect(observed.record?.current.releaseId).toBe(releaseId); + authoritativeAtStart.push(releaseId); + }; + const dependencies = activationDependencies(services, fixtures.probeRuntime); + const initial = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + dependencies + ) + ); + const upgraded = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.second, + fixtures.runtime, + dependencies + ) + ); + + expect(initial.current.releaseId).toBe(firstReleaseId); + expect(initial.previous).toBeNull(); + expect(upgraded.current.releaseId).toBe(secondReleaseId); + expect(upgraded.previous).toEqual({ + databaseSnapshotTransitionId: upgraded.transitionId, + releaseId: firstReleaseId, + runtimeRevision: runtimeIdentity.revision, + }); + expect( + readMigrationReleaseId( + path.join(paths.stateDirectory, "mira-dashboard.db") + ) + ).toBe(firstReleaseId); + expect(services.events).toEqual([ + `prepare:${firstReleaseId}`, + "stop", + `prepare:${firstReleaseId}`, + `start:${firstReleaseId}`, + `ready:${firstReleaseId}`, + `prepare:${firstReleaseId}`, + "stop", + `prepare:${secondReleaseId}`, + `start:${secondReleaseId}`, + `ready:${secondReleaseId}`, + ]); + expect(authoritativeAtStart).toEqual([firstReleaseId, secondReleaseId]); + const activation = await loadProductionActivationState(lease, paths); + const stateEntries = await readdir(paths.stateDirectory); + expect(activation.record).toEqual(upgraded); + expect(stateEntries).not.toContain("activation-transition.json"); + expect( + stateEntries.filter((entry) => entry.startsWith(".database-transition-")) + ).toEqual([]); + }); + }); + + test("restores the previous release and database when candidate readiness fails", async () => { + const sourceReleases = await Promise.all([ + localReleaseFixture(firstReleaseId), + localReleaseFixture(secondReleaseId), + ]); + const { projectRoot, runtimeSource } = await createProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishFixtures( + lease, + paths, + sourceReleases, + runtimeSource + ); + const services = new TestServiceController(); + const dependencies = activationDependencies(services, fixtures.probeRuntime); + const initial = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + dependencies + ) + ); + services.rejectReadyReleaseId = secondReleaseId; + const failure = await rejectionError( + Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.second, + fixtures.runtime, + dependencies + ) + ) + ); + + expect(failure.message).toBe("Production release activation failed"); + const activation = await loadProductionActivationState(lease, paths); + expect(activation.record).toEqual(initial); + expect( + readMigrationReleaseId( + path.join(paths.stateDirectory, "mira-dashboard.db") + ) + ).toBe(firstReleaseId); + expect(services.events.slice(-10)).toEqual([ + `prepare:${firstReleaseId}`, + "stop", + `prepare:${secondReleaseId}`, + `start:${secondReleaseId}`, + `ready:${secondReleaseId}`, + `prepare:${secondReleaseId}`, + "stop", + `prepare:${firstReleaseId}`, + `start:${firstReleaseId}`, + `ready:${firstReleaseId}`, + ]); + expect(await readdir(paths.stateDirectory)).not.toContain( + "activation-transition.json" + ); + + services.rejectReadyReleaseId = undefined; + services.rejectStartReleaseId = secondReleaseId; + const partialStartFailure = await rejectionError( + Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.second, + fixtures.runtime, + dependencies + ) + ) + ); + const activationAfterPartialStart = await loadProductionActivationState( + lease, + paths + ); + expect(partialStartFailure.message).toBe( + "Production release activation failed" + ); + expect(activationAfterPartialStart.record).toEqual(initial); + expect(services.events.slice(-9)).toEqual([ + `prepare:${firstReleaseId}`, + "stop", + `prepare:${secondReleaseId}`, + `start:${secondReleaseId}`, + `prepare:${secondReleaseId}`, + "stop", + `prepare:${firstReleaseId}`, + `start:${firstReleaseId}`, + `ready:${firstReleaseId}`, + ]); + }); + }); + + test("recovers the active service after interruption immediately after stop", async () => { + const sourceReleases = await Promise.all([ + localReleaseFixture(firstReleaseId), + localReleaseFixture(secondReleaseId), + ]); + const { projectRoot, runtimeSource } = await createProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishFixtures( + lease, + paths, + sourceReleases, + runtimeSource + ); + const services = new TestServiceController(); + const initial = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + activationDependencies(services, fixtures.probeRuntime) + ) + ); + let observedPhase: string | undefined; + const failure = await rejectionError( + Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.second, + fixtures.runtime, + activationDependencies(services, fixtures.probeRuntime, { + afterServicesStopped: async () => { + const observedJournal = + await loadProductionActivationJournal(lease, paths); + observedPhase = observedJournal?.phase; + throw new Error("simulated process interruption"); + }, + }) + ) + ) + ); + + expect(failure.message).toBe("Production release activation failed"); + expect(observedPhase).toBe("service-stop-requested"); + const recoveredActivation = await loadProductionActivationState(lease, paths); + expect(recoveredActivation.record).toEqual(initial); + expect(await loadProductionActivationJournal(lease, paths)).toBeUndefined(); + expect(services.events.slice(-7)).toEqual([ + `prepare:${firstReleaseId}`, + "stop", + `prepare:${firstReleaseId}`, + "stop", + `prepare:${firstReleaseId}`, + `start:${firstReleaseId}`, + `ready:${firstReleaseId}`, + ]); + }); + }); + + test("keeps a committed candidate when post-commit cleanup is interrupted", async () => { + const sourceReleases = await Promise.all([ + localReleaseFixture(firstReleaseId), + localReleaseFixture(secondReleaseId), + ]); + const { projectRoot, runtimeSource } = await createProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishFixtures( + lease, + paths, + sourceReleases, + runtimeSource + ); + const services = new TestServiceController(); + const baseDependencies = activationDependencies( + services, + fixtures.probeRuntime + ); + await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + baseDependencies + ) + ); + for (const scenario of [ + { + boundary: "afterActivationCommit" as const, + candidate: fixtures.second, + expectedReleaseId: secondReleaseId, + }, + { + boundary: "afterActivationJournalClear" as const, + candidate: fixtures.first, + expectedReleaseId: firstReleaseId, + }, + ]) { + let hookCalls = 0; + const upgraded = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + scenario.candidate, + fixtures.runtime, + activationDependencies(services, fixtures.probeRuntime, { + [scenario.boundary]: () => { + hookCalls += 1; + throw new Error("simulated cleanup interruption"); + }, + }) + ) + ); + + const activation = await loadProductionActivationState(lease, paths); + const stateEntries = await readdir(paths.stateDirectory); + expect(hookCalls).toBe(1); + expect(upgraded.current.releaseId).toBe(scenario.expectedReleaseId); + expect(activation.record).toEqual(upgraded); + expect(stateEntries).not.toContain("activation-transition.json"); + expect( + stateEntries.filter((entry) => + entry.startsWith(".database-transition-") + ) + ).toEqual([]); + expect(services.events.at(-1)).toBe( + `ready:${scenario.expectedReleaseId}` + ); + } + }); + }, 15_000); + + test("recovers a durable rollback request after candidate activation commit", async () => { + const sourceReleases = await Promise.all([ + localReleaseFixture(firstReleaseId), + localReleaseFixture(secondReleaseId), + ]); + const { projectRoot, runtimeSource } = await createProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishFixtures( + lease, + paths, + sourceReleases, + runtimeSource + ); + const services = new TestServiceController(); + const dependencies = activationDependencies(services, fixtures.probeRuntime); + const initial = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + dependencies + ) + ); + const previousState = await loadProductionActivationState(lease, paths); + const transitionId = Bun.randomUUIDv7(); + const snapshot = await runDatabaseSnapshotMaintenance( + lease, + paths, + fixtures.first, + fixtures.runtime, + transitionId, + "present", + dependencies.maintenance + ); + if (snapshot.state !== "present") throw new Error("Expected snapshot"); + const stopRequested = await createProductionActivationJournal( + lease, + paths, + parseProductionActivationTransition({ + candidate: { + releaseId: secondReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + formatVersion: 1, + phase: "service-stop-requested", + previousActivation: initial, + previousDatabase: { state: "unrecorded" }, + transitionId, + }) + ); + const prepared = await markProductionSnapshotPrepared( + lease, + paths, + stopRequested, + { + manifest: snapshot.manifest, + sourceDatabase: snapshot.sourceDatabase, + state: "present", + } + ); + const workspace = await prepareDatabaseTransitionWorkspace( + lease, + paths, + transitionId, + snapshot + ); + await runDatabaseCandidateMaintenance( + lease, + paths, + fixtures.second, + fixtures.runtime, + transitionId, + workspace.candidateDirectory, + dependencies.maintenance + ); + await promoteDatabaseTransitionCandidate( + lease, + paths, + await verifyDatabaseTransitionCandidate(workspace) + ); + const promoted = await markProductionDatabasePromoted(lease, paths, prepared); + await commitProductionActivationState(lease, paths, previousState, { + current: { + releaseId: secondReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + formatVersion: 1, + previous: { + databaseSnapshotTransitionId: transitionId, + releaseId: firstReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + transitionId, + }); + await markProductionRollbackRequired(lease, paths, promoted); + + const recovered = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + dependencies + ) + ); + + expect(recovered).toEqual(initial); + const recoveredState = await loadProductionActivationState(lease, paths); + expect(recoveredState.record).toEqual(initial); + expect( + readMigrationReleaseId( + path.join(paths.stateDirectory, "mira-dashboard.db") + ) + ).toBe(firstReleaseId); + const stateEntries = await readdir(paths.stateDirectory); + expect(stateEntries).not.toContain("activation-transition.json"); + expect( + stateEntries.filter((entry) => entry.startsWith(".database-transition-")) + ).toEqual([]); + }); + }); + + test("recovers a crash after database promotion but before journal advancement", async () => { + const sourceReleases = await Promise.all([ + localReleaseFixture(firstReleaseId), + localReleaseFixture(secondReleaseId), + ]); + const { projectRoot, runtimeSource } = await createProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishFixtures( + lease, + paths, + sourceReleases, + runtimeSource + ); + const services = new TestServiceController(); + const dependencies = activationDependencies(services, fixtures.probeRuntime); + const initial = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + dependencies + ) + ); + const transitionId = Bun.randomUUIDv7(); + const snapshot = await runDatabaseSnapshotMaintenance( + lease, + paths, + fixtures.first, + fixtures.runtime, + transitionId, + "present", + dependencies.maintenance + ); + if (snapshot.state !== "present") throw new Error("Expected snapshot"); + const stopRequested = await createProductionActivationJournal( + lease, + paths, + parseProductionActivationTransition({ + candidate: { + releaseId: secondReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + formatVersion: 1, + phase: "service-stop-requested", + previousActivation: initial, + previousDatabase: { state: "unrecorded" }, + transitionId, + }) + ); + await markProductionSnapshotPrepared(lease, paths, stopRequested, { + manifest: snapshot.manifest, + sourceDatabase: snapshot.sourceDatabase, + state: "present", + }); + const workspace = await prepareDatabaseTransitionWorkspace( + lease, + paths, + transitionId, + snapshot + ); + await runDatabaseCandidateMaintenance( + lease, + paths, + fixtures.second, + fixtures.runtime, + transitionId, + workspace.candidateDirectory, + dependencies.maintenance + ); + const promoted = await promoteDatabaseTransitionCandidate( + lease, + paths, + await verifyDatabaseTransitionCandidate(workspace) + ); + const promotedStatus = await lstat( + path.join(paths.stateDirectory, "mira-dashboard.db"), + { bigint: true } + ); + const promotedInode = promotedStatus.ino; + expect(promoted.fileIdentity.ino).toBe(promotedInode); + + const recovered = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + dependencies + ) + ); + const restoredStatus = await lstat( + path.join(paths.stateDirectory, "mira-dashboard.db"), + { bigint: true } + ); + const restoredInode = restoredStatus.ino; + expect(restoredInode).not.toBe(promotedInode); + expect(recovered).toEqual(initial); + expect( + readMigrationReleaseId( + path.join(paths.stateDirectory, "mira-dashboard.db") + ) + ).toBe(firstReleaseId); + expect(await readdir(paths.stateDirectory)).not.toContain( + "activation-transition.json" + ); + }); + }); +}); diff --git a/greenfield/scripts/delivery/productionReleaseActivation.ts b/greenfield/scripts/delivery/productionReleaseActivation.ts new file mode 100644 index 000000000..318ed2657 --- /dev/null +++ b/greenfield/scripts/delivery/productionReleaseActivation.ts @@ -0,0 +1,627 @@ +import { Effect, Schema } from "effect"; + +import type { ProductionActivationRecord } from "../../src/shared/productionActivationRecord.ts"; +import { + parseProductionActivationTransition, + type ProductionActivationPreviousDatabase, + type ProductionActivationTransition, +} from "../../src/shared/productionActivationTransition.ts"; +import { + type DatabaseMaintenanceProcessDependencies, + runDatabaseCandidateMaintenance, + runDatabaseSnapshotMaintenance, +} from "./databaseMaintenanceProcess.ts"; +import { + discardDatabaseTransitionWorkspace, + discardOrphanDatabaseTransitionWorkspace, + inspectDatabaseTransitionRecovery, + prepareDatabaseRollbackCandidate, + prepareDatabaseTransitionWorkspace, + promoteDatabaseTransitionCandidate, + restorePromotedDatabaseState, + verifyDatabaseTransitionCandidate, + type DatabaseTransitionWorkspace, + type PromotedDatabaseState, +} from "./databaseTransitionFilesystem.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import { + clearProductionActivationJournal, + createProductionActivationJournal, + loadProductionActivationJournal, + markProductionDatabasePromoted, + markProductionRollbackRequired, + markProductionSnapshotPrepared, +} from "./productionActivationJournal.ts"; +import { + commitProductionActivationState, + loadProductionActivationState, + restorePreviousProductionActivationState, + type ProductionActivationState, +} from "./productionActivationState.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; +import { + loadPublishedProductionRelease, + type PublishedProductionRelease, +} from "./productionReleasePublication.ts"; +import { + type InstalledProductionRuntime, + loadInstalledProductionRuntime, + type ProductionRuntimeVerificationDependencies, +} from "./productionRuntime.ts"; + +const TaggedErrorClass = Schema.TaggedError; +const activationFailureMessage = "Production release activation failed"; + +/** Sanitized failure spanning release, process, database, and recovery boundaries. */ +export class ProductionReleaseActivationError extends TaggedErrorClass( + "mira-dashboard/scripts/delivery/ProductionReleaseActivationError" +)("ProductionReleaseActivationError", { message: Schema.String }) {} + +/** Idempotent process-control port implemented by the project-local systemd adapter. */ +export interface ProductionServiceController { + readonly prepare: ( + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime + ) => Promise; + readonly start: ( + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime + ) => Promise; + readonly stop: () => Promise; + readonly verifyReady: ( + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime + ) => Promise; +} + +/** Activation dependencies kept explicit for disposable-host lifecycle tests. */ +export interface ProductionReleaseActivationDependencies { + readonly maintenance?: DatabaseMaintenanceProcessDependencies; + readonly runtimeVerification?: ProductionRuntimeVerificationDependencies; + readonly services: ProductionServiceController; + readonly testHooks?: ProductionReleaseActivationTestHooks; +} + +/** Deterministic crash-boundary hooks used only by activation lifecycle tests. */ +export interface ProductionReleaseActivationTestHooks { + readonly afterActivationCommit?: () => Promise | void; + readonly afterActivationJournalClear?: () => Promise | void; + readonly afterServicesStopped?: () => Promise | void; +} + +interface ActiveArtifacts { + readonly release: PublishedProductionRelease; + readonly runtime: InstalledProductionRuntime; +} + +async function prepareAndStartServices( + services: ProductionServiceController, + artifacts: ActiveArtifacts +): Promise { + await services.prepare(artifacts.release, artifacts.runtime); + await services.start(artifacts.release, artifacts.runtime); +} + +function sameRecord( + left: ProductionActivationRecord | null | undefined, + right: ProductionActivationRecord | null | undefined +): boolean { + if (left === null || left === undefined || right === null || right === undefined) { + return ( + (left === null || left === undefined) && + (right === null || right === undefined) + ); + } + const samePrevious = + left.previous === null || right.previous === null + ? left.previous === right.previous + : left.previous.databaseSnapshotTransitionId === + right.previous.databaseSnapshotTransitionId && + left.previous.releaseId === right.previous.releaseId && + left.previous.runtimeRevision === right.previous.runtimeRevision; + return ( + left.formatVersion === right.formatVersion && + left.transitionId === right.transitionId && + left.current.releaseId === right.current.releaseId && + left.current.runtimeRevision === right.current.runtimeRevision && + samePrevious + ); +} + +function activationError(): ProductionReleaseActivationError { + return new ProductionReleaseActivationError({ message: activationFailureMessage }); +} + +async function loadActiveArtifacts( + paths: PreparedProductionDeliveryPaths, + record: ProductionActivationRecord, + dependencies: ProductionReleaseActivationDependencies +): Promise { + const release = await loadPublishedProductionRelease( + paths, + record.current.releaseId, + record.current.runtimeRevision + ); + const runtime = await loadInstalledProductionRuntime( + paths, + release.manifest.runtime, + dependencies.runtimeVerification + ); + return Object.freeze({ release, runtime }); +} + +async function loadExactArtifacts( + paths: PreparedProductionDeliveryPaths, + releaseId: string, + runtimeRevision: string, + dependencies: ProductionReleaseActivationDependencies +): Promise { + const release = await loadPublishedProductionRelease( + paths, + releaseId, + runtimeRevision + ); + const runtime = await loadInstalledProductionRuntime( + paths, + release.manifest.runtime, + dependencies.runtimeVerification + ); + return Object.freeze({ release, runtime }); +} + +async function verifyCandidateArtifacts( + paths: PreparedProductionDeliveryPaths, + candidateRelease: PublishedProductionRelease, + candidateRuntime: InstalledProductionRuntime, + dependencies: ProductionReleaseActivationDependencies +): Promise { + const verified = await loadExactArtifacts( + paths, + candidateRelease.manifest.source.commitSha, + candidateRuntime.identity.revision, + dependencies + ); + if ( + JSON.stringify(verified.release.manifest) !== + JSON.stringify(candidateRelease.manifest) || + verified.release.releaseRoot !== candidateRelease.releaseRoot || + verified.runtime.executable !== candidateRuntime.executable || + JSON.stringify(verified.runtime.identity) !== + JSON.stringify(candidateRuntime.identity) + ) { + throw activationError(); + } + return verified; +} + +function journalFor( + transitionId: string, + activation: ProductionActivationState, + candidate: ActiveArtifacts +): ProductionActivationTransition { + return parseProductionActivationTransition({ + candidate: { + releaseId: candidate.release.manifest.source.commitSha, + runtimeRevision: candidate.runtime.identity.revision, + }, + formatVersion: 1, + phase: "service-stop-requested", + previousActivation: activation.record ?? null, + previousDatabase: { state: "unrecorded" }, + transitionId, + }); +} + +function previousDatabaseForSnapshot( + snapshot: Awaited> +): ProductionActivationPreviousDatabase { + return snapshot.state === "absent" + ? { state: "absent" } + : { + manifest: snapshot.manifest, + sourceDatabase: snapshot.sourceDatabase, + state: "present", + }; +} + +function nextActivationRecord( + transitionId: string, + activation: ProductionActivationState, + candidate: ActiveArtifacts +): ProductionActivationRecord { + return { + current: { + releaseId: candidate.release.manifest.source.commitSha, + runtimeRevision: candidate.runtime.identity.revision, + }, + formatVersion: 1, + previous: activation.record + ? { + databaseSnapshotTransitionId: transitionId, + releaseId: activation.record.current.releaseId, + runtimeRevision: activation.record.current.runtimeRevision, + } + : null, + transitionId, + }; +} + +async function reconcileActivationCommit( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expected: ProductionActivationState, + next: ProductionActivationRecord +): Promise { + try { + return await commitProductionActivationState(lease, paths, expected, next); + } catch { + const observed = await loadProductionActivationState(lease, paths); + if (!sameRecord(observed.record, next)) throw activationError(); + return observed; + } +} + +async function reconcileActivationRollback( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expected: ProductionActivationState, + previous: ProductionActivationRecord | null +): Promise { + try { + return await restorePreviousProductionActivationState( + lease, + paths, + expected, + previous + ); + } catch { + const observed = await loadProductionActivationState(lease, paths); + if (!sameRecord(observed.record, previous ?? undefined)) { + throw activationError(); + } + return observed; + } +} + +async function restorePreviousDatabase( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + promoted: PromotedDatabaseState, + workspace: DatabaseTransitionWorkspace, + previous: ActiveArtifacts | undefined, + dependencies: ProductionReleaseActivationDependencies +): Promise { + if (promoted.previous.state === "absent") { + await restorePromotedDatabaseState(lease, paths, promoted); + return; + } + if (!previous) throw activationError(); + await prepareDatabaseRollbackCandidate(promoted, workspace); + await runDatabaseCandidateMaintenance( + lease, + paths, + previous.release, + previous.runtime, + workspace.transitionId, + workspace.candidateDirectory, + dependencies.maintenance + ); + const rollbackCandidate = await verifyDatabaseTransitionCandidate(workspace); + await restorePromotedDatabaseState(lease, paths, promoted, rollbackCandidate); +} + +async function discardTransitionWorkspace( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + transitionId: string, + workspace: DatabaseTransitionWorkspace | undefined +): Promise { + await (workspace + ? discardDatabaseTransitionWorkspace(lease, paths, workspace) + : discardOrphanDatabaseTransitionWorkspace(lease, paths, transitionId)); +} + +function activationMatchesCandidate( + activation: ProductionActivationState, + journal: ProductionActivationTransition +): boolean { + return ( + activation.record?.transitionId === journal.transitionId && + activation.record.current.releaseId === journal.candidate.releaseId && + activation.record.current.runtimeRevision === journal.candidate.runtimeRevision + ); +} + +async function rollbackTransition( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + journal: ProductionActivationTransition, + activation: ProductionActivationState, + dependencies: ProductionReleaseActivationDependencies +): Promise { + const candidateCommitted = activationMatchesCandidate(activation, journal); + const previousAuthoritative = sameRecord( + activation.record, + journal.previousActivation + ); + if ( + journal.phase === "service-stop-requested" && + (candidateCommitted || !previousAuthoritative) + ) { + throw activationError(); + } + if (!candidateCommitted && !previousAuthoritative) throw activationError(); + + const previous = journal.previousActivation + ? await loadActiveArtifacts(paths, journal.previousActivation, dependencies) + : undefined; + const stopOwner = + candidateCommitted || !previous + ? await loadExactArtifacts( + paths, + journal.candidate.releaseId, + journal.candidate.runtimeRevision, + dependencies + ) + : previous; + await dependencies.services.prepare(stopOwner.release, stopOwner.runtime); + await dependencies.services.stop(); + if (journal.phase === "service-stop-requested") { + await discardOrphanDatabaseTransitionWorkspace( + lease, + paths, + journal.transitionId + ); + if (previous) { + await prepareAndStartServices(dependencies.services, previous); + await dependencies.services.verifyReady(previous.release, previous.runtime); + } + await clearProductionActivationJournal(lease, paths, journal); + return activation; + } + const recovery = await inspectDatabaseTransitionRecovery(lease, paths, journal); + if (recovery.state === "promoted") { + await restorePreviousDatabase( + lease, + paths, + recovery.promoted, + recovery.workspace, + previous, + dependencies + ); + } + const restoredActivation = candidateCommitted + ? await reconcileActivationRollback( + lease, + paths, + activation, + journal.previousActivation + ) + : activation; + await discardOrphanDatabaseTransitionWorkspace(lease, paths, journal.transitionId); + if (previous) { + await prepareAndStartServices(dependencies.services, previous); + await dependencies.services.verifyReady(previous.release, previous.runtime); + } + await clearProductionActivationJournal(lease, paths, journal); + return restoredActivation; +} + +async function recoverExistingTransition( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + dependencies: ProductionReleaseActivationDependencies +): Promise { + const journal = await loadProductionActivationJournal(lease, paths); + const activation = await loadProductionActivationState(lease, paths); + if (!journal) return activation; + + const committed = activationMatchesCandidate(activation, journal); + if (committed && journal.phase !== "rollback-required") { + if (journal.phase !== "database-promoted") throw activationError(); + const currentRecord = activation.record; + if (!currentRecord) throw activationError(); + const current = await loadActiveArtifacts(paths, currentRecord, dependencies); + try { + await prepareAndStartServices(dependencies.services, current); + await dependencies.services.verifyReady(current.release, current.runtime); + } catch { + const rollback = await markProductionRollbackRequired(lease, paths, journal); + return rollbackTransition(lease, paths, rollback, activation, dependencies); + } + await discardOrphanDatabaseTransitionWorkspace( + lease, + paths, + journal.transitionId + ); + await clearProductionActivationJournal(lease, paths, journal); + return activation; + } + return rollbackTransition(lease, paths, journal, activation, dependencies); +} + +async function activateRelease( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + candidateRelease: PublishedProductionRelease, + candidateRuntime: InstalledProductionRuntime, + dependencies: ProductionReleaseActivationDependencies +): Promise { + const activation = await recoverExistingTransition(lease, paths, dependencies); + const candidate = await verifyCandidateArtifacts( + paths, + candidateRelease, + candidateRuntime, + dependencies + ); + if ( + activation.record?.current.releaseId === + candidate.release.manifest.source.commitSha && + activation.record.current.runtimeRevision === candidate.runtime.identity.revision + ) { + await prepareAndStartServices(dependencies.services, candidate); + await dependencies.services.verifyReady(candidate.release, candidate.runtime); + return activation.record; + } + + const previous = activation.record + ? await loadActiveArtifacts(paths, activation.record, dependencies) + : undefined; + const transitionId = Bun.randomUUIDv7(); + const expectedCommitted = nextActivationRecord(transitionId, activation, candidate); + let journal: ProductionActivationTransition | undefined; + let workspace: DatabaseTransitionWorkspace | undefined; + let promoted: PromotedDatabaseState | undefined; + try { + const stopOwner = previous ?? candidate; + await dependencies.services.prepare(stopOwner.release, stopOwner.runtime); + journal = await createProductionActivationJournal( + lease, + paths, + journalFor(transitionId, activation, candidate) + ); + await dependencies.services.stop(); + await dependencies.testHooks?.afterServicesStopped?.(); + const snapshotOwner = previous ?? candidate; + const snapshot = await runDatabaseSnapshotMaintenance( + lease, + paths, + snapshotOwner.release, + snapshotOwner.runtime, + transitionId, + previous ? "present" : "absent", + dependencies.maintenance + ); + journal = await markProductionSnapshotPrepared( + lease, + paths, + journal, + previousDatabaseForSnapshot(snapshot) + ); + workspace = await prepareDatabaseTransitionWorkspace( + lease, + paths, + transitionId, + snapshot + ); + await runDatabaseCandidateMaintenance( + lease, + paths, + candidate.release, + candidate.runtime, + transitionId, + workspace.candidateDirectory, + dependencies.maintenance + ); + const verifiedCandidate = await verifyDatabaseTransitionCandidate(workspace); + promoted = await promoteDatabaseTransitionCandidate( + lease, + paths, + verifiedCandidate + ); + journal = await markProductionDatabasePromoted(lease, paths, journal); + const committedState = await reconcileActivationCommit( + lease, + paths, + activation, + expectedCommitted + ); + const committed = committedState.record; + if (!committed) throw activationError(); + await dependencies.testHooks?.afterActivationCommit?.(); + try { + await prepareAndStartServices(dependencies.services, candidate); + await dependencies.services.verifyReady(candidate.release, candidate.runtime); + } catch { + journal = await markProductionRollbackRequired(lease, paths, journal); + throw activationError(); + } + await discardDatabaseTransitionWorkspace(lease, paths, workspace); + workspace = undefined; + await clearProductionActivationJournal(lease, paths, journal); + journal = undefined; + await dependencies.testHooks?.afterActivationJournalClear?.(); + return committed; + } catch { + const observedJournal = await loadProductionActivationJournal(lease, paths).catch( + () => journal + ); + const observedActivation = await loadProductionActivationState( + lease, + paths + ).catch(() => activation); + if (observedJournal) { + const recovered = await recoverExistingTransition(lease, paths, dependencies); + if (sameRecord(recovered.record, expectedCommitted)) { + return expectedCommitted; + } + throw activationError(); + } + if (sameRecord(observedActivation.record, expectedCommitted)) { + await discardTransitionWorkspace(lease, paths, transitionId, workspace); + return expectedCommitted; + } + + try { + await dependencies.services.stop(); + const rollbackPromoted = promoted; + const rollbackWorkspace = workspace; + if (rollbackPromoted && rollbackWorkspace) { + await restorePreviousDatabase( + lease, + paths, + rollbackPromoted, + rollbackWorkspace, + previous, + dependencies + ); + } + await discardTransitionWorkspace( + lease, + paths, + transitionId, + rollbackWorkspace + ); + if (previous) { + await prepareAndStartServices(dependencies.services, previous); + await dependencies.services.verifyReady( + previous.release, + previous.runtime + ); + } + } catch { + // Keep the durable journal and private workspace for the next recovery pass. + throw activationError(); + } + throw activationError(); + } +} + +/** + * Activates one published release and database as a crash-recoverable pair. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param candidateRelease Verified immutable candidate release. + * @param candidateRuntime Exact installed candidate Bun runtime. + * @param dependencies Process control and injectable verification boundaries. + * @returns Typed Effect yielding the authoritative committed activation record. + */ +export function activatePublishedProductionRelease( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + candidateRelease: PublishedProductionRelease, + candidateRuntime: InstalledProductionRuntime, + dependencies: ProductionReleaseActivationDependencies +): Effect.Effect { + return Effect.tryPromise({ + catch: () => activationError(), + try: () => + activateRelease( + lease, + paths, + candidateRelease, + candidateRuntime, + dependencies + ), + }); +} diff --git a/greenfield/scripts/delivery/productionReleasePublication.test.ts b/greenfield/scripts/delivery/productionReleasePublication.test.ts new file mode 100644 index 000000000..68d9c95d2 --- /dev/null +++ b/greenfield/scripts/delivery/productionReleasePublication.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + cp, + mkdir, + mkdtemp, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { releaseBuildCommands } from "../../src/shared/releaseManifest.ts"; +import type { BuildSourceIdentity } from "../buildSourceIdentity.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { buildDashboardRelease, type ReleaseBuildCommand } from "./buildRelease.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { publishProductionRelease } from "./productionReleasePublication.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; + +const sourceProjectRoot = path.resolve(import.meta.dir, "../.."); +const temporaryDirectories: string[] = []; +const commitSha = "b".repeat(40); +const cleanSource: BuildSourceIdentity = Object.freeze({ + commitSha, + state: "clean", +}); +const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ + revision: "a".repeat(40), + version: "1.4.0", +}); + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600); + } + } +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function repositoryFixture(): Promise { + const repositoryRoot = await mkdtemp( + path.join(tmpdir(), "mira-production-release-source-") + ); + temporaryDirectories.push(repositoryRoot); + await Promise.all([ + cp( + path.join(sourceProjectRoot, "docs/generated"), + path.join(repositoryRoot, "docs/generated"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, "migrations"), + path.join(repositoryRoot, "migrations"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, "systemd"), + path.join(repositoryRoot, "systemd"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, ".bun-version"), + path.join(repositoryRoot, ".bun-version") + ), + cp( + path.join(sourceProjectRoot, "bun.lock"), + path.join(repositoryRoot, "bun.lock") + ), + cp( + path.join(sourceProjectRoot, "package.json"), + path.join(repositoryRoot, "package.json") + ), + ]); + return repositoryRoot; +} + +async function materializeCommandOutput( + command: ReleaseBuildCommand, + repositoryRoot: string +): Promise { + if (command === "bun run build:browser") { + await mkdir(path.join(repositoryRoot, "dist/browser/assets"), { + recursive: true, + }); + await Promise.all([ + writeFile(path.join(repositoryRoot, "dist/browser/index.html"), "dashboard"), + writeFile( + path.join(repositoryRoot, "dist/browser/assets/app-a1b2c3d4.js"), + "app" + ), + ]); + } + if (command === "bun run build:processes") { + await mkdir(path.join(repositoryRoot, "dist/processes"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(repositoryRoot, "dist/processes/databaseMaintenance.js"), + "database-maintenance" + ), + writeFile(path.join(repositoryRoot, "dist/processes/web.js"), "web"), + writeFile(path.join(repositoryRoot, "dist/processes/worker.js"), "worker"), + ]); + } +} + +async function localReleaseFixture(): Promise { + const repositoryRoot = await repositoryFixture(); + const observedCommands: ReleaseBuildCommand[] = []; + const release = await buildDashboardRelease(repositoryRoot, { + resolveSourceIdentity: () => cleanSource, + runCommand: async (command, root) => { + observedCommands.push(command); + await materializeCommandOutput(command, root); + }, + runtimeIdentity, + }); + expect(observedCommands).toEqual(releaseBuildCommands); + return release.releaseRoot; +} + +async function productionProjectFixture(): Promise { + const projectRoot = await mkdtemp( + path.join(tmpdir(), "mira-production-release-target-") + ); + temporaryDirectories.push(projectRoot); + return projectRoot; +} + +describe("production release publication", () => { + test("publishes one immutable commit release idempotently under the project root", async () => { + const sourceReleaseRoot = await localReleaseFixture(); + const projectRoot = await productionProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + const first = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const publication = await publishProductionRelease( + lease, + paths, + sourceReleaseRoot, + runtimeIdentity + ); + return { paths, publication }; + }); + const second = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + return publishProductionRelease( + lease, + paths, + sourceReleaseRoot, + runtimeIdentity + ); + }); + + expect(first.publication.releaseRoot).toBe( + path.join(first.paths.releasesDirectory, commitSha) + ); + expect(second).toEqual(first.publication); + const releaseStatus = await stat(first.publication.releaseRoot); + const manifestStatus = await stat( + path.join(first.publication.releaseRoot, "release-manifest.json") + ); + expect(releaseStatus.mode & 0o777).toBe(0o500); + expect(manifestStatus.mode & 0o777).toBe(0o400); + expect(await readdir(first.paths.releasesDirectory)).toEqual([commitSha]); + }); + + test("rejects staged tampering and removes only its owned candidate", async () => { + const sourceReleaseRoot = await localReleaseFixture(); + const projectRoot = await productionProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + const result = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const failure = await rejectionError( + publishProductionRelease( + lease, + paths, + sourceReleaseRoot, + runtimeIdentity, + { + afterCopy: (stagingRoot) => + writeFile( + path.join(stagingRoot, "server/web.js"), + "tampered" + ), + } + ) + ); + return { failure, paths }; + }); + + expect(result.failure.message).toBe("Production release publication failed"); + expect(await readdir(result.paths.releasesDirectory)).toEqual([]); + }); + + test("never overwrites or removes a pre-existing commit path", async () => { + const sourceReleaseRoot = await localReleaseFixture(); + const projectRoot = await productionProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + const result = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const existing = path.join(paths.releasesDirectory, commitSha); + await mkdir(existing, { mode: 0o500 }); + const failure = await rejectionError( + publishProductionRelease(lease, paths, sourceReleaseRoot, runtimeIdentity) + ); + return { existing, failure }; + }); + + expect(result.failure.message).toBe("Production release publication failed"); + const existingStatus = await stat(result.existing); + expect(existingStatus.isDirectory()).toBeTrue(); + }); +}); diff --git a/greenfield/scripts/delivery/productionReleasePublication.ts b/greenfield/scripts/delivery/productionReleasePublication.ts new file mode 100644 index 000000000..862d96e12 --- /dev/null +++ b/greenfield/scripts/delivery/productionReleasePublication.ts @@ -0,0 +1,533 @@ +import type { BigIntStats, Dirent } from "node:fs"; +import { + chmod, + lstat, + mkdir, + readdir, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import type { ReleaseManifest } from "../../src/shared/releaseManifest.ts"; +import { parseReleaseManifest } from "../../src/shared/releaseManifest.ts"; +import { readBoundedRegularFile } from "../files/boundedFile.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; +import { + inventoryReleaseArtifactTree, + maximumReleaseArtifactBytes, + type ReleaseArtifactInventoryRecord, +} from "./releaseArtifactInventory.ts"; +import { type ReleaseRuntimeIdentity, verifyReleaseIdentity } from "./releaseIdentity.ts"; + +const productionReleaseFailureMessage = "Production release publication failed"; +const privateDirectoryMode = 0o700; +const privateFileMode = 0o600; +const immutableDirectoryMode = 0o500; +const immutableFileMode = 0o400; +const commitShaPattern = /^[a-f\d]{40}$/u; +const maximumCleanupEntries = 4608; +const maximumCleanupDepth = 20; +const maximumPublishedManifestBytes = 4 * 1024 * 1024; + +/** Immutable production release materialized below the project-local release root. */ +export interface PublishedProductionRelease { + readonly manifest: ReleaseManifest; + readonly releaseRoot: string; +} + +/** Deterministic publication mutation boundaries exposed only to adversarial tests. */ +export interface ProductionReleasePublicationTestHooks { + readonly afterCopy?: (stagingRoot: string) => Promise | void; + readonly afterFreeze?: (stagingRoot: string) => Promise | void; +} + +interface ExpectedTreeEntry { + readonly kind: "directory" | "file"; + readonly name: string; +} + +function productionReleaseFailure(): Error { + return new Error(productionReleaseFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function sameArtifactRecords( + left: readonly ReleaseArtifactInventoryRecord[], + right: readonly ReleaseArtifactInventoryRecord[] +): boolean { + return ( + left.length === right.length && + left.every( + (record, index) => + record.bytes === right[index]?.bytes && + record.path === right[index]?.path && + record.sha256 === right[index]?.sha256 + ) + ); +} + +function sameManifest(left: ReleaseManifest, right: ReleaseManifest): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function entrySignature(entry: Dirent): string { + if (entry.isDirectory()) return `directory:${entry.name}`; + if (entry.isFile()) return `file:${entry.name}`; + return `other:${entry.name}`; +} + +function expectedTreeEntries( + records: readonly ReleaseArtifactInventoryRecord[] +): ReadonlyMap { + const entries = new Map>([["", new Map()]]); + for (const record of records) { + const segments = record.path.split("/"); + let directory = ""; + for (const segment of segments.slice(0, -1)) { + const childDirectory = directory ? `${directory}/${segment}` : segment; + const parentEntries = entries.get(directory); + if (!parentEntries) throw productionReleaseFailure(); + parentEntries.set( + `directory:${segment}`, + Object.freeze({ kind: "directory", name: segment }) + ); + if (!entries.has(childDirectory)) entries.set(childDirectory, new Map()); + directory = childDirectory; + } + const filename = segments.at(-1); + const directoryEntries = entries.get(directory); + if (!filename || !directoryEntries) throw productionReleaseFailure(); + directoryEntries.set( + `file:${filename}`, + Object.freeze({ kind: "file", name: filename }) + ); + } + return new Map( + [...entries].map(([directory, values]) => [ + directory, + Object.freeze( + [...values.values()].toSorted((left, right) => + compareText( + `${left.kind}:${left.name}`, + `${right.kind}:${right.name}` + ) + ) + ), + ]) + ); +} + +function validDirectory( + status: BigIntStats, + userId: number, + device: bigint, + mode: bigint +): boolean { + return ( + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(userId) && + status.dev === device && + (status.mode & 0o7777n) === mode + ); +} + +function validFile( + status: BigIntStats, + userId: number, + device: bigint, + mode: bigint, + bytes: number +): boolean { + return ( + status.isFile() && + !status.isSymbolicLink() && + status.nlink === 1n && + status.uid === BigInt(userId) && + status.dev === device && + status.size === BigInt(bytes) && + (status.mode & 0o7777n) === mode + ); +} + +async function assertReleaseTreeMode( + releaseRoot: string, + records: readonly ReleaseArtifactInventoryRecord[], + immutable: boolean +): Promise { + if (typeof process.getuid !== "function") throw productionReleaseFailure(); + const [canonicalRoot, rootStatus] = await Promise.all([ + realpath(releaseRoot), + lstat(releaseRoot, { bigint: true }), + ]); + const directoryMode = immutable ? 0o500n : 0o700n; + const fileMode = immutable ? 0o400n : 0o600n; + if ( + canonicalRoot !== releaseRoot || + !validDirectory(rootStatus, process.getuid(), rootStatus.dev, directoryMode) + ) { + throw productionReleaseFailure(); + } + + const entriesByDirectory = expectedTreeEntries(records); + const recordByPath = new Map(records.map((record) => [record.path, record])); + for (const [relativeDirectory, expectedEntries] of entriesByDirectory) { + const directory = relativeDirectory + ? path.join(releaseRoot, relativeDirectory) + : releaseRoot; + const directoryStatus = await lstat(directory, { bigint: true }); + if ( + !validDirectory( + directoryStatus, + process.getuid(), + rootStatus.dev, + directoryMode + ) + ) { + throw productionReleaseFailure(); + } + const actualEntries = await readdir(directory, { withFileTypes: true }); + const actualSignatures = actualEntries + .map((entry) => entrySignature(entry)) + .toSorted(compareText); + const expectedSignatures = expectedEntries + .map((entry) => `${entry.kind}:${entry.name}`) + .toSorted(compareText); + if ( + actualSignatures.length !== expectedSignatures.length || + actualSignatures.some( + (signature, index) => signature !== expectedSignatures[index] + ) + ) { + throw productionReleaseFailure(); + } + for (const entry of expectedEntries) { + if (entry.kind !== "file") continue; + const relativePath = relativeDirectory + ? `${relativeDirectory}/${entry.name}` + : entry.name; + const record = recordByPath.get(relativePath); + if ( + !record || + !validFile( + await lstat(path.join(releaseRoot, relativePath), { + bigint: true, + }), + process.getuid(), + rootStatus.dev, + fileMode, + record.bytes + ) + ) { + throw productionReleaseFailure(); + } + } + } +} + +async function assertPrivateReleasesDirectory(directory: string): Promise { + if (typeof process.getuid !== "function") throw productionReleaseFailure(); + const [canonical, status] = await Promise.all([ + realpath(directory), + lstat(directory, { bigint: true }), + ]); + if ( + canonical !== directory || + !validDirectory(status, process.getuid(), status.dev, 0o700n) + ) { + throw productionReleaseFailure(); + } +} + +function validatePublicationInputs( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + sourceReleaseRoot: string +): void { + if ( + lease.stateDirectory !== paths.stateDirectory || + paths.releasesDirectory !== path.join(paths.productionDirectory, "releases") || + paths.runtimesDirectory !== path.join(paths.productionDirectory, "runtimes") || + !path.isAbsolute(sourceReleaseRoot) || + sourceReleaseRoot.includes("\0") || + path.resolve(sourceReleaseRoot) !== sourceReleaseRoot + ) { + throw productionReleaseFailure(); + } +} + +async function pathExists(candidate: string): Promise { + try { + await lstat(candidate); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw productionReleaseFailure(); + } +} + +async function copyReleaseTree( + sourceRoot: string, + destinationRoot: string +): Promise { + const sourceBefore = await inventoryReleaseArtifactTree(sourceRoot); + await mkdir(destinationRoot, { mode: privateDirectoryMode }); + for (const record of sourceBefore) { + const contents = await readBoundedRegularFile( + path.join(sourceRoot, record.path), + sourceRoot, + maximumReleaseArtifactBytes, + productionReleaseFailureMessage + ); + if ( + contents.byteLength !== record.bytes || + new Bun.CryptoHasher("sha256").update(contents).digest("hex") !== + record.sha256 + ) { + throw productionReleaseFailure(); + } + const destination = path.join(destinationRoot, record.path); + await mkdir(path.dirname(destination), { + mode: privateDirectoryMode, + recursive: true, + }); + await writeFile(destination, contents, { + flag: "wx", + mode: privateFileMode, + }); + } + const [sourceAfter, destination] = await Promise.all([ + inventoryReleaseArtifactTree(sourceRoot), + inventoryReleaseArtifactTree(destinationRoot), + ]); + if ( + !sameArtifactRecords(sourceBefore, sourceAfter) || + !sameArtifactRecords(sourceBefore, destination) + ) { + throw productionReleaseFailure(); + } + await assertReleaseTreeMode(destinationRoot, destination, false); + return destination; +} + +async function freezeReleaseTree( + releaseRoot: string, + records: readonly ReleaseArtifactInventoryRecord[] +): Promise { + const directories = [...expectedTreeEntries(records).keys()] + .map((relative) => (relative ? path.join(releaseRoot, relative) : releaseRoot)) + .toSorted((left, right) => right.length - left.length); + for (const record of records) { + await chmod(path.join(releaseRoot, record.path), immutableFileMode); + } + for (const directory of directories) { + await chmod(directory, immutableDirectoryMode); + } + const after = await inventoryReleaseArtifactTree(releaseRoot); + if (!sameArtifactRecords(records, after)) throw productionReleaseFailure(); + await assertReleaseTreeMode(releaseRoot, after, true); +} + +async function restoreOwnedCandidate( + releasesDirectory: string, + candidateRoot: string, + expectedName: string +): Promise { + if ( + path.dirname(candidateRoot) !== releasesDirectory || + path.basename(candidateRoot) !== expectedName + ) { + throw productionReleaseFailure(); + } + if (!(await pathExists(candidateRoot))) return; + if (typeof process.getuid !== "function") throw productionReleaseFailure(); + const userId = process.getuid(); + const rootStatus = await lstat(candidateRoot, { bigint: true }); + if ( + !rootStatus.isDirectory() || + rootStatus.isSymbolicLink() || + rootStatus.uid !== BigInt(userId) + ) { + throw productionReleaseFailure(); + } + + let entryCount = 0; + const restore = async (directory: string, depth: number): Promise => { + if (depth > maximumCleanupDepth) throw productionReleaseFailure(); + await chmod(directory, privateDirectoryMode); + for (const entry of await readdir(directory, { withFileTypes: true })) { + entryCount += 1; + if (entryCount > maximumCleanupEntries) throw productionReleaseFailure(); + const entryPath = path.join(directory, entry.name); + const status = await lstat(entryPath, { bigint: true }); + if ( + status.isSymbolicLink() || + status.uid !== BigInt(userId) || + status.dev !== rootStatus.dev + ) { + throw productionReleaseFailure(); + } + if (status.isDirectory()) { + await restore(entryPath, depth + 1); + } else if (status.isFile() && status.nlink === 1n) { + await chmod(entryPath, privateFileMode); + } else { + throw productionReleaseFailure(); + } + } + }; + await restore(candidateRoot, 0); + await rm(candidateRoot, { force: false, recursive: true }); +} + +/** + * Copies one verified local build into its immutable commit-addressed production slot. + * The unforgeable lease proves this runs inside the wider release/database transition. + * @param lease Active deployment lease token. + * @param paths Revalidated project-local production delivery paths. + * @param sourceReleaseRoot Immutable local release artifact created by `build:release`. + * @param runtimeIdentity Exact Bun identity represented by the release. + * @param testHooks Deterministic mutation hooks used only by tests. + * @returns Idempotently published production release. + */ +export async function publishProductionRelease( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + sourceReleaseRoot: string, + runtimeIdentity: ReleaseRuntimeIdentity, + testHooks: ProductionReleasePublicationTestHooks = {} +): Promise { + validatePublicationInputs(lease, paths, sourceReleaseRoot); + await assertPrivateReleasesDirectory(paths.releasesDirectory); + let ownedRoot: string | undefined; + let ownedName: string | undefined; + try { + const sourceManifest = await verifyReleaseIdentity( + sourceReleaseRoot, + runtimeIdentity + ); + const sourceRecords = await inventoryReleaseArtifactTree(sourceReleaseRoot); + await assertReleaseTreeMode(sourceReleaseRoot, sourceRecords, true); + const commitSha = sourceManifest.source.commitSha; + if (!commitShaPattern.test(commitSha)) throw productionReleaseFailure(); + const finalRoot = path.join(paths.releasesDirectory, commitSha); + if (await pathExists(finalRoot)) { + const existing = await verifyReleaseIdentity(finalRoot, runtimeIdentity); + const existingRecords = await inventoryReleaseArtifactTree(finalRoot); + await assertReleaseTreeMode(finalRoot, existingRecords, true); + if (!sameManifest(sourceManifest, existing)) { + throw productionReleaseFailure(); + } + return Object.freeze({ manifest: existing, releaseRoot: finalRoot }); + } + + const stageName = `.stage-${commitSha}-${Bun.randomUUIDv7()}`; + const stagingRoot = path.join(paths.releasesDirectory, stageName); + ownedRoot = stagingRoot; + ownedName = stageName; + const stagedRecords = await copyReleaseTree(sourceReleaseRoot, stagingRoot); + await testHooks.afterCopy?.(stagingRoot); + const stagedManifest = await verifyReleaseIdentity(stagingRoot, runtimeIdentity); + if (!sameManifest(sourceManifest, stagedManifest)) { + throw productionReleaseFailure(); + } + await freezeReleaseTree(stagingRoot, stagedRecords); + await testHooks.afterFreeze?.(stagingRoot); + const frozenManifest = await verifyReleaseIdentity(stagingRoot, runtimeIdentity); + if (!sameManifest(sourceManifest, frozenManifest)) { + throw productionReleaseFailure(); + } + + await rename(stagingRoot, finalRoot); + ownedRoot = finalRoot; + ownedName = commitSha; + if ((await realpath(finalRoot)) !== finalRoot) throw productionReleaseFailure(); + const published = await verifyReleaseIdentity(finalRoot, runtimeIdentity); + const publishedRecords = await inventoryReleaseArtifactTree(finalRoot); + await assertReleaseTreeMode(finalRoot, publishedRecords, true); + if (!sameManifest(sourceManifest, published)) { + throw productionReleaseFailure(); + } + ownedRoot = undefined; + ownedName = undefined; + return Object.freeze({ manifest: published, releaseRoot: finalRoot }); + } catch { + if (ownedRoot && ownedName) { + try { + await restoreOwnedCandidate( + paths.releasesDirectory, + ownedRoot, + ownedName + ); + } catch { + // Preserve the fixed publication failure and leave bounded evidence. + } + } + throw productionReleaseFailure(); + } +} + +/** + * Reloads and fully verifies one immutable production release named by activation state. + * @param paths Exact project-local production delivery paths. + * @param releaseId Full commit identity stored in the activation record. + * @param runtimeRevision Exact Bun revision stored in the activation record. + * @returns Verified immutable production release and manifest. + */ +export async function loadPublishedProductionRelease( + paths: PreparedProductionDeliveryPaths, + releaseId: string, + runtimeRevision: string +): Promise { + try { + if ( + !commitShaPattern.test(releaseId) || + !commitShaPattern.test(runtimeRevision) + ) { + throw productionReleaseFailure(); + } + await assertPrivateReleasesDirectory(paths.releasesDirectory); + const releaseRoot = path.join(paths.releasesDirectory, releaseId); + const manifestBytes = await readBoundedRegularFile( + path.join(releaseRoot, "release-manifest.json"), + releaseRoot, + maximumPublishedManifestBytes, + productionReleaseFailureMessage + ); + const manifestText = new TextDecoder("utf-8", { fatal: true }).decode( + manifestBytes + ); + const manifestValue: unknown = JSON.parse(manifestText); + const preliminary = parseReleaseManifest(manifestValue); + if ( + preliminary.source.commitSha !== releaseId || + preliminary.runtime.revision !== runtimeRevision + ) { + throw productionReleaseFailure(); + } + const manifest = await verifyReleaseIdentity(releaseRoot, preliminary.runtime); + const records = await inventoryReleaseArtifactTree(releaseRoot); + await assertReleaseTreeMode(releaseRoot, records, true); + if (JSON.stringify(manifest) !== JSON.stringify(preliminary)) { + throw productionReleaseFailure(); + } + return Object.freeze({ manifest, releaseRoot }); + } catch { + throw productionReleaseFailure(); + } +} diff --git a/greenfield/scripts/delivery/productionRuntime.test.ts b/greenfield/scripts/delivery/productionRuntime.test.ts new file mode 100644 index 000000000..b11bcd341 --- /dev/null +++ b/greenfield/scripts/delivery/productionRuntime.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { installProductionRuntime } from "./productionRuntime.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; + +const temporaryDirectories: string[] = []; +const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ + revision: "a".repeat(40), + version: "1.4.0", +}); + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600); + } + } +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function fixture(): Promise<{ + projectRoot: string; + sourceExecutable: string; +}> { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-production-runtime-")); + const sourceRoot = await mkdtemp( + path.join(tmpdir(), "mira-production-runtime-source-") + ); + temporaryDirectories.push(projectRoot, sourceRoot); + const sourceExecutable = path.join(sourceRoot, "bun"); + await writeFile(sourceExecutable, "test-bun-runtime-bytes"); + await chmod(sourceExecutable, 0o500); + return { projectRoot, sourceExecutable }; +} + +describe("production Bun runtime", () => { + test("installs one immutable exact runtime idempotently inside production", async () => { + const { projectRoot, sourceExecutable } = await fixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + const probes: string[] = []; + const dependencies = { + probeRuntime(executable: string) { + probes.push(executable); + return Promise.resolve(runtimeIdentity); + }, + sourceExecutable, + }; + const first = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const runtime = await installProductionRuntime( + lease, + paths, + runtimeIdentity, + dependencies + ); + return { paths, runtime }; + }); + const second = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + return installProductionRuntime(lease, paths, runtimeIdentity, dependencies); + }); + + expect(first.runtime.executable).toBe( + path.join( + first.paths.runtimesDirectory, + "bun", + runtimeIdentity.revision, + "bun" + ) + ); + expect(second).toEqual(first.runtime); + const executableStatus = await stat(first.runtime.executable); + const revisionStatus = await stat(path.dirname(first.runtime.executable)); + expect(executableStatus.mode & 0o777).toBe(0o500); + expect(revisionStatus.mode & 0o777).toBe(0o500); + expect(probes).toContain(sourceExecutable); + expect(probes).toContain(first.runtime.executable); + }); + + test("rejects copied-byte tampering and removes its owned stage", async () => { + const { projectRoot, sourceExecutable } = await fixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + const result = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const failure = await rejectionError( + installProductionRuntime(lease, paths, runtimeIdentity, { + afterCopy: (destination) => writeFile(destination, "tampered"), + probeRuntime: () => Promise.resolve(runtimeIdentity), + sourceExecutable, + }) + ); + return { failure, paths }; + }); + + expect(result.failure.message).toBe("Production Bun runtime installation failed"); + const bunRoot = path.join(result.paths.runtimesDirectory, "bun"); + expect(await readdir(bunRoot)).toEqual([]); + }); + + test("never replaces a pre-existing runtime revision directory", async () => { + const { projectRoot, sourceExecutable } = await fixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + const result = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const bunRoot = path.join(paths.runtimesDirectory, "bun"); + const existing = path.join(bunRoot, runtimeIdentity.revision); + await mkdir(bunRoot, { mode: 0o700 }); + await mkdir(existing, { mode: 0o500 }); + const failure = await rejectionError( + installProductionRuntime(lease, paths, runtimeIdentity, { + probeRuntime: () => Promise.resolve(runtimeIdentity), + sourceExecutable, + }) + ); + return { existing, failure }; + }); + + expect(result.failure.message).toBe("Production Bun runtime installation failed"); + const existingStatus = await stat(result.existing); + expect(existingStatus.isDirectory()).toBeTrue(); + }); +}); diff --git a/greenfield/scripts/delivery/productionRuntime.ts b/greenfield/scripts/delivery/productionRuntime.ts new file mode 100644 index 000000000..a65b3bbf0 --- /dev/null +++ b/greenfield/scripts/delivery/productionRuntime.ts @@ -0,0 +1,541 @@ +import { constants, type BigIntStats } from "node:fs"; +import { + chmod, + lstat, + mkdir, + open, + realpath, + rename, + rm, + type FileHandle, +} from "node:fs/promises"; +import path from "node:path"; + +import * as v from "valibot"; + +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; + +const productionRuntimeFailureMessage = "Production Bun runtime installation failed"; +const maximumRuntimeBytes = 256 * 1024 * 1024; +const maximumProbeBytes = 512; +const copyBufferBytes = 1024 * 1024; +const privateDirectoryMode = 0o700; +const privateFileMode = 0o600; +const immutableDirectoryMode = 0o500; +const immutableFileMode = 0o500; +const sourceFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +const destinationFlags = + constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR; +const runtimeIdentitySchema = v.strictObject({ + revision: v.pipe(v.string(), v.regex(/^[a-f\d]{40}$/u)), + version: v.pipe(v.string(), v.regex(/^\d+\.\d+\.\d+$/u)), +}); + +/** Installed exact Bun runtime below the Dashboard project root. */ +export interface InstalledProductionRuntime { + readonly executable: string; + readonly identity: ReleaseRuntimeIdentity; +} + +/** Read-only runtime probe boundary used by activation verification tests. */ +export interface ProductionRuntimeVerificationDependencies { + readonly probeRuntime?: (executable: string) => Promise; +} + +/** Runtime probe and mutation boundaries exposed only to focused tests. */ +export interface ProductionRuntimeDependencies { + readonly afterCopy?: (destination: string) => Promise | void; + readonly probeRuntime?: (executable: string) => Promise; + readonly sourceExecutable?: string; +} + +interface FileSnapshot { + readonly ctimeNs: bigint; + readonly dev: bigint; + readonly ino: bigint; + readonly mtimeNs: bigint; + readonly size: bigint; + readonly uid: bigint; +} + +function productionRuntimeFailure(): Error { + return new Error(productionRuntimeFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function snapshot(status: BigIntStats): FileSnapshot { + if ( + typeof process.getuid !== "function" || + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.size <= 0n || + status.size > BigInt(maximumRuntimeBytes) + ) { + throw productionRuntimeFailure(); + } + return Object.freeze({ + ctimeNs: status.ctimeNs, + dev: status.dev, + ino: status.ino, + mtimeNs: status.mtimeNs, + size: status.size, + uid: status.uid, + }); +} + +function sameSnapshot(expected: FileSnapshot, actual: FileSnapshot): boolean { + return ( + expected.ctimeNs === actual.ctimeNs && + expected.dev === actual.dev && + expected.ino === actual.ino && + expected.mtimeNs === actual.mtimeNs && + expected.size === actual.size && + expected.uid === actual.uid + ); +} + +function sameRuntimeIdentity( + expected: ReleaseRuntimeIdentity, + actual: ReleaseRuntimeIdentity +): boolean { + return expected.revision === actual.revision && expected.version === actual.version; +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function readBoundedProbeOutput( + stream: ReadableStream +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + bytes += result.value.byteLength; + if (bytes > maximumProbeBytes) throw productionRuntimeFailure(); + chunks.push(result.value); + } + } finally { + reader.releaseLock(); + } + const output = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +async function probeProductionRuntime( + executable: string +): Promise { + const child = Bun.spawn( + [ + executable, + "-e", + "process.stdout.write(JSON.stringify({revision:Bun.revision,version:Bun.version}))", + ], + { + env: { PATH: "/usr/bin:/bin" }, + signal: AbortSignal.timeout(10_000), + stderr: "ignore", + stdin: "ignore", + stdout: "pipe", + } + ); + try { + const output = await readBoundedProbeOutput(child.stdout); + if ((await child.exited) !== 0) throw productionRuntimeFailure(); + const text = new TextDecoder("utf-8", { fatal: true }).decode(output); + const parsed: unknown = JSON.parse(text); + return Object.freeze(v.parse(runtimeIdentitySchema, parsed)); + } catch { + child.kill(); + throw productionRuntimeFailure(); + } +} + +async function ensurePrivateDirectory(parent: string, name: string): Promise { + if (typeof process.getuid !== "function") throw productionRuntimeFailure(); + const directory = path.join(parent, name); + try { + await mkdir(directory, { mode: privateDirectoryMode }); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw productionRuntimeFailure(); + } + try { + const [canonical, status] = await Promise.all([ + realpath(directory), + lstat(directory, { bigint: true }), + ]); + if ( + canonical !== directory || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) || + (status.mode & 0o700n) !== 0o700n + ) { + throw productionRuntimeFailure(); + } + await chmod(directory, privateDirectoryMode); + const after = await lstat(directory, { bigint: true }); + if ( + after.dev !== status.dev || + after.ino !== status.ino || + (after.mode & 0o7777n) !== 0o700n + ) { + throw productionRuntimeFailure(); + } + return directory; + } catch { + throw productionRuntimeFailure(); + } +} + +async function assertPrivateRuntimeRoot(directory: string): Promise { + if (typeof process.getuid !== "function") throw productionRuntimeFailure(); + const [canonical, status] = await Promise.all([ + realpath(directory), + lstat(directory, { bigint: true }), + ]); + if ( + canonical !== directory || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) || + (status.mode & 0o7777n) !== 0o700n + ) { + throw productionRuntimeFailure(); + } +} + +async function pathExists(candidate: string): Promise { + try { + await lstat(candidate); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw productionRuntimeFailure(); + } +} + +async function hashFileHandle( + handle: FileHandle, + expectedBytes: number +): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + const buffer = Buffer.alloc(Math.min(copyBufferBytes, expectedBytes)); + let offset = 0; + while (offset < expectedBytes) { + const length = Math.min(buffer.byteLength, expectedBytes - offset); + const result = await handle.read(buffer, 0, length, offset); + if (result.bytesRead <= 0) throw productionRuntimeFailure(); + hasher.update(buffer.subarray(0, result.bytesRead)); + offset += result.bytesRead; + } + return hasher.digest("hex"); +} + +async function copyRuntimeExecutable( + sourceExecutable: string, + destination: string, + afterCopy?: (destination: string) => Promise | void +): Promise { + let source: FileHandle | undefined; + let target: FileHandle | undefined; + let failed = false; + try { + source = await open(sourceExecutable, sourceFlags); + const heldStatus = await source.stat({ bigint: true }); + const heldBefore = snapshot(heldStatus); + const canonical = await realpath(`/proc/self/fd/${source.fd}`); + if (canonical !== sourceExecutable || (heldStatus.mode & 0o100n) === 0n) { + throw productionRuntimeFailure(); + } + + target = await open(destination, destinationFlags, privateFileMode); + const buffer = Buffer.alloc(Math.min(copyBufferBytes, Number(heldBefore.size))); + const sourceHasher = new Bun.CryptoHasher("sha256"); + let offset = 0; + while (offset < Number(heldBefore.size)) { + const length = Math.min(buffer.byteLength, Number(heldBefore.size) - offset); + const read = await source.read(buffer, 0, length, offset); + if (read.bytesRead <= 0) throw productionRuntimeFailure(); + sourceHasher.update(buffer.subarray(0, read.bytesRead)); + let written = 0; + while (written < read.bytesRead) { + const write = await target.write( + buffer, + written, + read.bytesRead - written, + offset + written + ); + if (write.bytesWritten <= 0) throw productionRuntimeFailure(); + written += write.bytesWritten; + } + offset += read.bytesRead; + } + await target.sync(); + await afterCopy?.(destination); + const [heldAfter, sourceAfter, targetStatus] = await Promise.all([ + source.stat({ bigint: true }), + lstat(sourceExecutable, { bigint: true }), + target.stat({ bigint: true }), + ]); + if ( + !sameSnapshot(heldBefore, snapshot(heldAfter)) || + !sameSnapshot(heldBefore, snapshot(sourceAfter)) || + targetStatus.size !== heldBefore.size || + targetStatus.nlink !== 1n || + targetStatus.uid !== heldBefore.uid + ) { + throw productionRuntimeFailure(); + } + const destinationHash = await hashFileHandle(target, Number(heldBefore.size)); + if (destinationHash !== sourceHasher.digest("hex")) { + throw productionRuntimeFailure(); + } + await target.chmod(immutableFileMode); + await target.sync(); + } catch { + failed = true; + } + const [sourceClosed, targetClosed] = await Promise.all([ + closeHandle(source), + closeHandle(target), + ]); + if (failed || !sourceClosed || !targetClosed) throw productionRuntimeFailure(); +} + +async function assertInstalledRuntimeFile(executable: string): Promise { + if (typeof process.getuid !== "function") throw productionRuntimeFailure(); + const [canonical, status, parentStatus] = await Promise.all([ + realpath(executable), + lstat(executable, { bigint: true }), + lstat(path.dirname(executable), { bigint: true }), + ]); + if ( + canonical !== executable || + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.dev !== parentStatus.dev || + status.size <= 0n || + status.size > BigInt(maximumRuntimeBytes) || + (status.mode & 0o7777n) !== 0o500n || + !parentStatus.isDirectory() || + parentStatus.isSymbolicLink() || + parentStatus.uid !== BigInt(process.getuid()) || + (parentStatus.mode & 0o7777n) !== 0o500n + ) { + throw productionRuntimeFailure(); + } +} + +async function removeOwnedRuntimeCandidate( + bunRoot: string, + stageRoot: string, + stageName: string +): Promise { + if (path.dirname(stageRoot) !== bunRoot || path.basename(stageRoot) !== stageName) { + throw productionRuntimeFailure(); + } + try { + const status = await lstat(stageRoot, { bigint: true }); + if ( + typeof process.getuid !== "function" || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) + ) { + throw productionRuntimeFailure(); + } + await chmod(stageRoot, privateDirectoryMode); + const executable = path.join(stageRoot, "bun"); + const file = await lstat(executable, { bigint: true }).catch(() => null); + if (file) { + if ( + !file.isFile() || + file.isSymbolicLink() || + file.nlink !== 1n || + file.uid !== BigInt(process.getuid()) + ) { + throw productionRuntimeFailure(); + } + await chmod(executable, privateFileMode); + } + await rm(stageRoot, { force: false, recursive: true }); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw productionRuntimeFailure(); + } +} + +/** + * Installs the exact Bun executable represented by a release under project-local runtimes. + * @param lease Active wider deployment transition lease. + * @param paths Revalidated production delivery paths. + * @param expectedIdentity Exact runtime identity from the release manifest. + * @param dependencies Injectable source/probe boundaries used by focused tests. + * @returns Idempotently installed immutable runtime executable. + */ +export async function installProductionRuntime( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + expectedIdentity: ReleaseRuntimeIdentity, + dependencies: ProductionRuntimeDependencies = {} +): Promise { + if ( + lease.stateDirectory !== paths.stateDirectory || + paths.runtimesDirectory !== path.join(paths.productionDirectory, "runtimes") || + !v.is(runtimeIdentitySchema, expectedIdentity) + ) { + throw productionRuntimeFailure(); + } + const probe = dependencies.probeRuntime ?? probeProductionRuntime; + const sourceExecutable = dependencies.sourceExecutable ?? process.execPath; + let ownedRoot: string | undefined; + let ownedName: string | undefined; + try { + await assertPrivateRuntimeRoot(paths.runtimesDirectory); + if ( + !path.isAbsolute(sourceExecutable) || + path.resolve(sourceExecutable) !== sourceExecutable || + (await realpath(sourceExecutable)) !== sourceExecutable || + !sameRuntimeIdentity(expectedIdentity, await probe(sourceExecutable)) + ) { + throw productionRuntimeFailure(); + } + const bunRoot = await ensurePrivateDirectory(paths.runtimesDirectory, "bun"); + const finalRoot = path.join(bunRoot, expectedIdentity.revision); + const finalExecutable = path.join(finalRoot, "bun"); + if (await pathExists(finalRoot)) { + await assertInstalledRuntimeFile(finalExecutable); + const observed = await probe(finalExecutable); + if (!sameRuntimeIdentity(expectedIdentity, observed)) { + throw productionRuntimeFailure(); + } + return Object.freeze({ executable: finalExecutable, identity: observed }); + } + + const stageName = `.stage-${expectedIdentity.revision}-${Bun.randomUUIDv7()}`; + const stageRoot = path.join(bunRoot, stageName); + ownedRoot = stageRoot; + ownedName = stageName; + await mkdir(stageRoot, { mode: privateDirectoryMode }); + const stageExecutable = path.join(stageRoot, "bun"); + await copyRuntimeExecutable( + sourceExecutable, + stageExecutable, + dependencies.afterCopy + ); + const stagedIdentity = await probe(stageExecutable); + if (!sameRuntimeIdentity(expectedIdentity, stagedIdentity)) { + throw productionRuntimeFailure(); + } + await chmod(stageRoot, immutableDirectoryMode); + await rename(stageRoot, finalRoot); + ownedRoot = finalRoot; + ownedName = expectedIdentity.revision; + await assertInstalledRuntimeFile(finalExecutable); + const observed = await probe(finalExecutable); + if (!sameRuntimeIdentity(expectedIdentity, observed)) { + throw productionRuntimeFailure(); + } + ownedRoot = undefined; + ownedName = undefined; + return Object.freeze({ executable: finalExecutable, identity: observed }); + } catch { + if (ownedRoot && ownedName) { + try { + await removeOwnedRuntimeCandidate( + path.dirname(ownedRoot), + ownedRoot, + ownedName + ); + } catch { + // Preserve the fixed runtime-installation failure and bounded evidence. + } + } + throw productionRuntimeFailure(); + } +} + +/** + * Revalidates one immutable project-local Bun runtime before process execution. + * @param paths Exact prepared production delivery roots. + * @param runtime Previously installed runtime identity and executable. + * @param dependencies Injectable probe boundary for focused tests. + * @returns The exact observed identity after path and executable verification. + */ +export async function verifyInstalledProductionRuntime( + paths: PreparedProductionDeliveryPaths, + runtime: InstalledProductionRuntime, + dependencies: ProductionRuntimeVerificationDependencies = {} +): Promise { + try { + if ( + !v.is(runtimeIdentitySchema, runtime.identity) || + runtime.executable !== + path.join( + paths.runtimesDirectory, + "bun", + runtime.identity.revision, + "bun" + ) + ) { + throw productionRuntimeFailure(); + } + await assertPrivateRuntimeRoot(paths.runtimesDirectory); + await assertInstalledRuntimeFile(runtime.executable); + const observed = await (dependencies.probeRuntime ?? probeProductionRuntime)( + runtime.executable + ); + if (!sameRuntimeIdentity(runtime.identity, observed)) { + throw productionRuntimeFailure(); + } + return observed; + } catch { + throw productionRuntimeFailure(); + } +} + +/** + * Reconstructs and verifies one installed runtime named by immutable activation state. + * @param paths Exact prepared production delivery roots. + * @param identity Runtime identity from the verified production release manifest. + * @param dependencies Injectable probe boundary for focused tests. + * @returns Verified installed runtime executable and identity. + */ +export async function loadInstalledProductionRuntime( + paths: PreparedProductionDeliveryPaths, + identity: ReleaseRuntimeIdentity, + dependencies: ProductionRuntimeVerificationDependencies = {} +): Promise { + const runtime = Object.freeze({ + executable: path.join(paths.runtimesDirectory, "bun", identity.revision, "bun"), + identity, + }); + await verifyInstalledProductionRuntime(paths, runtime, dependencies); + return runtime; +} diff --git a/greenfield/scripts/delivery/productionRuntimePointers.ts b/greenfield/scripts/delivery/productionRuntimePointers.ts new file mode 100644 index 000000000..2bc5005f8 --- /dev/null +++ b/greenfield/scripts/delivery/productionRuntimePointers.ts @@ -0,0 +1,224 @@ +import { constants, type BigIntStats } from "node:fs"; +import { + lstat, + open, + readlink, + realpath, + rename, + symlink, + unlink, + type FileHandle, +} from "node:fs/promises"; +import path from "node:path"; + +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; +import type { PublishedProductionRelease } from "./productionReleasePublication.ts"; +import type { InstalledProductionRuntime } from "./productionRuntime.ts"; + +const runtimePointerFailureMessage = "Production runtime pointer update failed"; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; + +interface OpenedDirectory { + readonly device: bigint; + readonly handle: FileHandle; + readonly inode: bigint; + readonly path: string; +} + +function pointerFailure(): Error { + return new Error(runtimePointerFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function validPrivateDirectory(status: BigIntStats): boolean { + return ( + typeof process.getuid === "function" && + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(process.getuid()) && + (status.mode & 0o7777n) === 0o700n + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function openPrivateDirectory(directory: string): Promise { + if (process.platform !== "linux") throw pointerFailure(); + let handle: FileHandle | undefined; + try { + handle = await open(directory, directoryFlags); + const [held, after, canonical] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(directory, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + if ( + canonical !== directory || + !validPrivateDirectory(held) || + !validPrivateDirectory(after) || + after.dev !== held.dev || + after.ino !== held.ino + ) { + throw pointerFailure(); + } + return Object.freeze({ + device: held.dev, + handle, + inode: held.ino, + path: directory, + }); + } catch { + await closeHandle(handle); + throw pointerFailure(); + } +} + +async function revalidateDirectory(directory: OpenedDirectory): Promise { + const [held, current, canonical] = await Promise.all([ + directory.handle.stat({ bigint: true }), + lstat(directory.path, { bigint: true }), + realpath(`/proc/self/fd/${directory.handle.fd}`), + ]); + if ( + canonical !== directory.path || + !validPrivateDirectory(held) || + !validPrivateDirectory(current) || + held.dev !== directory.device || + held.ino !== directory.inode || + current.dev !== directory.device || + current.ino !== directory.inode + ) { + throw pointerFailure(); + } +} + +async function validateExistingPointer( + descriptorRoot: string, + pointerName: string +): Promise { + const pointerPath = path.join(descriptorRoot, pointerName); + try { + const status = await lstat(pointerPath, { bigint: true }); + if ( + typeof process.getuid !== "function" || + !status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) + ) { + throw pointerFailure(); + } + } catch (error) { + if (errorCode(error) !== "ENOENT") throw pointerFailure(); + } +} + +async function replaceRelativePointer( + directory: OpenedDirectory, + targetName: string +): Promise { + const descriptorRoot = `/proc/self/fd/${directory.handle.fd}`; + const pointerName = "current"; + const pointerPath = path.join(descriptorRoot, pointerName); + const stageName = `.current-${Bun.randomUUIDv7()}`; + const stagePath = path.join(descriptorRoot, stageName); + let stageOwned = false; + try { + if ( + targetName.length !== 40 || + targetName !== targetName.toLowerCase() || + /[^0-9a-f]/u.test(targetName) + ) { + throw pointerFailure(); + } + await validateExistingPointer(descriptorRoot, pointerName); + await symlink(targetName, stagePath, "dir"); + stageOwned = true; + const stageStatus = await lstat(stagePath, { bigint: true }); + if ( + typeof process.getuid !== "function" || + !stageStatus.isSymbolicLink() || + stageStatus.uid !== BigInt(process.getuid()) || + (await readlink(stagePath)) !== targetName + ) { + throw pointerFailure(); + } + await rename(stagePath, pointerPath); + stageOwned = false; + await directory.handle.sync(); + const pointerStatus = await lstat(pointerPath, { bigint: true }); + if ( + !pointerStatus.isSymbolicLink() || + pointerStatus.uid !== BigInt(process.getuid()) || + (await readlink(pointerPath)) !== targetName || + (await realpath(pointerPath)) !== path.join(directory.path, targetName) + ) { + throw pointerFailure(); + } + await revalidateDirectory(directory); + } catch { + if (stageOwned) await unlink(stagePath).catch(() => null); + throw pointerFailure(); + } +} + +/** + * Updates the stopped-service release and Bun runtime pointers for one activation attempt. + * The activation journal remains authoritative across a crash between the two atomic renames. + * @param lease Active wider deployment lease. + * @param paths Exact project-local delivery paths. + * @param release Verified immutable release selected for process startup. + * @param runtime Verified installed Bun runtime selected by that release. + */ +export async function pointProductionProcessesAtRelease( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime +): Promise { + const releaseId = release.manifest.source.commitSha; + const runtimeRevision = runtime.identity.revision; + const bunRoot = path.join(paths.runtimesDirectory, "bun"); + if ( + lease.stateDirectory !== paths.stateDirectory || + release.releaseRoot !== path.join(paths.releasesDirectory, releaseId) || + runtime.executable !== path.join(bunRoot, runtimeRevision, "bun") || + release.manifest.runtime.revision !== runtimeRevision || + release.manifest.runtime.version !== runtime.identity.version + ) { + throw pointerFailure(); + } + const releases = await openPrivateDirectory(paths.releasesDirectory); + let runtimes: OpenedDirectory | undefined; + let failed = false; + try { + runtimes = await openPrivateDirectory(bunRoot); + await replaceRelativePointer(releases, releaseId); + await replaceRelativePointer(runtimes, runtimeRevision); + await revalidateDirectory(releases); + await revalidateDirectory(runtimes); + } catch { + failed = true; + } + const [releasesClosed, runtimesClosed] = await Promise.all([ + closeHandle(releases.handle), + closeHandle(runtimes?.handle), + ]); + if (failed || !releasesClosed || !runtimesClosed) throw pointerFailure(); +} diff --git a/greenfield/scripts/delivery/productionStateFilesystem.test.ts b/greenfield/scripts/delivery/productionStateFilesystem.test.ts new file mode 100644 index 000000000..1ee3e3ad2 --- /dev/null +++ b/greenfield/scripts/delivery/productionStateFilesystem.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdir, mkdtemp, rename, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + prepareProtectedProductionStatePath, + ProductionStateFilesystemError, +} from "./productionStateFilesystem.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map(async (directory) => { + await chmod(directory, 0o700).catch(() => {}); + await rm(directory, { force: true, recursive: true }); + }) + ); +}); + +async function createProjectRoot(): Promise<{ parent: string; root: string }> { + const parent = await mkdtemp(path.join(tmpdir(), "mira-production-state-")); + temporaryDirectories.push(parent); + const root = path.join(parent, "dashboard"); + await mkdir(root, { mode: 0o755 }); + await chmod(root, 0o755); + return { parent, root }; +} + +async function permissionMode(directory: string): Promise { + const status = await lstat(directory, { bigint: true }); + return Number(status.mode & 0o7777n); +} + +async function expectFilesystemRejection(operation: Promise): Promise { + try { + await operation; + } catch (error) { + expect(error).toBeInstanceOf(ProductionStateFilesystemError); + return; + } + throw new Error("Expected protected filesystem preparation to reject"); +} + +describe("production state filesystem", () => { + test("creates only private project-local state and narrows writable ancestors", async () => { + const { parent, root } = await createProjectRoot(); + await chmod(parent, 0o775); + + const prepared = await prepareProtectedProductionStatePath(root); + + expect(prepared).toEqual({ + backupsDirectory: path.join(root, "production/state/backups"), + jobOutputDirectory: path.join(root, "production/state/job-output"), + logsDirectory: path.join(root, "production/state/logs"), + productionDirectory: path.join(root, "production"), + projectRoot: root, + stateDirectory: path.join(root, "production/state"), + }); + expect(await permissionMode(parent)).toBe(0o755); + expect(await permissionMode(root)).toBe(0o755); + expect(await permissionMode(prepared.productionDirectory)).toBe(0o700); + expect(await permissionMode(prepared.stateDirectory)).toBe(0o700); + expect(await permissionMode(prepared.backupsDirectory)).toBe(0o700); + expect(await permissionMode(prepared.jobOutputDirectory)).toBe(0o700); + expect(await permissionMode(prepared.logsDirectory)).toBe(0o700); + }); + + test("narrows existing managed directories without broadening permissions", async () => { + const { root } = await createProjectRoot(); + const production = path.join(root, "production"); + await mkdir(production, { mode: 0o755 }); + await chmod(production, 0o755); + + const prepared = await prepareProtectedProductionStatePath(root); + + expect(await permissionMode(prepared.productionDirectory)).toBe(0o700); + expect(await prepareProtectedProductionStatePath(root)).toEqual(prepared); + }); + + test("rejects a managed directory that private-mode repair would broaden", async () => { + const { root } = await createProjectRoot(); + const production = path.join(root, "production"); + await mkdir(production, { mode: 0o600 }); + await chmod(production, 0o600); + + await expectFilesystemRejection(prepareProtectedProductionStatePath(root)); + expect(await permissionMode(production)).toBe(0o600); + }); + + test("rejects noncanonical and symlinked project roots", async () => { + const { parent, root } = await createProjectRoot(); + const link = path.join(parent, "dashboard-link"); + await symlink(root, link, "dir"); + + await expectFilesystemRejection(prepareProtectedProductionStatePath(`${root}/.`)); + await expectFilesystemRejection(prepareProtectedProductionStatePath(link)); + }); + + test("rejects a symlinked managed directory", async () => { + const { parent, root } = await createProjectRoot(); + const target = path.join(parent, "outside"); + await mkdir(target, { mode: 0o700 }); + await symlink(target, path.join(root, "production"), "dir"); + + await expectFilesystemRejection(prepareProtectedProductionStatePath(root)); + }); + + test("rejects a managed path swap after descriptor validation", async () => { + const { root } = await createProjectRoot(); + const production = path.join(root, "production"); + const displacedProduction = path.join(root, "displaced-production"); + let replaced = false; + + await expectFilesystemRejection( + prepareProtectedProductionStatePath(root, { + afterStage: async (stage, directory) => { + if ( + !replaced && + stage === "managed-directory-prepared" && + directory === production + ) { + replaced = true; + await rename(production, displacedProduction); + await mkdir(production, { mode: 0o700 }); + await chmod(production, 0o700); + } + }, + }) + ); + expect(replaced).toBe(true); + }); + + test("rejects a final managed-child swap before returning its path", async () => { + const { root } = await createProjectRoot(); + const logs = path.join(root, "production/state/logs"); + const displacedLogs = path.join(root, "production/state/displaced-logs"); + let replaced = false; + + await expectFilesystemRejection( + prepareProtectedProductionStatePath(root, { + afterStage: async (stage, directory) => { + if ( + !replaced && + stage === "managed-directory-prepared" && + directory === logs + ) { + replaced = true; + await rename(logs, displacedLogs); + await mkdir(logs, { mode: 0o700 }); + await chmod(logs, 0o700); + } + }, + }) + ); + expect(replaced).toBe(true); + }); + + test("rejects an ancestor identity swap before state creation", async () => { + const { parent, root } = await createProjectRoot(); + const displacedRoot = path.join(parent, "displaced-dashboard"); + let replaced = false; + + await expectFilesystemRejection( + prepareProtectedProductionStatePath(root, { + afterStage: async (stage, directory) => { + if ( + !replaced && + stage === "ancestor-protected" && + directory === root + ) { + replaced = true; + await rename(root, displacedRoot); + await mkdir(root, { mode: 0o700 }); + await chmod(root, 0o700); + } + }, + }) + ); + expect(replaced).toBe(true); + }); +}); diff --git a/greenfield/scripts/delivery/productionStateFilesystem.ts b/greenfield/scripts/delivery/productionStateFilesystem.ts new file mode 100644 index 000000000..299c41695 --- /dev/null +++ b/greenfield/scripts/delivery/productionStateFilesystem.ts @@ -0,0 +1,536 @@ +import { constants, type BigIntStats } from "node:fs"; +import { type FileHandle, lstat, mkdir, open, realpath } from "node:fs/promises"; +import path from "node:path"; + +const directoryOpenFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const permissionBits = 0o7777n; +const privateDirectoryMode = 0o700; +const privateDirectoryModeBigInt = 0o700n; +const otherPrincipalWriteBits = 0o022n; +const stickyBit = 0o1000n; + +const productionDirectoryName = "production"; +const stateDirectoryName = "state"; +const stateChildDirectoryNames = Object.freeze([ + "backups", + "job-output", + "logs", +] as const); + +/** Stable paths created beneath one canonical Dashboard project root. */ +export interface PreparedProductionStatePaths { + readonly backupsDirectory: string; + readonly jobOutputDirectory: string; + readonly logsDirectory: string; + readonly productionDirectory: string; + readonly projectRoot: string; + readonly stateDirectory: string; +} + +/** Deterministic mutation boundaries exposed only to adversarial tests. */ +export type ProductionStateFilesystemTestStage = + | "ancestor-protected" + | "managed-directory-prepared"; + +/** + * Deterministic test hook. Production delivery composition must leave this absent. + * @internal + */ +export interface ProductionStateFilesystemTestHooks { + readonly afterStage?: ( + stage: ProductionStateFilesystemTestStage, + directory: string + ) => Promise | void; +} + +/** Raised when project-local production state cannot be prepared safely. */ +export class ProductionStateFilesystemError extends Error { + override readonly name = "ProductionStateFilesystemError"; +} + +interface DirectoryIdentity { + readonly device: bigint; + readonly inode: bigint; +} + +interface OpenedDirectory { + readonly canonicalPath: string; + readonly descriptorPath: string; + readonly handle: FileHandle; + readonly identity: DirectoryIdentity; +} + +function invalidProductionStateFilesystem(): ProductionStateFilesystemError { + return new ProductionStateFilesystemError( + "Production state path violates the protected project-local filesystem policy" + ); +} + +function currentUserId(): number { + if (process.platform !== "linux" || typeof process.getuid !== "function") { + throw invalidProductionStateFilesystem(); + } + return process.getuid(); +} + +function descriptorPath(handle: FileHandle): string { + return `/proc/self/fd/${handle.fd}`; +} + +function identityOf(stat: BigIntStats): DirectoryIdentity { + return Object.freeze({ device: stat.dev, inode: stat.ino }); +} + +function hasIdentity(stat: BigIntStats, identity: DirectoryIdentity): boolean { + return stat.dev === identity.device && stat.ino === identity.inode; +} + +function isMissingPathFailure(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + try { + const descriptor = Object.getOwnPropertyDescriptor(error, "code"); + return descriptor !== undefined && "value" in descriptor + ? descriptor.value === "ENOENT" + : false; + } catch { + return false; + } +} + +function isExistingPathFailure(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + try { + const descriptor = Object.getOwnPropertyDescriptor(error, "code"); + return descriptor !== undefined && "value" in descriptor + ? descriptor.value === "EEXIST" + : false; + } catch { + return false; + } +} + +function isDirectChild(parent: string, child: string, childName: string): boolean { + return path.dirname(child) === parent && path.basename(child) === childName; +} + +function isTrustedOwner(ownerId: bigint, userId: number): boolean { + return ownerId === 0n || ownerId === BigInt(userId); +} + +function isProtectedAncestor( + stat: BigIntStats, + childOwnerId: bigint, + userId: number +): boolean { + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + !isTrustedOwner(stat.uid, userId) + ) { + return false; + } + if ((stat.mode & otherPrincipalWriteBits) === 0n) return true; + return (stat.mode & stickyBit) !== 0n && isTrustedOwner(childOwnerId, userId); +} + +function isPrivateDirectory(stat: BigIntStats, userId: number): boolean { + return ( + stat.isDirectory() && + !stat.isSymbolicLink() && + stat.uid === BigInt(userId) && + (stat.mode & permissionBits) === privateDirectoryModeBigInt + ); +} + +async function openStableDirectory( + requestedPath: string, + expectedCanonicalPath: string, + resources: FileHandle[] +): Promise { + try { + const handle = await open(requestedPath, directoryOpenFlags); + resources.push(handle); + const heldDescriptorPath = descriptorPath(handle); + const [snapshot, canonicalPath, afterOpen] = await Promise.all([ + handle.stat({ bigint: true }), + realpath(heldDescriptorPath), + lstat(requestedPath, { bigint: true }), + ]); + const identity = identityOf(snapshot); + if ( + !snapshot.isDirectory() || + snapshot.isSymbolicLink() || + canonicalPath !== expectedCanonicalPath || + !hasIdentity(afterOpen, identity) + ) { + throw invalidProductionStateFilesystem(); + } + return { + canonicalPath, + descriptorPath: heldDescriptorPath, + handle, + identity, + }; + } catch (error) { + if (error instanceof ProductionStateFilesystemError) throw error; + throw invalidProductionStateFilesystem(); + } +} + +async function entryStillMatches(directory: OpenedDirectory): Promise { + let pathHandle: FileHandle | undefined; + let matches: boolean | undefined; + try { + pathHandle = await open(directory.canonicalPath, directoryOpenFlags); + const pathDescriptor = descriptorPath(pathHandle); + const [heldStat, pathStat, canonicalPath, afterOpen] = await Promise.all([ + directory.handle.stat({ bigint: true }), + pathHandle.stat({ bigint: true }), + realpath(pathDescriptor), + lstat(directory.canonicalPath, { bigint: true }), + ]); + matches = + heldStat.isDirectory() && + pathStat.isDirectory() && + !afterOpen.isSymbolicLink() && + canonicalPath === directory.canonicalPath && + hasIdentity(heldStat, directory.identity) && + hasIdentity(pathStat, directory.identity) && + hasIdentity(afterOpen, directory.identity); + } catch { + matches = false; + } + if (pathHandle) { + try { + await pathHandle.close(); + } catch { + matches = false; + } + } + return matches ?? false; +} + +async function protectAncestor( + directory: OpenedDirectory, + childOwnerId: bigint, + userId: number +): Promise { + const before = await directory.handle.stat({ bigint: true }); + if ( + !hasIdentity(before, directory.identity) || + !before.isDirectory() || + before.isSymbolicLink() || + !isTrustedOwner(before.uid, userId) + ) { + throw invalidProductionStateFilesystem(); + } + + const writableByAnotherPrincipal = (before.mode & otherPrincipalWriteBits) !== 0n; + const sticky = (before.mode & stickyBit) !== 0n; + if (writableByAnotherPrincipal && !sticky) { + if (before.uid !== BigInt(userId)) { + throw invalidProductionStateFilesystem(); + } + const currentMode = before.mode & permissionBits; + const protectedMode = currentMode & ~otherPrincipalWriteBits; + await directory.handle.chmod(Number(protectedMode)); + } + + const after = await directory.handle.stat({ bigint: true }); + if ( + !hasIdentity(after, directory.identity) || + after.uid !== before.uid || + !isProtectedAncestor(after, childOwnerId, userId) || + !(await entryStillMatches(directory)) + ) { + throw invalidProductionStateFilesystem(); + } + return after.uid; +} + +async function protectAncestorChain( + projectRoot: string, + userId: number, + resources: FileHandle[], + testHooks?: ProductionStateFilesystemTestHooks +): Promise { + const directories: OpenedDirectory[] = []; + let currentPath = projectRoot; + let childOwnerId = BigInt(userId); + + while (true) { + const directory = await openStableDirectory(currentPath, currentPath, resources); + directories.push(directory); + const ownerId = await protectAncestor(directory, childOwnerId, userId); + if (directories.length === 1 && ownerId !== BigInt(userId)) { + throw invalidProductionStateFilesystem(); + } + await testHooks?.afterStage?.("ancestor-protected", currentPath); + childOwnerId = ownerId; + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) break; + currentPath = parentPath; + } + + await revalidateAncestorChain(directories, userId); + return directories; +} + +async function revalidateAncestorChain( + directories: readonly OpenedDirectory[], + userId: number +): Promise { + let childOwnerId = BigInt(userId); + for (const [index, directory] of directories.entries()) { + const stat = await directory.handle.stat({ bigint: true }); + if ( + !hasIdentity(stat, directory.identity) || + !isProtectedAncestor(stat, childOwnerId, userId) || + (index === 0 && stat.uid !== BigInt(userId)) || + !(await entryStillMatches(directory)) + ) { + throw invalidProductionStateFilesystem(); + } + childOwnerId = stat.uid; + } +} + +async function createDirectoryIfMissing( + parent: OpenedDirectory, + childName: string +): Promise { + const anchoredPath = path.join(parent.descriptorPath, childName); + try { + await lstat(anchoredPath, { bigint: true }); + return false; + } catch (error) { + if (!isMissingPathFailure(error)) { + throw invalidProductionStateFilesystem(); + } + } + + try { + await mkdir(anchoredPath, { mode: privateDirectoryMode }); + return true; + } catch (error) { + if (isExistingPathFailure(error)) return false; + throw invalidProductionStateFilesystem(); + } +} + +async function openManagedChild( + parent: OpenedDirectory, + childName: string, + resources: FileHandle[] +): Promise<{ readonly created: boolean; readonly directory: OpenedDirectory }> { + const created = await createDirectoryIfMissing(parent, childName); + const canonicalPath = path.join(parent.canonicalPath, childName); + const directory = await openStableDirectory( + path.join(parent.descriptorPath, childName), + canonicalPath, + resources + ); + const stat = await directory.handle.stat({ bigint: true }); + if ( + stat.dev !== parent.identity.device || + !isDirectChild(parent.canonicalPath, canonicalPath, childName) + ) { + throw invalidProductionStateFilesystem(); + } + return { created, directory }; +} + +async function prepareProductionDirectory( + projectRoot: OpenedDirectory, + userId: number, + resources: FileHandle[], + testHooks?: ProductionStateFilesystemTestHooks +): Promise { + const { created, directory } = await openManagedChild( + projectRoot, + productionDirectoryName, + resources + ); + const before = await directory.handle.stat({ bigint: true }); + if (before.uid !== BigInt(userId)) { + throw invalidProductionStateFilesystem(); + } + if ( + !created && + (before.mode & privateDirectoryModeBigInt) !== privateDirectoryModeBigInt + ) { + throw invalidProductionStateFilesystem(); + } + await directory.handle.chmod(privateDirectoryMode); + const after = await directory.handle.stat({ bigint: true }); + if ( + !hasIdentity(after, directory.identity) || + after.uid !== BigInt(userId) || + !isPrivateDirectory(after, userId) || + !(await entryStillMatches(directory)) + ) { + throw invalidProductionStateFilesystem(); + } + await testHooks?.afterStage?.("managed-directory-prepared", directory.canonicalPath); + return directory; +} + +async function preparePrivateDirectory( + parent: OpenedDirectory, + childName: string, + userId: number, + resources: FileHandle[], + testHooks?: ProductionStateFilesystemTestHooks +): Promise { + const { created, directory } = await openManagedChild(parent, childName, resources); + const before = await directory.handle.stat({ bigint: true }); + if ( + before.uid !== BigInt(userId) || + (!created && + (before.mode & privateDirectoryModeBigInt) !== privateDirectoryModeBigInt) + ) { + throw invalidProductionStateFilesystem(); + } + await directory.handle.chmod(privateDirectoryMode); + const after = await directory.handle.stat({ bigint: true }); + if ( + !hasIdentity(after, directory.identity) || + after.uid !== before.uid || + !isPrivateDirectory(after, userId) || + !(await entryStillMatches(directory)) + ) { + throw invalidProductionStateFilesystem(); + } + await testHooks?.afterStage?.("managed-directory-prepared", directory.canonicalPath); + return directory; +} + +async function validatePrivateDirectory( + directory: OpenedDirectory, + userId: number +): Promise { + const stat = await directory.handle.stat({ bigint: true }); + if ( + !hasIdentity(stat, directory.identity) || + !isPrivateDirectory(stat, userId) || + !(await entryStillMatches(directory)) + ) { + throw invalidProductionStateFilesystem(); + } +} + +async function closeResources(resources: readonly FileHandle[]): Promise { + let closed = true; + for (const handle of resources.toReversed()) { + try { + await handle.close(); + } catch { + closed = false; + } + } + return closed; +} + +function validateProjectRootInput(projectRoot: string): void { + if ( + !path.isAbsolute(projectRoot) || + projectRoot.includes("\0") || + path.resolve(projectRoot) !== projectRoot || + path.parse(projectRoot).root === projectRoot + ) { + throw invalidProductionStateFilesystem(); + } +} + +/** + * Creates and verifies private production state beneath one explicit project root. + * Existing current-user-owned non-sticky ancestors are only made less writable; + * application runtime startup must validate the result without calling this helper. + * @param projectRoot Canonical Dashboard project root, not a checkout directory. + * @param testHooks Deterministic adversarial hooks used only by tests. + * @returns Canonical project-local production state paths. + */ +export async function prepareProtectedProductionStatePath( + projectRoot: string, + testHooks?: ProductionStateFilesystemTestHooks +): Promise { + validateProjectRootInput(projectRoot); + const userId = currentUserId(); + const resources: FileHandle[] = []; + let preparedPaths: PreparedProductionStatePaths | undefined; + let failure: unknown; + + try { + const ancestors = await protectAncestorChain( + projectRoot, + userId, + resources, + testHooks + ); + const canonicalProjectRoot = ancestors[0]; + if (!canonicalProjectRoot) throw invalidProductionStateFilesystem(); + + const production = await prepareProductionDirectory( + canonicalProjectRoot, + userId, + resources, + testHooks + ); + const state = await preparePrivateDirectory( + production, + stateDirectoryName, + userId, + resources, + testHooks + ); + const stateChildren = new Map(); + for (const childName of stateChildDirectoryNames) { + stateChildren.set( + childName, + await preparePrivateDirectory( + state, + childName, + userId, + resources, + testHooks + ) + ); + } + + await revalidateAncestorChain(ancestors, userId); + await protectAncestor(production, BigInt(userId), userId); + await validatePrivateDirectory(state, userId); + for (const child of stateChildren.values()) { + await validatePrivateDirectory(child, userId); + } + + const backups = stateChildren.get("backups"); + const jobOutput = stateChildren.get("job-output"); + const logs = stateChildren.get("logs"); + if (!backups || !jobOutput || !logs) { + throw invalidProductionStateFilesystem(); + } + preparedPaths = Object.freeze({ + backupsDirectory: backups.canonicalPath, + jobOutputDirectory: jobOutput.canonicalPath, + logsDirectory: logs.canonicalPath, + productionDirectory: production.canonicalPath, + projectRoot: canonicalProjectRoot.canonicalPath, + stateDirectory: state.canonicalPath, + }); + } catch (error) { + failure = error; + } + + const closed = await closeResources(resources); + if (failure instanceof ProductionStateFilesystemError) throw failure; + if (failure !== undefined || !closed || !preparedPaths) { + throw invalidProductionStateFilesystem(); + } + return preparedPaths; +} diff --git a/greenfield/scripts/delivery/productionSystemdUnitFilesystem.ts b/greenfield/scripts/delivery/productionSystemdUnitFilesystem.ts new file mode 100644 index 000000000..b22b8920a --- /dev/null +++ b/greenfield/scripts/delivery/productionSystemdUnitFilesystem.ts @@ -0,0 +1,397 @@ +import { constants, type BigIntStats } from "node:fs"; +import { + lstat, + mkdir, + open, + realpath, + rename, + unlink, + type FileHandle, +} from "node:fs/promises"; +import path from "node:path"; + +import { productionSystemdUnits } from "./productionSystemdUnitPolicy.ts"; + +const unitFilesystemFailureMessage = "Production systemd unit installation failed"; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const temporaryFileFlags = + constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR; +const sourceFileFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +const privateFileMode = 0o600; +const maximumUnitBytes = 64 * 1024; + +/** Manifest-verified bytes for one fixed Dashboard user unit. */ +export interface ProductionSystemdUnitFile { + readonly bytes: Uint8Array; + readonly fileName: (typeof productionSystemdUnits)[number]["fileName"]; + readonly sha256: string; +} + +/** Deterministic external-filesystem boundaries used only by adversarial tests. */ +export interface ProductionSystemdUnitFilesystemTestHooks { + readonly beforeRename?: (fileName: string) => Promise | void; +} + +interface OpenedDirectory { + readonly device: bigint; + readonly handle: FileHandle; + readonly inode: bigint; + readonly path: string; + readonly userId: number; +} + +interface ExistingFileSnapshot { + readonly device: bigint; + readonly inode: bigint; + readonly mode: bigint; + readonly size: bigint; + readonly userId: bigint; +} + +function unitFilesystemFailure(): Error { + return new Error(unitFilesystemFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function sameDirectoryIdentity(status: BigIntStats, directory: OpenedDirectory): boolean { + return status.dev === directory.device && status.ino === directory.inode; +} + +function validOwnedDirectory( + status: BigIntStats, + userId: number, + expectedDevice?: bigint +): boolean { + return ( + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(userId) && + (status.mode & 0o022n) === 0n && + (expectedDevice === undefined || status.dev === expectedDevice) + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function openOwnedDirectory( + openPath: string, + expectedPath: string, + userId: number, + expectedDevice?: bigint +): Promise { + let handle: FileHandle | undefined; + let opened: OpenedDirectory | undefined; + let failed = false; + try { + handle = await open(openPath, directoryFlags); + const [held, after, canonical] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(openPath, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + if ( + canonical !== expectedPath || + !validOwnedDirectory(held, userId, expectedDevice) || + !validOwnedDirectory(after, userId, expectedDevice) || + after.dev !== held.dev || + after.ino !== held.ino + ) { + throw unitFilesystemFailure(); + } + opened = Object.freeze({ + device: held.dev, + handle, + inode: held.ino, + path: expectedPath, + userId, + }); + } catch { + failed = true; + } + if (failed || !opened) { + await closeHandle(handle); + throw unitFilesystemFailure(); + } + return opened; +} + +async function prepareOwnedChild( + parent: OpenedDirectory, + childName: string +): Promise { + const anchoredPath = path.join(`/proc/self/fd/${parent.handle.fd}`, childName); + const expectedPath = path.join(parent.path, childName); + try { + await mkdir(anchoredPath, { mode: 0o700 }); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw unitFilesystemFailure(); + } + const child = await openOwnedDirectory( + anchoredPath, + expectedPath, + parent.userId, + parent.device + ); + const parentAfter = await parent.handle.stat({ bigint: true }); + if (!sameDirectoryIdentity(parentAfter, parent)) { + await closeHandle(child.handle); + throw unitFilesystemFailure(); + } + return child; +} + +function sha256(bytes: Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); +} + +function validateUnits(units: readonly ProductionSystemdUnitFile[]): void { + if ( + units.length !== productionSystemdUnits.length || + units.some((unit, index) => { + const expected = productionSystemdUnits[index]; + return ( + unit.fileName !== expected?.fileName || + unit.bytes.byteLength <= 0 || + unit.bytes.byteLength > maximumUnitBytes || + !/^[a-f\d]{64}$/u.test(unit.sha256) || + sha256(unit.bytes) !== unit.sha256 + ); + }) + ) { + throw unitFilesystemFailure(); + } +} + +function snapshotExistingFile( + status: BigIntStats, + directory: OpenedDirectory +): ExistingFileSnapshot { + if ( + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(directory.userId) || + status.dev !== directory.device || + (status.mode & 0o022n) !== 0n + ) { + throw unitFilesystemFailure(); + } + return Object.freeze({ + device: status.dev, + inode: status.ino, + mode: status.mode, + size: status.size, + userId: status.uid, + }); +} + +async function existingFileSnapshot( + anchoredPath: string, + directory: OpenedDirectory +): Promise { + try { + return snapshotExistingFile( + await lstat(anchoredPath, { bigint: true }), + directory + ); + } catch (error) { + if (errorCode(error) === "ENOENT") return undefined; + throw unitFilesystemFailure(); + } +} + +function sameExistingFile( + left: ExistingFileSnapshot | undefined, + right: ExistingFileSnapshot | undefined +): boolean { + if (!left || !right) return left === right; + return ( + left.device === right.device && + left.inode === right.inode && + left.mode === right.mode && + left.size === right.size && + left.userId === right.userId + ); +} + +async function readExactHeldFile( + anchoredPath: string, + expected: ProductionSystemdUnitFile, + directory: OpenedDirectory +): Promise { + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open(anchoredPath, sourceFileFlags); + const held = await handle.stat({ bigint: true }); + if ( + !held.isFile() || + held.isSymbolicLink() || + held.nlink !== 1n || + held.uid !== BigInt(directory.userId) || + held.dev !== directory.device || + held.size !== BigInt(expected.bytes.byteLength) || + (held.mode & 0o7777n) !== BigInt(privateFileMode) + ) { + throw unitFilesystemFailure(); + } + const contents = Buffer.alloc(expected.bytes.byteLength + 1); + let offset = 0; + while (offset < contents.byteLength) { + const result = await handle.read( + contents, + offset, + contents.byteLength - offset, + offset + ); + if (result.bytesRead === 0) break; + offset += result.bytesRead; + } + const [heldAfter, pathAfter] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(anchoredPath, { bigint: true }), + ]); + if ( + offset !== expected.bytes.byteLength || + heldAfter.dev !== held.dev || + heldAfter.ino !== held.ino || + heldAfter.size !== held.size || + pathAfter.dev !== held.dev || + pathAfter.ino !== held.ino || + sha256(contents.subarray(0, offset)) !== expected.sha256 + ) { + throw unitFilesystemFailure(); + } + } catch { + failed = true; + } + const closed = await closeHandle(handle); + if (failed || !closed) throw unitFilesystemFailure(); +} + +async function installUnitFile( + directory: OpenedDirectory, + unit: ProductionSystemdUnitFile, + testHooks: ProductionSystemdUnitFilesystemTestHooks +): Promise { + const descriptorRoot = `/proc/self/fd/${directory.handle.fd}`; + const destination = path.join(descriptorRoot, unit.fileName); + const temporaryName = `.${unit.fileName}.${Bun.randomUUIDv7()}.tmp`; + const temporary = path.join(descriptorRoot, temporaryName); + const existing = await existingFileSnapshot(destination, directory); + let temporaryHandle: FileHandle | undefined; + let renamed = false; + let failed = false; + try { + temporaryHandle = await open(temporary, temporaryFileFlags, privateFileMode); + await temporaryHandle.writeFile(unit.bytes); + await temporaryHandle.sync(); + const temporaryStatus = await temporaryHandle.stat({ bigint: true }); + if ( + !temporaryStatus.isFile() || + temporaryStatus.nlink !== 1n || + temporaryStatus.uid !== BigInt(directory.userId) || + temporaryStatus.dev !== directory.device || + temporaryStatus.size !== BigInt(unit.bytes.byteLength) || + (temporaryStatus.mode & 0o7777n) !== BigInt(privateFileMode) + ) { + throw unitFilesystemFailure(); + } + if (!(await closeHandle(temporaryHandle))) throw unitFilesystemFailure(); + temporaryHandle = undefined; + await testHooks.beforeRename?.(unit.fileName); + const current = await existingFileSnapshot(destination, directory); + if (!sameExistingFile(existing, current)) throw unitFilesystemFailure(); + await rename(temporary, destination); + renamed = true; + await directory.handle.sync(); + await readExactHeldFile(destination, unit, directory); + } catch { + failed = true; + } + if (!(await closeHandle(temporaryHandle))) failed = true; + if (!renamed) { + try { + await unlink(temporary); + } catch (error) { + if (errorCode(error) !== "ENOENT") failed = true; + } + } + if (failed) throw unitFilesystemFailure(); +} + +/** + * Atomically installs the two manifest-verified user units below one protected home. + * @param homeDirectory Canonical current-user home selected by the caller. + * @param userUnitDirectory Exact `/.config/systemd/user` destination. + * @param units Exact ordered Dashboard unit bytes and hashes. + * @param testHooks Deterministic adversarial mutation boundary. + */ +export async function installProductionSystemdUnitFiles( + homeDirectory: string, + userUnitDirectory: string, + units: readonly ProductionSystemdUnitFile[], + testHooks: ProductionSystemdUnitFilesystemTestHooks = {} +): Promise { + if ( + process.platform !== "linux" || + typeof process.getuid !== "function" || + !path.isAbsolute(homeDirectory) || + path.resolve(homeDirectory) !== homeDirectory || + path.parse(homeDirectory).root === homeDirectory || + userUnitDirectory !== path.join(homeDirectory, ".config/systemd/user") + ) { + throw unitFilesystemFailure(); + } + validateUnits(units); + const userId = process.getuid(); + const opened: OpenedDirectory[] = []; + let failed = false; + try { + const home = await openOwnedDirectory(homeDirectory, homeDirectory, userId); + opened.push(home); + let current = home; + for (const childName of [".config", "systemd", "user"]) { + current = await prepareOwnedChild(current, childName); + opened.push(current); + } + if (current.path !== userUnitDirectory) throw unitFilesystemFailure(); + for (const unit of units) { + await installUnitFile(current, unit, testHooks); + } + const [heldAfter, pathAfter] = await Promise.all([ + current.handle.stat({ bigint: true }), + lstat(userUnitDirectory, { bigint: true }), + ]); + if ( + !sameDirectoryIdentity(heldAfter, current) || + !sameDirectoryIdentity(pathAfter, current) || + !validOwnedDirectory(pathAfter, userId, current.device) + ) { + throw unitFilesystemFailure(); + } + } catch { + failed = true; + } + for (const directory of opened.toReversed()) { + if (!(await closeHandle(directory.handle))) failed = true; + } + if (failed) throw unitFilesystemFailure(); +} diff --git a/greenfield/scripts/delivery/productionSystemdUnitPolicy.ts b/greenfield/scripts/delivery/productionSystemdUnitPolicy.ts new file mode 100644 index 000000000..90245f344 --- /dev/null +++ b/greenfield/scripts/delivery/productionSystemdUnitPolicy.ts @@ -0,0 +1,14 @@ +/** Exact unit artifacts admitted into one immutable Dashboard release. */ +export const productionSystemdUnits = Object.freeze([ + Object.freeze({ + artifactPath: "systemd/mira-dashboard-web.service", + fileName: "mira-dashboard-web.service", + }), + Object.freeze({ + artifactPath: "systemd/mira-dashboard-worker.service", + fileName: "mira-dashboard-worker.service", + }), +] as const); + +/** Current host-native project location represented by the reviewed unit sources. */ +export const productionProjectHomeRelativePath = "projects/mira-dashboard"; diff --git a/greenfield/scripts/delivery/releaseArtifactInventory.test.ts b/greenfield/scripts/delivery/releaseArtifactInventory.test.ts new file mode 100644 index 000000000..35facd6ad --- /dev/null +++ b/greenfield/scripts/delivery/releaseArtifactInventory.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { link, mkdir, mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { inventoryReleaseArtifactTree } from "./releaseArtifactInventory.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function releaseTree(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "mira-release-tree-")); + temporaryDirectories.push(root); + await mkdir(path.join(root, "browser")); + await mkdir(path.join(root, "server")); + await writeFile(path.join(root, "browser/index.html"), "dashboard"); + await writeFile(path.join(root, "server/web.js"), "web"); + await writeFile(path.join(root, "server/worker.js"), "worker"); + return root; +} + +describe("release artifact inventory", () => { + test("returns a stable sorted identity for every regular artifact", async () => { + const root = await releaseTree(); + + const inventory = await inventoryReleaseArtifactTree(root); + + expect( + inventory.map(({ bytes, path: artifactPath }) => ({ + bytes, + path: artifactPath, + })) + ).toEqual([ + { bytes: 9, path: "browser/index.html" }, + { bytes: 3, path: "server/web.js" }, + { bytes: 6, path: "server/worker.js" }, + ]); + expect(inventory.every(({ sha256 }) => /^[a-f\d]{64}$/u.test(sha256))).toBe(true); + expect(Object.isFrozen(inventory)).toBe(true); + expect(inventory.every((record) => Object.isFrozen(record))).toBe(true); + }); + + test("rejects symlinks, hardlinks, empty files and noncanonical names", async () => { + for (const invalidKind of ["symlink", "hardlink", "empty", "name"] as const) { + const root = await releaseTree(); + const target = path.join(root, "browser/index.html"); + if (invalidKind === "symlink") { + await symlink(target, path.join(root, "linked.html")); + } else if (invalidKind === "hardlink") { + await link(target, path.join(root, "linked.html")); + } else if (invalidKind === "empty") { + await writeFile(path.join(root, "empty.txt"), ""); + } else { + await writeFile(path.join(root, "not canonical.txt"), "invalid"); + } + const failure = await rejectionError(inventoryReleaseArtifactTree(root)); + expect(failure.message).toBe("Release artifact tree is invalid"); + } + }); + + test("rejects a path replacement after file verification", async () => { + const root = await releaseTree(); + const target = path.join(root, "browser/index.html"); + const displaced = path.join(root, "browser/displaced.html"); + let replaced = false; + + const failure = await rejectionError( + inventoryReleaseArtifactTree(root, { + afterFileRead: async (relativePath) => { + if (!replaced && relativePath === "browser/index.html") { + replaced = true; + await rename(target, displaced); + await writeFile(target, "replacement"); + } + }, + }) + ); + expect(failure.message).toBe("Release artifact tree is invalid"); + expect(replaced).toBe(true); + }); +}); diff --git a/greenfield/scripts/delivery/releaseArtifactInventory.ts b/greenfield/scripts/delivery/releaseArtifactInventory.ts new file mode 100644 index 000000000..7d1990d44 --- /dev/null +++ b/greenfield/scripts/delivery/releaseArtifactInventory.ts @@ -0,0 +1,222 @@ +import type { BigIntStats, Dirent } from "node:fs"; +import { lstat, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { readBoundedRegularFile } from "../files/boundedFile.ts"; + +const invalidArtifactTreeMessage = "Release artifact tree is invalid"; +export const maximumReleaseArtifactBytes = 64 * 1024 * 1024; +const maximumArtifactCount = 4096; +const maximumArtifactDirectoryCount = 512; +const maximumArtifactDepth = 16; +const maximumArtifactTreeBytes = 512 * 1024 * 1024; +const artifactPathSegmentPattern = /^[A-Za-z0-9._+-]+$/u; + +function compareCanonicalText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +/** Immutable content identity for one regular file in a staged release. */ +export interface ReleaseArtifactInventoryRecord { + readonly bytes: number; + readonly path: string; + readonly sha256: string; +} + +/** Deterministic mutation boundary used only by adversarial tests. */ +export interface ReleaseArtifactInventoryTestHooks { + readonly afterFileRead?: (relativePath: string) => Promise | void; +} + +interface DirectorySnapshot { + readonly entries: readonly string[]; + readonly status: BigIntStats; +} + +function invalidArtifactTree(): Error { + return new Error(invalidArtifactTreeMessage); +} + +function matchesDirectorySnapshot(before: BigIntStats, after: BigIntStats): boolean { + return ( + after.isDirectory() && + !after.isSymbolicLink() && + after.dev === before.dev && + after.ino === before.ino && + after.ctimeNs === before.ctimeNs && + after.mtimeNs === before.mtimeNs + ); +} + +function matchesFileSnapshot(before: BigIntStats, after: BigIntStats): boolean { + return ( + after.isFile() && + !after.isSymbolicLink() && + after.nlink === 1n && + after.dev === before.dev && + after.ino === before.ino && + after.size === before.size && + after.ctimeNs === before.ctimeNs && + after.mtimeNs === before.mtimeNs + ); +} + +function entrySignature(entry: Dirent): string { + if (entry.isDirectory()) return `d:${entry.name}`; + if (entry.isFile()) return `f:${entry.name}`; + return `x:${entry.name}`; +} + +async function directorySnapshot(directory: string): Promise { + const status = await lstat(directory, { bigint: true }); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw invalidArtifactTree(); + } + const directoryEntries = await readdir(directory, { withFileTypes: true }); + const entries = directoryEntries + .map((entry) => entrySignature(entry)) + .toSorted((left, right) => compareCanonicalText(left, right)); + return { entries, status }; +} + +function validPathSegment(segment: string): boolean { + return ( + segment.length > 0 && + segment !== "." && + segment !== ".." && + artifactPathSegmentPattern.test(segment) + ); +} + +function sha256(bytes: Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); +} + +/** + * Inventories a canonical, stable release tree without following symbolic links. + * Every regular file must be nonempty, single-linked, bounded, and unchanged across read. + * @param releaseRoot Canonical absolute staged-release root. + * @param testHooks Deterministic adversarial hooks used only by tests. + * @returns Strictly path-sorted file identities. + */ +export async function inventoryReleaseArtifactTree( + releaseRoot: string, + testHooks: ReleaseArtifactInventoryTestHooks = {} +): Promise { + if ( + !path.isAbsolute(releaseRoot) || + releaseRoot.includes("\0") || + path.resolve(releaseRoot) !== releaseRoot || + path.parse(releaseRoot).root === releaseRoot + ) { + throw new TypeError(invalidArtifactTreeMessage); + } + + try { + const canonicalRoot = await realpath(releaseRoot); + if (canonicalRoot !== releaseRoot) throw invalidArtifactTree(); + const rootSnapshot = await directorySnapshot(releaseRoot); + const records: ReleaseArtifactInventoryRecord[] = []; + let directoryCount = 0; + let totalBytes = 0; + + const visit = async (relativeDirectory: string, depth: number): Promise => { + if (depth > maximumArtifactDepth) throw invalidArtifactTree(); + directoryCount += 1; + if (directoryCount > maximumArtifactDirectoryCount) { + throw invalidArtifactTree(); + } + const absoluteDirectory = + relativeDirectory.length === 0 + ? releaseRoot + : path.join(releaseRoot, relativeDirectory); + const before = await directorySnapshot(absoluteDirectory); + if (before.status.dev !== rootSnapshot.status.dev) { + throw invalidArtifactTree(); + } + + const entries = await readdir(absoluteDirectory, { withFileTypes: true }); + for (const entry of entries.toSorted((left, right) => + compareCanonicalText(left.name, right.name) + )) { + if (!validPathSegment(entry.name)) throw invalidArtifactTree(); + const relativePath = + relativeDirectory.length === 0 + ? entry.name + : `${relativeDirectory}/${entry.name}`; + const absolutePath = path.join(releaseRoot, relativePath); + if (entry.isDirectory()) { + await visit(relativePath, depth + 1); + continue; + } + if (!entry.isFile() || records.length >= maximumArtifactCount) { + throw invalidArtifactTree(); + } + + const beforeRead = await lstat(absolutePath, { bigint: true }); + if ( + !beforeRead.isFile() || + beforeRead.isSymbolicLink() || + beforeRead.nlink !== 1n || + beforeRead.dev !== rootSnapshot.status.dev || + beforeRead.size <= 0n || + beforeRead.size > BigInt(maximumReleaseArtifactBytes) + ) { + throw invalidArtifactTree(); + } + const contents = await readBoundedRegularFile( + absolutePath, + releaseRoot, + maximumReleaseArtifactBytes, + invalidArtifactTreeMessage + ); + await testHooks.afterFileRead?.(relativePath); + const afterRead = await lstat(absolutePath, { bigint: true }); + if (!matchesFileSnapshot(beforeRead, afterRead)) { + throw invalidArtifactTree(); + } + totalBytes += contents.byteLength; + if (totalBytes > maximumArtifactTreeBytes) throw invalidArtifactTree(); + records.push( + Object.freeze({ + bytes: contents.byteLength, + path: relativePath, + sha256: sha256(contents), + }) + ); + } + + const after = await directorySnapshot(absoluteDirectory); + if ( + !matchesDirectorySnapshot(before.status, after.status) || + before.entries.length !== after.entries.length || + before.entries.some((entry, index) => entry !== after.entries[index]) + ) { + throw invalidArtifactTree(); + } + }; + + await visit("", 0); + const finalRoot = await directorySnapshot(releaseRoot); + if ( + !matchesDirectorySnapshot(rootSnapshot.status, finalRoot.status) || + rootSnapshot.entries.length !== finalRoot.entries.length || + rootSnapshot.entries.some( + (entry, index) => entry !== finalRoot.entries[index] + ) + ) { + throw invalidArtifactTree(); + } + if (records.length === 0) throw invalidArtifactTree(); + return Object.freeze( + records.toSorted((left, right) => compareCanonicalText(left.path, right.path)) + ); + } catch (error) { + if (error instanceof TypeError && error.message === invalidArtifactTreeMessage) { + throw error; + } + throw invalidArtifactTree(); + } +} diff --git a/greenfield/scripts/delivery/releaseIdentity.test.ts b/greenfield/scripts/delivery/releaseIdentity.test.ts new file mode 100644 index 000000000..b922de645 --- /dev/null +++ b/greenfield/scripts/delivery/releaseIdentity.test.ts @@ -0,0 +1,272 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { serializeReleaseManifest } from "../../src/shared/releaseManifest.ts"; +import type { BuildSourceIdentity } from "../buildSourceIdentity.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { inventoryReleaseArtifactTree } from "./releaseArtifactInventory.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; +import { + createReleaseIdentity, + verifyReleaseArtifactIdentity, + verifyReleaseIdentity, + writeReleaseIdentity, +} from "./releaseIdentity.ts"; + +const sourceProjectRoot = path.resolve(import.meta.dir, "../.."); +const temporaryDirectories: string[] = []; +const commitSha = "b".repeat(40); +const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ + revision: "a".repeat(40), + version: "1.4.0", +}); +const cleanSourceIdentity: BuildSourceIdentity = Object.freeze({ + commitSha, + state: "clean", +}); + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function copyDirectory(source: string, destination: string): Promise { + await cp(source, destination, { + errorOnExist: true, + force: false, + recursive: true, + }); +} + +async function releaseFixture(): Promise<{ + releaseRoot: string; + repositoryRoot: string; +}> { + const repositoryRoot = await mkdtemp(path.join(tmpdir(), "mira-release-identity-")); + temporaryDirectories.push(repositoryRoot); + const releaseRoot = path.join(repositoryRoot, "dist/candidate"); + await Promise.all([ + mkdir(path.join(repositoryRoot, "docs"), { recursive: true }), + mkdir(path.join(releaseRoot, "browser/assets"), { recursive: true }), + mkdir(path.join(releaseRoot, "docs"), { recursive: true }), + mkdir(path.join(releaseRoot, "metadata"), { recursive: true }), + mkdir(path.join(releaseRoot, "server"), { recursive: true }), + mkdir(path.join(releaseRoot, "systemd"), { recursive: true }), + ]); + + const packageJson = `${JSON.stringify( + { + dependencies: { react: "^19.2.8" }, + devDependencies: { typescript: "^7.0.2" }, + name: "mira-dashboard", + private: true, + version: "0.0.0", + }, + null, + 2 + )}\n`; + const lockfile = `{ + "packages": { + "react": ["react@19.2.8", "", {}], + "typescript": ["typescript@7.0.2", "", {}], + }, + }\n`; + await Promise.all([ + writeFile(path.join(repositoryRoot, ".bun-version"), "canary\n"), + writeFile(path.join(repositoryRoot, "package.json"), packageJson), + writeFile(path.join(repositoryRoot, "bun.lock"), lockfile), + writeFile(path.join(releaseRoot, "metadata/.bun-version"), "canary\n"), + writeFile(path.join(releaseRoot, "metadata/package.json"), packageJson), + writeFile(path.join(releaseRoot, "metadata/bun.lock"), lockfile), + writeFile(path.join(releaseRoot, "browser/index.html"), "dashboard"), + writeFile(path.join(releaseRoot, "browser/assets/app-a1b2c3d4.js"), "app"), + writeFile( + path.join(releaseRoot, "server/databaseMaintenance.js"), + "database-maintenance" + ), + writeFile(path.join(releaseRoot, "server/web.js"), "web"), + writeFile(path.join(releaseRoot, "server/worker.js"), "worker"), + writeFile( + path.join(releaseRoot, "systemd/mira-dashboard-web.service"), + "[Service]\nExecStart=/web\n" + ), + writeFile( + path.join(releaseRoot, "systemd/mira-dashboard-worker.service"), + "[Service]\nExecStart=/worker\n" + ), + ]); + await Promise.all([ + copyDirectory( + path.join(sourceProjectRoot, "docs/generated"), + path.join(repositoryRoot, "docs/generated") + ), + copyDirectory( + path.join(sourceProjectRoot, "docs/generated"), + path.join(releaseRoot, "docs/generated") + ), + copyDirectory( + path.join(sourceProjectRoot, "migrations"), + path.join(releaseRoot, "migrations") + ), + ]); + return { releaseRoot, repositoryRoot }; +} + +function creationOptions(fixture: { releaseRoot: string; repositoryRoot: string }) { + return { + ...fixture, + runtimeIdentity, + sourceIdentity: cleanSourceIdentity, + }; +} + +describe("release identity", () => { + test("derives, persists, rereads and verifies the complete staged identity", async () => { + const fixture = await releaseFixture(); + + const created = await createReleaseIdentity(creationOptions(fixture)); + const persisted = await writeReleaseIdentity(creationOptions(fixture)); + const declared = await verifyReleaseArtifactIdentity(fixture.releaseRoot); + const verified = await verifyReleaseIdentity( + fixture.releaseRoot, + runtimeIdentity + ); + + expect(persisted).toEqual(created); + expect(declared).toEqual(created); + expect(verified).toEqual(created); + expect(created.source).toEqual({ commitSha, treeState: "clean" }); + expect(created.runtime).toEqual(runtimeIdentity); + expect(created.packages).toEqual([ + { name: "react", scope: "dependency", version: "19.2.8" }, + { name: "typescript", scope: "devDependency", version: "7.0.2" }, + ]); + expect( + created.artifacts.some( + ({ path: artifactPath }) => artifactPath === "server/worker.js" + ) + ).toBe(true); + expect( + created.artifacts + .filter(({ path: artifactPath }) => artifactPath.startsWith("systemd/")) + .map(({ path: artifactPath }) => artifactPath) + ).toEqual([ + "systemd/mira-dashboard-web.service", + "systemd/mira-dashboard-worker.service", + ]); + const manifestText = await readFile( + path.join(fixture.releaseRoot, "release-manifest.json"), + "utf8" + ); + const manifestValue: unknown = JSON.parse(manifestText); + expect(manifestValue).toEqual(created); + expect(Object.isFrozen(verified)).toBe(true); + }); + + test("separates artifact verification from executable runtime binding", async () => { + const fixture = await releaseFixture(); + const persisted = await writeReleaseIdentity(creationOptions(fixture)); + + expect(await verifyReleaseArtifactIdentity(fixture.releaseRoot)).toEqual( + persisted + ); + const runtimeFailure = await rejectionError( + verifyReleaseIdentity(fixture.releaseRoot, { + revision: "f".repeat(40), + version: runtimeIdentity.version, + }) + ); + expect(runtimeFailure.message).toBe("Release identity is invalid"); + }); + + test("reconstructs the migration graph from the release manifest", async () => { + const fixture = await releaseFixture(); + const persisted = await writeReleaseIdentity(creationOptions(fixture)); + const migrationId = "20260805000000_add-reviewed-node"; + const migrationSql = "CREATE TABLE reviewed_node (id TEXT PRIMARY KEY);\n"; + const snapshot = '{"version":"1"}\n'; + const migrationRoot = path.join(fixture.releaseRoot, "migrations", migrationId); + await mkdir(migrationRoot); + await Promise.all([ + writeFile(path.join(migrationRoot, "migration.sql"), migrationSql), + writeFile(path.join(migrationRoot, "snapshot.json"), snapshot), + ]); + const completeInventory = await inventoryReleaseArtifactTree(fixture.releaseRoot); + const artifacts = completeInventory.filter( + ({ path: artifactPath }) => artifactPath !== "release-manifest.json" + ); + const releaseOwnedMigration = Object.freeze({ + id: migrationId, + migrationSha256: new Bun.CryptoHasher("sha256") + .update(migrationSql) + .digest("hex"), + snapshotSha256: new Bun.CryptoHasher("sha256").update(snapshot).digest("hex"), + }); + await writeFile( + path.join(fixture.releaseRoot, "release-manifest.json"), + serializeReleaseManifest({ + ...persisted, + artifacts, + migrations: [...persisted.migrations, releaseOwnedMigration], + }) + ); + + const reconstructed = await verifyReleaseArtifactIdentity(fixture.releaseRoot); + + expect(reconstructed.migrations).toEqual([ + ...persisted.migrations, + releaseOwnedMigration, + ]); + }); + + test("rejects dirty source, staged metadata drift and migration mismatch", async () => { + const dirtyFixture = await releaseFixture(); + const dirtyFailure = await rejectionError( + createReleaseIdentity({ + ...creationOptions(dirtyFixture), + sourceIdentity: { commitSha, state: "dirty" }, + }) + ); + expect(dirtyFailure.message).toBe("Release identity is invalid"); + + const metadataFixture = await releaseFixture(); + await writeFile( + path.join(metadataFixture.releaseRoot, "metadata/.bun-version"), + "stable\n" + ); + const metadataFailure = await rejectionError( + createReleaseIdentity(creationOptions(metadataFixture)) + ); + expect(metadataFailure.message).toBe("Release identity is invalid"); + + const migrationFixture = await releaseFixture(); + const migrationPath = path.join( + migrationFixture.releaseRoot, + "migrations/20260804022252_dashboard-foundation/migration.sql" + ); + await writeFile(migrationPath, `${await readFile(migrationPath, "utf8")}\n`); + const migrationFailure = await rejectionError( + createReleaseIdentity(creationOptions(migrationFixture)) + ); + expect(migrationFailure.message).toBe("Release identity is invalid"); + }); + + test("rejects artifact tampering and never overwrites a persisted manifest", async () => { + const fixture = await releaseFixture(); + const options = creationOptions(fixture); + await writeReleaseIdentity(options); + + const overwriteFailure = await rejectionError(writeReleaseIdentity(options)); + expect(overwriteFailure.message).toBe("Release identity is invalid"); + await writeFile(path.join(fixture.releaseRoot, "server/web.js"), "tampered"); + const tamperFailure = await rejectionError( + verifyReleaseIdentity(fixture.releaseRoot, runtimeIdentity) + ); + expect(tamperFailure.message).toBe("Release identity is invalid"); + }); +}); diff --git a/greenfield/scripts/delivery/releaseIdentity.ts b/greenfield/scripts/delivery/releaseIdentity.ts new file mode 100644 index 000000000..4f524c3d0 --- /dev/null +++ b/greenfield/scripts/delivery/releaseIdentity.ts @@ -0,0 +1,473 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; + +import * as v from "valibot"; + +import { bunRuntimePolicy } from "../../src/shared/bunRuntimePolicy.ts"; +import { migrationManifest } from "../../src/shared/databaseMigrationManifest.ts"; +import { + parseReleaseManifest, + type ReleaseManifest, + releaseBuildCommands, + releaseProcessRoles, + serializeReleaseManifest, +} from "../../src/shared/releaseManifest.ts"; +import { + type BuildSourceIdentity, + resolveBuildSourceIdentity, +} from "../buildSourceIdentity.ts"; +import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; +import { resolveDirectPackageVersions } from "../packageIdentity.ts"; +import { productionSystemdUnits } from "./productionSystemdUnitPolicy.ts"; +import { + inventoryReleaseArtifactTree, + type ReleaseArtifactInventoryRecord, +} from "./releaseArtifactInventory.ts"; + +const invalidReleaseIdentityMessage = "Release identity is invalid"; +const releaseManifestFileName = "release-manifest.json"; +const maximumPackageJsonBytes = 1024 * 1024; +const maximumLockfileBytes = 4 * 1024 * 1024; +const maximumManifestBytes = 4 * 1024 * 1024; +const packageGroupSchema = v.record(v.string(), v.string()); +const packageJsonSchema = v.object({ + dependencies: packageGroupSchema, + devDependencies: packageGroupSchema, + name: v.literal("mira-dashboard"), + private: v.literal(true), +}); +const allowedArtifactRoots = new Set([ + "browser", + "docs", + "metadata", + "migrations", + "scripts", + "server", + "systemd", +]); +const exactMetadataPaths = Object.freeze([ + "metadata/.bun-version", + "metadata/bun.lock", + "metadata/package.json", +] as const); +const exactSystemdPaths = Object.freeze( + productionSystemdUnits.map(({ artifactPath }) => artifactPath) +); + +/** Bun identity observed by release creation and activation verification. */ +export interface ReleaseRuntimeIdentity { + readonly revision: string; + readonly version: string; +} + +/** Inputs for one manifest derived from an already staged release tree. */ +export interface CreateReleaseIdentityOptions { + readonly releaseRoot: string; + readonly repositoryRoot: string; + readonly runtimeIdentity?: ReleaseRuntimeIdentity; + readonly sourceIdentity?: BuildSourceIdentity; +} + +function invalidReleaseIdentity(): Error { + return new Error(invalidReleaseIdentityMessage); +} + +function compareCanonicalText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function sha256(value: string | Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(value).digest("hex"); +} + +function currentRuntimeIdentity(): ReleaseRuntimeIdentity { + return Object.freeze({ revision: Bun.revision, version: Bun.version }); +} + +function validateRoots(repositoryRoot: string, releaseRoot: string): void { + const expectedReleaseParent = path.join(repositoryRoot, "dist"); + const releaseRelative = path.relative(expectedReleaseParent, releaseRoot); + if ( + !path.isAbsolute(repositoryRoot) || + !path.isAbsolute(releaseRoot) || + repositoryRoot.includes("\0") || + releaseRoot.includes("\0") || + path.resolve(repositoryRoot) !== repositoryRoot || + path.resolve(releaseRoot) !== releaseRoot || + releaseRelative.length === 0 || + releaseRelative === ".." || + releaseRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(releaseRelative) + ) { + throw invalidReleaseIdentity(); + } +} + +async function readUtf8( + filePath: string, + allowedRoot: string, + maximumBytes: number +): Promise<{ bytes: Buffer; text: string }> { + return readBoundedUtf8RegularFile( + filePath, + allowedRoot, + maximumBytes, + invalidReleaseIdentityMessage, + invalidReleaseIdentityMessage + ); +} + +async function readPackageInputs(root: string, metadataPrefix: string) { + const [packageFile, lockfile] = await Promise.all([ + readUtf8( + path.join(root, metadataPrefix, "package.json"), + root, + maximumPackageJsonBytes + ), + readUtf8(path.join(root, metadataPrefix, "bun.lock"), root, maximumLockfileBytes), + ]); + let packageValue: unknown; + try { + packageValue = JSON.parse(packageFile.text) as unknown; + } catch { + throw invalidReleaseIdentity(); + } + const parsed = v.safeParse(packageJsonSchema, packageValue, { abortEarly: true }); + if (!parsed.success) throw invalidReleaseIdentity(); + const duplicatePackages = Object.keys(parsed.output.dependencies).filter((name) => + Object.hasOwn(parsed.output.devDependencies, name) + ); + if (duplicatePackages.length > 0) throw invalidReleaseIdentity(); + let versions: Readonly>; + try { + versions = resolveDirectPackageVersions( + [parsed.output.dependencies, parsed.output.devDependencies], + lockfile.text + ); + } catch { + throw invalidReleaseIdentity(); + } + const packages = [ + ...Object.keys(parsed.output.dependencies).map((name) => ({ + name, + scope: "dependency" as const, + version: versions[name]!, + })), + ...Object.keys(parsed.output.devDependencies).map((name) => ({ + name, + scope: "devDependency" as const, + version: versions[name]!, + })), + ].toSorted((left, right) => compareCanonicalText(left.name, right.name)); + if (packages.length === 0) throw invalidReleaseIdentity(); + return Object.freeze({ lockfile, packageFile, packages: Object.freeze(packages) }); +} + +function sameArtifactRecords( + left: readonly ReleaseArtifactInventoryRecord[], + right: readonly ReleaseArtifactInventoryRecord[] +): boolean { + return ( + left.length === right.length && + left.every( + (record, index) => + record.bytes === right[index]?.bytes && + record.path === right[index]?.path && + record.sha256 === right[index]?.sha256 + ) + ); +} + +function documentationRecords( + artifacts: readonly ReleaseArtifactInventoryRecord[] +): readonly ReleaseArtifactInventoryRecord[] { + const records = artifacts.filter(({ path: artifactPath }) => + artifactPath.startsWith("docs/generated/") + ); + if (records.length === 0) throw invalidReleaseIdentity(); + return records; +} + +function aggregateArtifactIdentity( + records: readonly ReleaseArtifactInventoryRecord[] +): string { + return sha256(JSON.stringify(records)); +} + +function artifactByPath( + artifacts: readonly ReleaseArtifactInventoryRecord[], + artifactPath: string +): ReleaseArtifactInventoryRecord { + const artifact = artifacts.find(({ path: candidate }) => candidate === artifactPath); + if (!artifact) throw invalidReleaseIdentity(); + return artifact; +} + +function assertArtifactShape( + artifacts: readonly ReleaseArtifactInventoryRecord[], + migrations: ReleaseManifest["migrations"] +): void { + for (const artifact of artifacts) { + const root = artifact.path.split("/", 1)[0]; + if (!root || !allowedArtifactRoots.has(root)) throw invalidReleaseIdentity(); + } + for (const requiredPath of [ + ...exactMetadataPaths, + ...exactSystemdPaths, + "browser/index.html", + "server/databaseMaintenance.js", + "server/web.js", + "server/worker.js", + ]) { + artifactByPath(artifacts, requiredPath); + } + const metadataPaths = artifacts + .filter(({ path: artifactPath }) => artifactPath.startsWith("metadata/")) + .map(({ path: artifactPath }) => artifactPath); + if ( + metadataPaths.length !== exactMetadataPaths.length || + exactMetadataPaths.some((expected, index) => metadataPaths[index] !== expected) + ) { + throw invalidReleaseIdentity(); + } + const systemdPaths = artifacts + .filter(({ path: artifactPath }) => artifactPath.startsWith("systemd/")) + .map(({ path: artifactPath }) => artifactPath); + if ( + systemdPaths.length !== exactSystemdPaths.length || + exactSystemdPaths.some((expected, index) => systemdPaths[index] !== expected) + ) { + throw invalidReleaseIdentity(); + } + + const expectedMigrationPaths = migrations + .flatMap(({ id }) => [ + `migrations/${id}/migration.sql`, + `migrations/${id}/snapshot.json`, + ]) + .toSorted(compareCanonicalText); + const migrationPaths = artifacts + .filter(({ path: artifactPath }) => artifactPath.startsWith("migrations/")) + .map(({ path: artifactPath }) => artifactPath); + if ( + migrationPaths.length !== expectedMigrationPaths.length || + expectedMigrationPaths.some( + (expected, index) => migrationPaths[index] !== expected + ) + ) { + throw invalidReleaseIdentity(); + } + for (const migration of migrations) { + if ( + artifactByPath(artifacts, `migrations/${migration.id}/migration.sql`) + .sha256 !== migration.migrationSha256 || + artifactByPath(artifacts, `migrations/${migration.id}/snapshot.json`) + .sha256 !== migration.snapshotSha256 + ) { + throw invalidReleaseIdentity(); + } + } +} + +async function sourceDocumentationIdentity(repositoryRoot: string): Promise { + const documentationRoot = path.join(repositoryRoot, "docs/generated"); + const sourceRecords = await inventoryReleaseArtifactTree(documentationRoot); + return aggregateArtifactIdentity( + sourceRecords.map((record) => ({ + ...record, + path: `docs/generated/${record.path}`, + })) + ); +} + +function assertRuntimeIdentity(runtime: ReleaseRuntimeIdentity): void { + if ( + runtime.version !== bunRuntimePolicy.version || + !/^[a-f\d]{40}$/u.test(runtime.revision) + ) { + throw invalidReleaseIdentity(); + } +} + +/** + * Derives a complete manifest from the clean checkout and staged release bytes. + * @param options Canonical repository/staging roots and optional test identities. + * @returns Parsed immutable release manifest. + */ +export async function createReleaseIdentity( + options: CreateReleaseIdentityOptions +): Promise { + validateRoots(options.repositoryRoot, options.releaseRoot); + const source = + options.sourceIdentity ?? resolveBuildSourceIdentity(options.repositoryRoot); + if (source.state !== "clean") throw invalidReleaseIdentity(); + const runtime = options.runtimeIdentity ?? currentRuntimeIdentity(); + assertRuntimeIdentity(runtime); + + const artifacts = await inventoryReleaseArtifactTree(options.releaseRoot); + if ( + artifacts.some( + ({ path: artifactPath }) => artifactPath === releaseManifestFileName + ) + ) { + throw invalidReleaseIdentity(); + } + assertArtifactShape(artifacts, migrationManifest); + const [sourcePackages, stagedPackages, sourceDocsSha256, sourceBunVersion] = + await Promise.all([ + readPackageInputs(options.repositoryRoot, ""), + readPackageInputs(options.releaseRoot, "metadata"), + sourceDocumentationIdentity(options.repositoryRoot), + readUtf8( + path.join(options.repositoryRoot, ".bun-version"), + options.repositoryRoot, + 128 + ), + ]); + if ( + sourceBunVersion.text !== `${bunRuntimePolicy.channel}\n` || + sha256(sourceBunVersion.bytes) !== + artifactByPath(artifacts, "metadata/.bun-version").sha256 || + sha256(sourcePackages.lockfile.bytes) !== + artifactByPath(artifacts, "metadata/bun.lock").sha256 || + sha256(sourcePackages.packageFile.bytes) !== + artifactByPath(artifacts, "metadata/package.json").sha256 || + JSON.stringify(sourcePackages.packages) !== + JSON.stringify(stagedPackages.packages) + ) { + throw invalidReleaseIdentity(); + } + const stagedDocumentationSha256 = aggregateArtifactIdentity( + documentationRecords(artifacts) + ); + if (stagedDocumentationSha256 !== sourceDocsSha256) { + throw invalidReleaseIdentity(); + } + + return parseReleaseManifest({ + artifacts, + buildCommands: [...releaseBuildCommands], + documentationSha256: stagedDocumentationSha256, + formatVersion: 1, + lockfileSha256: artifactByPath(artifacts, "metadata/bun.lock").sha256, + migrations: migrationManifest.map((migration) => ({ ...migration })), + packages: stagedPackages.packages, + processRoles: [...releaseProcessRoles], + runtime, + source: { commitSha: source.commitSha, treeState: "clean" }, + }); +} + +async function reconstructReleaseArtifactIdentity( + releaseRoot: string +): Promise { + const manifestFile = await readUtf8( + path.join(releaseRoot, releaseManifestFileName), + releaseRoot, + maximumManifestBytes + ); + let manifestValue: unknown; + try { + manifestValue = JSON.parse(manifestFile.text) as unknown; + } catch { + throw invalidReleaseIdentity(); + } + let manifest: ReleaseManifest; + try { + manifest = parseReleaseManifest(manifestValue); + } catch { + throw invalidReleaseIdentity(); + } + assertRuntimeIdentity(manifest.runtime); + + const completeInventory = await inventoryReleaseArtifactTree(releaseRoot); + const artifacts = completeInventory.filter( + ({ path: artifactPath }) => artifactPath !== releaseManifestFileName + ); + if ( + completeInventory.length !== artifacts.length + 1 || + !sameArtifactRecords(manifest.artifacts, artifacts) + ) { + throw invalidReleaseIdentity(); + } + assertArtifactShape(artifacts, manifest.migrations); + const stagedPackages = await readPackageInputs(releaseRoot, "metadata"); + const stagedBunVersion = await readUtf8( + path.join(releaseRoot, "metadata/.bun-version"), + releaseRoot, + 128 + ); + if ( + stagedBunVersion.text !== `${bunRuntimePolicy.channel}\n` || + sha256(stagedBunVersion.bytes) !== + artifactByPath(artifacts, "metadata/.bun-version").sha256 || + manifest.lockfileSha256 !== + artifactByPath(artifacts, "metadata/bun.lock").sha256 || + manifest.documentationSha256 !== + aggregateArtifactIdentity(documentationRecords(artifacts)) || + JSON.stringify(manifest.packages) !== JSON.stringify(stagedPackages.packages) + ) { + throw invalidReleaseIdentity(); + } + return manifest; +} + +/** + * Reconstructs every identity represented by one release artifact. + * This proves the manifest against its bytes but does not prove an executable runtime. + * Activation must bind the returned runtime identity to its explicit runtime source. + * @param releaseRoot Canonical immutable-release candidate root. + * @returns Deeply frozen, internally consistent manifest. + */ +export function verifyReleaseArtifactIdentity( + releaseRoot: string +): Promise { + return reconstructReleaseArtifactIdentity(releaseRoot); +} + +/** + * Reconstructs a release identity and binds it to one already selected Bun runtime. + * @param releaseRoot Canonical immutable-release candidate root. + * @param runtimeIdentity Bun runtime selected to serve the candidate. + * @returns Deeply frozen verified manifest. + */ +export async function verifyReleaseIdentity( + releaseRoot: string, + runtimeIdentity: ReleaseRuntimeIdentity +): Promise { + assertRuntimeIdentity(runtimeIdentity); + const manifest = await reconstructReleaseArtifactIdentity(releaseRoot); + if ( + manifest.runtime.version !== runtimeIdentity.version || + manifest.runtime.revision !== runtimeIdentity.revision + ) { + throw invalidReleaseIdentity(); + } + return manifest; +} + +/** + * Writes a new manifest without overwriting an existing candidate, then rereads and verifies it. + * @param options Canonical repository and staged-release inputs. + * @returns Verified persisted release identity. + */ +export async function writeReleaseIdentity( + options: CreateReleaseIdentityOptions +): Promise { + const manifest = await createReleaseIdentity(options); + try { + await writeFile( + path.join(options.releaseRoot, releaseManifestFileName), + serializeReleaseManifest(manifest), + { encoding: "utf8", flag: "wx", mode: 0o600 } + ); + } catch { + throw invalidReleaseIdentity(); + } + return verifyReleaseIdentity( + options.releaseRoot, + options.runtimeIdentity ?? currentRuntimeIdentity() + ); +} diff --git a/greenfield/scripts/delivery/releaseStaging.ts b/greenfield/scripts/delivery/releaseStaging.ts new file mode 100644 index 000000000..7cef86c48 --- /dev/null +++ b/greenfield/scripts/delivery/releaseStaging.ts @@ -0,0 +1,350 @@ +import { + chmod, + lstat, + mkdir, + readdir, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { readBoundedRegularFile } from "../files/boundedFile.ts"; +import { resolveRepositoryBuildPath } from "./buildPaths.ts"; +import { + inventoryReleaseArtifactTree, + maximumReleaseArtifactBytes, + type ReleaseArtifactInventoryRecord, +} from "./releaseArtifactInventory.ts"; + +const invalidReleaseStagingMessage = "Release staging failed"; +const commitShaPattern = /^[a-f\d]{40}$/u; +const privateDirectoryMode = 0o700; +const privateFileMode = 0o600; +const immutableDirectoryMode = 0o500; +const immutableFileMode = 0o400; +const maximumMetadataBytes = 4 * 1024 * 1024; + +/** Exclusive temporary and final paths for one source commit. */ +export interface ReleaseStagingPaths { + readonly finalRoot: string; + readonly stagingRoot: string; +} + +/** Artifact sources copied into a fresh release candidate. */ +export interface ReleaseStagingSources { + readonly browserRoot: string; + readonly processRoot: string; + readonly repositoryRoot: string; + readonly stagingRoot: string; +} + +function invalidReleaseStaging(): Error { + return new Error(invalidReleaseStagingMessage); +} + +function sha256(bytes: Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); +} + +function sameArtifactRecords( + left: readonly ReleaseArtifactInventoryRecord[], + right: readonly ReleaseArtifactInventoryRecord[] +): boolean { + return ( + left.length === right.length && + left.every( + (record, index) => + record.bytes === right[index]?.bytes && + record.path === right[index]?.path && + record.sha256 === right[index]?.sha256 + ) + ); +} + +function isMissingPath(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +async function requireMissing(candidate: string): Promise { + try { + await lstat(candidate); + } catch (error) { + if (isMissingPath(error)) return; + throw invalidReleaseStaging(); + } + throw invalidReleaseStaging(); +} + +async function requireProtectedOwnedDirectory(directory: string): Promise { + if (typeof process.getuid !== "function") throw invalidReleaseStaging(); + const [canonical, status] = await Promise.all([ + realpath(directory), + lstat(directory, { bigint: true }), + ]); + if ( + canonical !== directory || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) || + (status.mode & 0o022n) !== 0n + ) { + throw invalidReleaseStaging(); + } +} + +async function copyArtifactTree(sourceRoot: string, destinationRoot: string) { + const sourceBefore = await inventoryReleaseArtifactTree(sourceRoot); + await mkdir(path.dirname(destinationRoot), { + mode: privateDirectoryMode, + recursive: true, + }); + await mkdir(destinationRoot, { mode: privateDirectoryMode, recursive: false }); + + for (const record of sourceBefore) { + const contents = await readBoundedRegularFile( + path.join(sourceRoot, record.path), + sourceRoot, + maximumReleaseArtifactBytes, + invalidReleaseStagingMessage + ); + if (contents.byteLength !== record.bytes || sha256(contents) !== record.sha256) { + throw invalidReleaseStaging(); + } + const destination = path.join(destinationRoot, record.path); + await mkdir(path.dirname(destination), { + mode: privateDirectoryMode, + recursive: true, + }); + await writeFile(destination, contents, { + flag: "wx", + mode: privateFileMode, + }); + } + + const [sourceAfter, destination] = await Promise.all([ + inventoryReleaseArtifactTree(sourceRoot), + inventoryReleaseArtifactTree(destinationRoot), + ]); + if ( + !sameArtifactRecords(sourceBefore, sourceAfter) || + !sameArtifactRecords(sourceBefore, destination) + ) { + throw invalidReleaseStaging(); + } + return sourceBefore; +} + +async function copyMetadataFile( + source: string, + sourceRoot: string, + destination: string +): Promise { + const contents = await readBoundedRegularFile( + source, + sourceRoot, + maximumMetadataBytes, + invalidReleaseStagingMessage + ); + await writeFile(destination, contents, { flag: "wx", mode: privateFileMode }); + const reread = await readBoundedRegularFile( + source, + sourceRoot, + maximumMetadataBytes, + invalidReleaseStagingMessage + ); + if ( + contents.byteLength !== reread.byteLength || + sha256(contents) !== sha256(reread) + ) { + throw invalidReleaseStaging(); + } +} + +/** + * Allocates an exclusive staging directory and reserves a commit-addressed final path. + * @param repositoryRoot Canonical future-root checkout. + * @param commitSha Clean source commit represented by the release. + * @returns Fresh staging and absent final paths below `dist/releases`. + */ +export async function createReleaseStagingPaths( + repositoryRoot: string, + commitSha: string +): Promise { + if (!commitShaPattern.test(commitSha)) throw invalidReleaseStaging(); + const buildPath = resolveRepositoryBuildPath( + repositoryRoot, + path.join(repositoryRoot, "dist/releases", commitSha), + invalidReleaseStagingMessage + ); + const releasesRoot = path.dirname(buildPath.output); + await mkdir(releasesRoot, { mode: privateDirectoryMode, recursive: true }); + await requireProtectedOwnedDirectory(releasesRoot); + await requireMissing(buildPath.output); + + const stagingRoot = path.join( + releasesRoot, + `.stage-${commitSha}-${Bun.randomUUIDv7()}` + ); + await mkdir(stagingRoot, { mode: privateDirectoryMode, recursive: false }); + await requireProtectedOwnedDirectory(stagingRoot); + return Object.freeze({ finalRoot: buildPath.output, stagingRoot }); +} + +/** + * Copies exact browser, process, migration, documentation, and package metadata bytes. + * @param sources Canonical build/source roots and an empty exclusive staging root. + */ +export async function stageReleaseArtifacts( + sources: ReleaseStagingSources +): Promise { + const { output: stagingRoot } = resolveRepositoryBuildPath( + sources.repositoryRoot, + sources.stagingRoot, + invalidReleaseStagingMessage + ); + await requireProtectedOwnedDirectory(stagingRoot); + const existingEntries = await readdir(stagingRoot); + if (existingEntries.length > 0) throw invalidReleaseStaging(); + + const metadataRoot = path.join(stagingRoot, "metadata"); + await mkdir(metadataRoot, { mode: privateDirectoryMode, recursive: false }); + await Promise.all([ + copyArtifactTree(sources.browserRoot, path.join(stagingRoot, "browser")), + copyArtifactTree(sources.processRoot, path.join(stagingRoot, "server")), + copyArtifactTree( + path.join(sources.repositoryRoot, "docs/generated"), + path.join(stagingRoot, "docs/generated") + ), + copyArtifactTree( + path.join(sources.repositoryRoot, "migrations"), + path.join(stagingRoot, "migrations") + ), + copyArtifactTree( + path.join(sources.repositoryRoot, "systemd"), + path.join(stagingRoot, "systemd") + ), + copyMetadataFile( + path.join(sources.repositoryRoot, ".bun-version"), + sources.repositoryRoot, + path.join(metadataRoot, ".bun-version") + ), + copyMetadataFile( + path.join(sources.repositoryRoot, "bun.lock"), + sources.repositoryRoot, + path.join(metadataRoot, "bun.lock") + ), + copyMetadataFile( + path.join(sources.repositoryRoot, "package.json"), + sources.repositoryRoot, + path.join(metadataRoot, "package.json") + ), + ]); + await inventoryReleaseArtifactTree(stagingRoot); +} + +/** + * Removes owner write access from every staged file and directory. + * @param repositoryRoot Canonical future-root checkout. + * @param releaseRoot Verified repository-contained release tree. + */ +export async function makeReleaseTreeImmutable( + repositoryRoot: string, + releaseRoot: string +): Promise { + const { output } = resolveRepositoryBuildPath( + repositoryRoot, + releaseRoot, + invalidReleaseStagingMessage + ); + const before = await inventoryReleaseArtifactTree(output); + const directories = new Set([output]); + for (const artifact of before) { + await chmod(path.join(output, artifact.path), immutableFileMode); + let directory = path.dirname(artifact.path); + while (directory !== ".") { + directories.add(path.join(output, directory)); + directory = path.dirname(directory); + } + } + for (const directory of [...directories].toSorted( + (left, right) => right.length - left.length + )) { + await chmod(directory, immutableDirectoryMode); + } + const after = await inventoryReleaseArtifactTree(output); + if (!sameArtifactRecords(before, after)) throw invalidReleaseStaging(); +} + +/** + * Atomically moves a frozen staging tree into its commit-addressed build slot. + * @param repositoryRoot Canonical future-root checkout. + * @param paths Exclusive staging and final paths created together. + */ +export async function promoteStagedRelease( + repositoryRoot: string, + paths: ReleaseStagingPaths +): Promise { + const staging = resolveRepositoryBuildPath( + repositoryRoot, + paths.stagingRoot, + invalidReleaseStagingMessage + ).output; + const final = resolveRepositoryBuildPath( + repositoryRoot, + paths.finalRoot, + invalidReleaseStagingMessage + ).output; + if (path.dirname(staging) !== path.dirname(final)) { + throw invalidReleaseStaging(); + } + await requireMissing(final); + await rename(staging, final); + if ((await realpath(final)) !== final) throw invalidReleaseStaging(); +} + +async function restoreOwnerWrite(directory: string): Promise { + const status = await lstat(directory); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw invalidReleaseStaging(); + } + await chmod(directory, privateDirectoryMode); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, privateFileMode); + } else { + throw invalidReleaseStaging(); + } + } +} + +/** + * Discards only an explicit repository-contained build tree, including a frozen candidate. + * @param repositoryRoot Canonical future-root checkout. + * @param releaseRoot Exact staging or final tree to remove. + */ +export async function discardReleaseTree( + repositoryRoot: string, + releaseRoot: string +): Promise { + const { output } = resolveRepositoryBuildPath( + repositoryRoot, + releaseRoot, + invalidReleaseStagingMessage + ); + try { + await restoreOwnerWrite(output); + } catch (error) { + if (isMissingPath(error)) return; + throw error; + } + await rm(output, { force: false, recursive: true }); +} diff --git a/greenfield/scripts/delivery/systemctlProcess.test.ts b/greenfield/scripts/delivery/systemctlProcess.test.ts new file mode 100644 index 000000000..faa330ff4 --- /dev/null +++ b/greenfield/scripts/delivery/systemctlProcess.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; + +import { executeSystemctlProcess } from "./systemctlProcess.ts"; + +describe("systemctl process boundary", () => { + test("does not forward ambient HOME to the user manager command", async () => { + const result = await executeSystemctlProcess("/usr/bin/env", []); + const environment = new TextDecoder().decode(result.stdout).split("\n"); + + expect(result.exitCode).toBe(0); + expect(environment.some((entry) => entry.startsWith("HOME="))).toBe(false); + }); +}); diff --git a/greenfield/scripts/delivery/systemctlProcess.ts b/greenfield/scripts/delivery/systemctlProcess.ts new file mode 100644 index 000000000..4a2c1b82f --- /dev/null +++ b/greenfield/scripts/delivery/systemctlProcess.ts @@ -0,0 +1,121 @@ +import { secondsToMilliseconds } from "date-fns"; + +const maximumSystemctlOutputBytes = 64 * 1024; +const systemctlDeadlineMs = secondsToMilliseconds(30); + +/** Bounded systemctl result retained only for exit-status validation. */ +export interface SystemctlProcessResult { + readonly exitCode: number; + readonly stderr: Uint8Array; + readonly stdout: Uint8Array; +} + +/** Injectable systemctl process boundary used by delivery tests. */ +export type SystemctlExecutor = ( + executable: string, + arguments_: readonly string[] +) => Promise; + +function systemctlProcessFailure(): Error { + return new Error("Systemctl process failed"); +} + +async function readBoundedStream( + stream: ReadableStream +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + total += result.value.byteLength; + if (total > maximumSystemctlOutputBytes) { + throw systemctlProcessFailure(); + } + chunks.push(result.value); + } + } finally { + reader.releaseLock(); + } + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function systemctlEnvironment(): Record { + const environment: Record = { PATH: "/usr/bin:/bin" }; + for (const name of ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"] as const) { + const value = process.env[name]; + if (value !== undefined) environment[name] = value; + } + return environment; +} + +/** + * Executes one bounded non-interactive systemctl command. + * @param executable Absolute systemctl executable. + * @param arguments_ Exact caller-owned argument vector. + * @returns Bounded stdout, stderr, and exit code. + */ +export async function executeSystemctlProcess( + executable: string, + arguments_: readonly string[] +): Promise { + if ( + !executable.startsWith("/") || + executable.includes("\0") || + executable.length > 4096 + ) { + throw systemctlProcessFailure(); + } + const child = Bun.spawn([executable, ...arguments_], { + env: systemctlEnvironment(), + signal: AbortSignal.timeout(systemctlDeadlineMs), + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + readBoundedStream(child.stdout), + readBoundedStream(child.stderr), + ]); + return Object.freeze({ exitCode, stderr, stdout }); + } catch { + child.kill(); + await child.exited.catch(() => null); + throw systemctlProcessFailure(); + } +} + +/** + * Requires an exact successful, bounded systemctl result. + * @param execute Injectable bounded process executor. + * @param executable Absolute systemctl executable. + * @param arguments_ Exact systemctl argument vector. + */ +export async function requireSuccessfulSystemctlProcess( + execute: SystemctlExecutor, + executable: string, + arguments_: readonly string[] +): Promise { + try { + const result = await execute(executable, arguments_); + if ( + result.exitCode !== 0 || + result.stdout.byteLength > maximumSystemctlOutputBytes || + result.stderr.byteLength > maximumSystemctlOutputBytes + ) { + throw systemctlProcessFailure(); + } + } catch { + throw systemctlProcessFailure(); + } +} diff --git a/greenfield/scripts/delivery/systemdProductionServices.test.ts b/greenfield/scripts/delivery/systemdProductionServices.test.ts new file mode 100644 index 000000000..586329f4f --- /dev/null +++ b/greenfield/scripts/delivery/systemdProductionServices.test.ts @@ -0,0 +1,287 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, readFile, readlink, unlink } from "node:fs/promises"; +import path from "node:path"; + +import { + createLocalReleaseFixture, + createProductionTargetFixture, + publishProductionDeliveryFixtures, + removeProductionDeliveryFixtures, +} from "../testSupport/productionDeliveryFixture.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { pointProductionProcessesAtRelease } from "./productionRuntimePointers.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; +import { + createSystemdProductionServiceController, + type SystemctlProcessResult, +} from "./systemdProductionServices.ts"; + +const sourceProjectRoot = path.resolve(import.meta.dir, "../.."); +const firstReleaseId = "a".repeat(40); +const secondReleaseId = "b".repeat(40); +const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ + revision: "c".repeat(40), + version: "1.4.0", +}); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await removeProductionDeliveryFixtures(temporaryDirectories); +}); + +function successfulProcessResult(): SystemctlProcessResult { + return Object.freeze({ + exitCode: 0, + stderr: new Uint8Array(), + stdout: new Uint8Array(), + }); +} + +function inactiveProcessResult(): SystemctlProcessResult { + return Object.freeze({ + exitCode: 3, + stderr: new Uint8Array(), + stdout: new Uint8Array(), + }); +} + +describe("production user-systemd service control", () => { + test("points at exact artifacts and controls worker/web in safe order", async () => { + const sourceReleases = await Promise.all([ + createLocalReleaseFixture( + sourceProjectRoot, + firstReleaseId, + runtimeIdentity, + temporaryDirectories + ), + createLocalReleaseFixture( + sourceProjectRoot, + secondReleaseId, + runtimeIdentity, + temporaryDirectories + ), + ]); + const { projectRoot, runtimeSource } = + await createProductionTargetFixture(temporaryDirectories); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishProductionDeliveryFixtures( + lease, + paths, + sourceReleases, + runtimeSource, + runtimeIdentity + ); + const commands: string[][] = []; + const requests: Request[] = []; + const controller = createSystemdProductionServiceController(lease, paths, { + execute: (_executable, arguments_) => { + commands.push([...arguments_]); + return Promise.resolve(successfulProcessResult()); + }, + fetch: (request) => { + requests.push(request); + return Promise.resolve(new Response(null, { status: 200 })); + }, + installUnits: (observedLease, observedPaths, observedRelease) => { + expect(observedLease).toBe(lease); + expect(observedPaths).toBe(paths); + expect(observedRelease).toBe(fixtures.first); + return Promise.resolve(); + }, + readinessUrl: "http://127.0.0.1:3100/api/health/ready", + }); + + await controller.prepare(fixtures.first, fixtures.runtime); + await controller.start(fixtures.first, fixtures.runtime); + await controller.verifyReady(fixtures.first, fixtures.runtime); + await controller.stop(); + expect(await readlink(path.join(paths.releasesDirectory, "current"))).toBe( + firstReleaseId + ); + expect( + await readlink(path.join(paths.runtimesDirectory, "bun", "current")) + ).toBe(runtimeIdentity.revision); + expect(commands).toEqual([ + ["--user", "restart", "mira-dashboard-worker.service"], + ["--user", "restart", "mira-dashboard-web.service"], + ["--user", "is-active", "--quiet", "mira-dashboard-worker.service"], + ["--user", "is-active", "--quiet", "mira-dashboard-web.service"], + ["--user", "is-active", "--quiet", "mira-dashboard-worker.service"], + ["--user", "is-active", "--quiet", "mira-dashboard-web.service"], + ["--user", "stop", "mira-dashboard-web.service"], + ["--user", "stop", "mira-dashboard-worker.service"], + ]); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("HEAD"); + expect(requests[0]?.url).toBe("http://127.0.0.1:3100/api/health/ready"); + expect(() => + createSystemdProductionServiceController(lease, paths, { + readinessUrl: "http://[::1]:3100/api/health/ready", + }) + ).toThrow("Production service control failed"); + + for (const inactiveUnit of [ + "mira-dashboard-worker.service", + "mira-dashboard-web.service", + ]) { + const checkedUnits: string[] = []; + let readinessRequests = 0; + const inactiveController = createSystemdProductionServiceController( + lease, + paths, + { + execute: (_executable, arguments_) => { + const unit = arguments_.at(-1); + if (unit) checkedUnits.push(unit); + return Promise.resolve( + unit === inactiveUnit + ? inactiveProcessResult() + : successfulProcessResult() + ); + }, + fetch: () => { + readinessRequests += 1; + return Promise.resolve(new Response(null, { status: 200 })); + }, + readinessUrl: "http://127.0.0.1:3100/api/health/ready", + } + ); + const inactiveFailure = await rejectionError( + inactiveController.verifyReady(fixtures.first, fixtures.runtime) + ); + expect(inactiveFailure.message).toBe("Production service control failed"); + expect(checkedUnits).toEqual( + inactiveUnit === "mira-dashboard-worker.service" + ? ["mira-dashboard-worker.service"] + : ["mira-dashboard-worker.service", "mira-dashboard-web.service"] + ); + expect(readinessRequests).toBe(0); + } + + let activeChecks = 0; + let readinessRequests = 0; + const exitedDuringReadiness = createSystemdProductionServiceController( + lease, + paths, + { + execute: (_executable, arguments_) => { + if (arguments_.includes("is-active")) activeChecks += 1; + return Promise.resolve( + activeChecks === 3 + ? inactiveProcessResult() + : successfulProcessResult() + ); + }, + fetch: () => { + readinessRequests += 1; + return Promise.resolve(new Response(null, { status: 200 })); + }, + readinessUrl: "http://127.0.0.1:3100/api/health/ready", + } + ); + const exitedFailure = await rejectionError( + exitedDuringReadiness.verifyReady(fixtures.first, fixtures.runtime) + ); + expect(exitedFailure.message).toBe("Production service control failed"); + expect(activeChecks).toBe(3); + expect(readinessRequests).toBe(1); + + await pointProductionProcessesAtRelease( + lease, + paths, + fixtures.second, + fixtures.runtime + ); + expect(await readlink(path.join(paths.releasesDirectory, "current"))).toBe( + secondReleaseId + ); + }); + }); + + test("refuses to replace an untrusted current entry", async () => { + const sourceReleases = await Promise.all([ + createLocalReleaseFixture( + sourceProjectRoot, + firstReleaseId, + runtimeIdentity, + temporaryDirectories + ), + createLocalReleaseFixture( + sourceProjectRoot, + secondReleaseId, + runtimeIdentity, + temporaryDirectories + ), + ]); + const { projectRoot, runtimeSource } = + await createProductionTargetFixture(temporaryDirectories); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishProductionDeliveryFixtures( + lease, + paths, + sourceReleases, + runtimeSource, + runtimeIdentity + ); + await pointProductionProcessesAtRelease( + lease, + paths, + fixtures.first, + fixtures.runtime + ); + const current = path.join(paths.releasesDirectory, "current"); + await unlink(current); + await mkdir(current, { mode: 0o700 }); + const failure = await rejectionError( + pointProductionProcessesAtRelease( + lease, + paths, + fixtures.second, + fixtures.runtime + ) + ); + expect(failure.message).toBe("Production runtime pointer update failed"); + }); + }); + + test("ships project-local logs and the reviewed resource ceilings", async () => { + const systemdRoot = path.join(sourceProjectRoot, "systemd"); + const [web, worker] = await Promise.all([ + readFile(path.join(systemdRoot, "mira-dashboard-web.service"), "utf8"), + readFile(path.join(systemdRoot, "mira-dashboard-worker.service"), "utf8"), + ]); + for (const unit of [web, worker]) { + expect(unit).not.toContain("StateDirectory="); + expect(unit).not.toContain("LogsDirectory="); + expect(unit).not.toContain("/var/lib/"); + expect(unit).not.toContain("/var/log/"); + expect(unit).toContain( + "Environment=MIRA_DASHBOARD_PROJECT_ROOT=%h/projects/mira-dashboard" + ); + expect(unit).toContain( + "WorkingDirectory=%h/projects/mira-dashboard/production/releases/current" + ); + expect(unit).toMatch( + /StandardOutput=append:%h\/projects\/mira-dashboard\/production\/state\/logs\//u + ); + expect(unit).toMatch( + /StandardError=append:%h\/projects\/mira-dashboard\/production\/state\/logs\//u + ); + } + expect(web).toContain("MemoryHigh=768M"); + expect(web).toContain("MemoryMax=1G"); + expect(web).toContain("TasksMax=96"); + expect(web).toContain("CPUQuota=100%"); + expect(worker).toContain("MemoryHigh=768M"); + expect(worker).toContain("MemoryMax=1536M"); + expect(worker).toContain("TasksMax=128"); + expect(worker).toContain("CPUQuota=150%"); + }); +}); diff --git a/greenfield/scripts/delivery/systemdProductionServices.ts b/greenfield/scripts/delivery/systemdProductionServices.ts new file mode 100644 index 000000000..041d6daa4 --- /dev/null +++ b/greenfield/scripts/delivery/systemdProductionServices.ts @@ -0,0 +1,196 @@ +import { secondsToMilliseconds } from "date-fns"; +import * as v from "valibot"; + +import { healthReadinessPath } from "../../src/contracts/system.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import { installPublishedProductionSystemdUnits } from "./installProductionSystemdUnits.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; +import type { ProductionServiceController } from "./productionReleaseActivation.ts"; +import type { PublishedProductionRelease } from "./productionReleasePublication.ts"; +import type { InstalledProductionRuntime } from "./productionRuntime.ts"; +import { pointProductionProcessesAtRelease } from "./productionRuntimePointers.ts"; +import { + executeSystemctlProcess, + requireSuccessfulSystemctlProcess, + type SystemctlExecutor, +} from "./systemctlProcess.ts"; + +const systemdServiceFailureMessage = "Production service control failed"; +const readinessAttemptTimeoutMs = secondsToMilliseconds(2); +const readinessDeadlineMs = secondsToMilliseconds(30); +const readinessRetryMs = 250; +const webUnit = "mira-dashboard-web.service"; +const workerUnit = "mira-dashboard-worker.service"; +const systemctlExecutableDefault = "/usr/bin/systemctl"; +const loopbackReadinessUrlSchema = v.pipe( + v.string(), + v.url(), + v.check((input) => { + try { + const url = new URL(input); + return ( + url.protocol === "http:" && + url.hostname === "127.0.0.1" && + url.pathname === healthReadinessPath && + url.search.length === 0 && + url.hash.length === 0 && + url.username.length === 0 && + url.password.length === 0 + ); + } catch { + return false; + } + }, systemdServiceFailureMessage) +); + +export type { SystemctlProcessResult } from "./systemctlProcess.ts"; + +/** Explicit systemd and readiness boundaries for one project-local deployment. */ +export interface SystemdProductionServiceOptions { + readonly execute?: SystemctlExecutor; + readonly fetch?: (request: Request) => Promise; + readonly installUnits?: typeof installPublishedProductionSystemdUnits; + readonly readinessUrl: string; + readonly systemctlExecutable?: string; +} + +function serviceFailure(): Error { + return new Error(systemdServiceFailureMessage); +} + +function validateExecutable(executable: string): void { + if ( + !executable.startsWith("/") || + executable.includes("\0") || + executable.length > 4096 + ) { + throw serviceFailure(); + } +} + +async function requireSystemctlSuccess( + execute: SystemctlExecutor, + executable: string, + arguments_: readonly string[] +): Promise { + try { + await requireSuccessfulSystemctlProcess(execute, executable, arguments_); + } catch { + throw serviceFailure(); + } +} + +async function stopUnits( + execute: NonNullable, + executable: string +): Promise { + let failed = false; + try { + await requireSystemctlSuccess(execute, executable, ["--user", "stop", webUnit]); + } catch { + failed = true; + } + try { + await requireSystemctlSuccess(execute, executable, [ + "--user", + "stop", + workerUnit, + ]); + } catch { + failed = true; + } + if (failed) throw serviceFailure(); +} + +async function probeReadiness( + fetch_: NonNullable, + readinessUrl: string +): Promise { + try { + const response = await fetch_( + new Request(readinessUrl, { + cache: "no-store", + method: "HEAD", + signal: AbortSignal.timeout(readinessAttemptTimeoutMs), + }) + ); + return response.status === 200; + } catch { + return false; + } +} + +async function awaitReadiness( + fetch_: NonNullable, + readinessUrl: string +): Promise { + const deadline = Date.now() + readinessDeadlineMs; + while (Date.now() < deadline) { + if (await probeReadiness(fetch_, readinessUrl)) return; + await Bun.sleep(readinessRetryMs); + } + throw serviceFailure(); +} + +async function requireUnitsActive( + execute: NonNullable, + executable: string +): Promise { + for (const unit of [workerUnit, webUnit]) { + await requireSystemctlSuccess(execute, executable, [ + "--user", + "is-active", + "--quiet", + unit, + ]); + } +} + +/** + * Creates the idempotent user-systemd adapter used by crash-safe activation. + * Release/runtime pointers are changed only while the activation orchestrator has stopped writers. + * @param lease Active deployment lease captured by the controller. + * @param paths Exact project-local production paths. + * @param options Loopback readiness URL plus injectable process boundaries. + * @returns Service controller for worker-first start, web-first stop, and readiness proof. + */ +export function createSystemdProductionServiceController( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + options: SystemdProductionServiceOptions +): ProductionServiceController { + const readinessUrl = v.parse(loopbackReadinessUrlSchema, options.readinessUrl); + const executable = options.systemctlExecutable ?? systemctlExecutableDefault; + validateExecutable(executable); + const execute = options.execute ?? executeSystemctlProcess; + const fetch_ = options.fetch ?? fetch; + const installUnits = options.installUnits ?? installPublishedProductionSystemdUnits; + + return Object.freeze({ + prepare(release: PublishedProductionRelease): Promise { + return installUnits(lease, paths, release); + }, + async start( + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime + ): Promise { + await pointProductionProcessesAtRelease(lease, paths, release, runtime); + await requireSystemctlSuccess(execute, executable, [ + "--user", + "restart", + workerUnit, + ]); + await requireSystemctlSuccess(execute, executable, [ + "--user", + "restart", + webUnit, + ]); + }, + stop: () => stopUnits(execute, executable), + async verifyReady(): Promise { + await requireUnitsActive(execute, executable); + await awaitReadiness(fetch_, readinessUrl); + await requireUnitsActive(execute, executable); + }, + }); +} diff --git a/greenfield/scripts/frontendBuildArtifacts.ts b/greenfield/scripts/frontendBuildArtifacts.ts index 2e385bd9d..252ae1f53 100644 --- a/greenfield/scripts/frontendBuildArtifacts.ts +++ b/greenfield/scripts/frontendBuildArtifacts.ts @@ -16,6 +16,19 @@ const MINIMUM_COMPRESSION_BYTES = 512; const SCRIPT_TAG_PATTERN = /]*>[\s\S]*?<\/script(?:\s[^>]*)?>/giu; const SCRIPT_SOURCE_ATTRIBUTE_PATTERN = /\bsrc=(["'])([^"']+)\1/iu; const MODULE_SCRIPT_TYPE_PATTERN = /\btype=(["'])module\1/iu; +const frontendHtmlResourceAttributes = new Set([ + "action", + "background", + "cite", + "data", + "formaction", + "href", + "manifest", + "poster", + "src", + "xlink:href", +]); +const frontendHtmlSourceSetAttributes = new Set(["imagesrcset", "srcset"]); export interface FrontendBundleMeasurements { initialJavaScriptGzipBytes: number; @@ -347,3 +360,137 @@ export async function writePrecompressedFrontendAssets( return compressedFileCount; } + +function isSelfHostedResourceReference(value: string): boolean { + const reference = value.trim(); + if (reference.length === 0 || reference.includes("&") || reference.includes("\\")) { + return false; + } + if (/^[a-z][a-z\d+.-]*:/iu.test(reference) || reference.startsWith("//")) { + return false; + } + try { + const base = new URL("https://build.invalid/"); + const resolved = new URL(reference, base); + return ( + resolved.origin === base.origin && resolved.pathname.startsWith("/assets/") + ); + } catch { + return false; + } +} + +function isSelfHostedSourceSet(value: string): boolean { + const candidates = value.split(","); + return ( + candidates.length > 0 && + candidates.every((candidate) => { + const tokens = candidate.trim().split(/\s+/u); + if ( + tokens.length === 0 || + tokens.length > 2 || + !isSelfHostedResourceReference(tokens[0] ?? "") + ) { + return false; + } + const descriptor = tokens[1]; + return ( + descriptor === undefined || + /^\d+w$/u.test(descriptor) || + /^(?:\d+|\d*\.\d+)x$/u.test(descriptor) + ); + }) + ); +} + +/** + * Fails when generated HTML would require inline or third-party script/style CSP. + * @param indexPath Generated HTML entrypoint. + * @returns Promise that resolves when the entrypoint is self-hosted. + */ +export async function assertSelfHostedFrontendHtml(indexPath: string): Promise { + const html = await readFile(indexPath, "utf8"); + const scripts: Array<{ body: string; source: string | null; type: string | null }> = + []; + let styleCount = 0; + let hasInlineEventHandler = false; + let hasInlineSourceDocument = false; + let hasInlineStyle = false; + let hasNonSelfHostedResource = false; + let hasBaseElement = false; + const rewriter = new HTMLRewriter() + .on("*", { + element(element) { + for (const [name, value] of element.attributes) { + const normalizedName = name.toLowerCase(); + if (normalizedName.startsWith("on")) { + hasInlineEventHandler = true; + } else if (normalizedName === "srcdoc") { + hasInlineSourceDocument = true; + } else if (normalizedName === "style") { + hasInlineStyle = true; + } else if ( + frontendHtmlResourceAttributes.has(normalizedName) && + !isSelfHostedResourceReference(value) + ) { + hasNonSelfHostedResource = true; + } else if ( + frontendHtmlSourceSetAttributes.has(normalizedName) && + !isSelfHostedSourceSet(value) + ) { + hasNonSelfHostedResource = true; + } + } + }, + }) + .on("base", { + element() { + hasBaseElement = true; + }, + }) + .on("script", { + element(element) { + scripts.push({ + body: "", + source: element.getAttribute("src"), + type: element.getAttribute("type"), + }); + }, + text(text) { + const script = scripts.at(-1); + if (script) script.body += text.text; + }, + }) + .on("style", { + element() { + styleCount += 1; + }, + }); + rewriter.transform(html); + + if ( + scripts.length !== 1 || + styleCount > 0 || + hasInlineEventHandler || + hasInlineSourceDocument || + hasInlineStyle || + hasBaseElement + ) { + throw new Error( + "Frontend HTML must contain one external script and no inline code" + ); + } + + const script = scripts[0]!; + if ( + script.type !== "module" || + !script.source?.startsWith("/assets/") || + script.body.trim().length > 0 + ) { + throw new Error("Frontend HTML module script must be external and self-hosted"); + } + + if (hasNonSelfHostedResource) { + throw new Error("Frontend HTML cannot depend on a third-party CSP origin"); + } +} diff --git a/greenfield/scripts/generateDocs.ts b/greenfield/scripts/generateDocs.ts index 06da70a43..b0d1993ee 100644 --- a/greenfield/scripts/generateDocs.ts +++ b/greenfield/scripts/generateDocs.ts @@ -3,11 +3,11 @@ import path from "node:path"; import * as v from "valibot"; import { buildDocumentationArtifacts } from "./documentation/artifacts.ts"; -import { resolveDirectPackageVersions } from "./documentation/bunLock.ts"; import { checkDocumentationArtifacts, writeDocumentationArtifacts, } from "./documentation/files.ts"; +import { resolveDirectPackageVersions } from "./packageIdentity.ts"; const packageManifestSchema = v.object({ dependencies: v.record(v.string(), v.string()), diff --git a/greenfield/scripts/documentation/bunLock.test.ts b/greenfield/scripts/packageIdentity.test.ts similarity index 90% rename from greenfield/scripts/documentation/bunLock.test.ts rename to greenfield/scripts/packageIdentity.test.ts index 4970fef01..a7ad87b68 100644 --- a/greenfield/scripts/documentation/bunLock.test.ts +++ b/greenfield/scripts/packageIdentity.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { resolveDirectPackageVersions } from "./bunLock.ts"; +import { resolveDirectPackageVersions } from "./packageIdentity.ts"; -describe("Bun lockfile documentation facts", () => { +describe("direct package identity", () => { test("separates declared constraints from exact direct resolutions", () => { const lockfile = `{ "packages": { diff --git a/greenfield/scripts/documentation/bunLock.ts b/greenfield/scripts/packageIdentity.ts similarity index 97% rename from greenfield/scripts/documentation/bunLock.ts rename to greenfield/scripts/packageIdentity.ts index 5c687b440..22ff6ed1a 100644 --- a/greenfield/scripts/documentation/bunLock.ts +++ b/greenfield/scripts/packageIdentity.ts @@ -37,5 +37,5 @@ export function resolveDirectPackageVersions( resolvedVersions[name] = resolutionVersion(name, packageEntry[0]); } - return resolvedVersions; + return Object.freeze(resolvedVersions); } diff --git a/greenfield/scripts/runCoverage.test.ts b/greenfield/scripts/runCoverage.test.ts new file mode 100644 index 000000000..b9c499dfa --- /dev/null +++ b/greenfield/scripts/runCoverage.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; + +import { createCoverageTestArguments } from "./runCoverage.ts"; + +describe("coverage runner", () => { + test("runs every test target in one coverage process without a global DOM", () => { + const arguments_ = createCoverageTestArguments("/tmp/coverage-output"); + + expect(arguments_).toEqual([ + "--coverage", + "--coverage-reporter", + "text", + "--coverage-reporter", + "lcov", + "--coverage-dir", + "/tmp/coverage-output", + "scripts", + "src", + ]); + }); +}); diff --git a/greenfield/scripts/runCoverage.ts b/greenfield/scripts/runCoverage.ts index 8cd86dd60..a6abafa5c 100644 --- a/greenfield/scripts/runCoverage.ts +++ b/greenfield/scripts/runCoverage.ts @@ -10,6 +10,24 @@ const lcovPath = path.join(coverageDirectory, "lcov.info"); const coveredSourceRoots = Object.freeze(["src"]); const coverageTestTargets = Object.freeze(["scripts", "src"]); +/** + * Builds the exact Bun test arguments used by the coverage gate. + * @param outputDirectory Directory where Bun writes coverage artifacts. + * @returns Complete arguments after `bun test`. + */ +export function createCoverageTestArguments(outputDirectory: string): readonly string[] { + return Object.freeze([ + "--coverage", + "--coverage-reporter", + "text", + "--coverage-reporter", + "lcov", + "--coverage-dir", + outputDirectory, + ...coverageTestTargets, + ]); +} + /** @returns Completion after the exact stale LCOV artifact is absent. */ async function removeStaleLcov(): Promise { try { @@ -30,16 +48,7 @@ export async function runCoverage(): Promise { await removeStaleLcov(); const testExitCode = await runTestSuite( - [ - "--coverage", - "--coverage-reporter", - "text", - "--coverage-reporter", - "lcov", - "--coverage-dir", - coverageDirectory, - ...coverageTestTargets, - ], + createCoverageTestArguments(coverageDirectory), projectRoot ); if (testExitCode !== 0) return testExitCode; diff --git a/greenfield/scripts/sourceBoundaries/policy.test.ts b/greenfield/scripts/sourceBoundaries/policy.test.ts index 9cb4e8c16..2518a9b32 100644 --- a/greenfield/scripts/sourceBoundaries/policy.test.ts +++ b/greenfield/scripts/sourceBoundaries/policy.test.ts @@ -45,6 +45,26 @@ describe("source-boundary policy", () => { staticImport("../../shared/dateTime.ts") ) ).toBeUndefined(); + expect( + validateSourceImport( + "src/app/worker.ts", + staticImport("../server/database/runtime/databaseRuntimeOwner.ts") + ) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/app/databaseMaintenance.ts", + staticImport( + "../server/database/runtime/databaseCandidateMigrationOwner.ts" + ) + ) + ).toBeUndefined(); + expect( + validateSourceImport( + "src/app/databaseMaintenance.ts", + staticImport("../server/database/runtime/databaseService.ts") + ) + ).toBeUndefined(); expect(validateSourceFile("tailwind.config.ts")).toBeUndefined(); expect(validateSourceFile("drizzle.config.ts")).toBeUndefined(); expect( @@ -74,6 +94,12 @@ describe("source-boundary policy", () => { staticImport("../worker/adapters/systemd.ts") )?.message ).toContain("web-app may not import worker"); + expect( + validateSourceImport( + "src/worker/jobs/run.ts", + staticImport("../../server/domains/security/authenticationLifecycle.ts") + )?.message + ).toContain("worker may not import server"); expect( validateSourceImport("src/contracts/auth.ts", staticImport("./auth.test.ts")) ?.message diff --git a/greenfield/scripts/sourceBoundaries/policy.ts b/greenfield/scripts/sourceBoundaries/policy.ts index b7487ddba..f22766ad2 100644 --- a/greenfield/scripts/sourceBoundaries/policy.ts +++ b/greenfield/scripts/sourceBoundaries/policy.ts @@ -8,6 +8,7 @@ import { environmentSourceConsumers, environmentSourceFile, isTestPath, + isReviewedApplicationServerTarget, normalizeRepositoryPath, relativeImportTarget, sourceRole, @@ -317,6 +318,12 @@ export function validateSourceImport( "Imports may not target an unclassified src/app file" ); } + if ( + targetRole === "server" && + isReviewedApplicationServerTarget(normalizedImporter, target) + ) { + return undefined; + } if (!allowedTargets[importerRole].has(targetRole)) { return violation( normalizedImporter, diff --git a/greenfield/scripts/sourceBoundaries/sourceDiscovery.ts b/greenfield/scripts/sourceBoundaries/sourceDiscovery.ts index 9a102c0ae..92048a4a5 100644 --- a/greenfield/scripts/sourceBoundaries/sourceDiscovery.ts +++ b/greenfield/scripts/sourceBoundaries/sourceDiscovery.ts @@ -22,6 +22,7 @@ const reviewedRootDirectories: ReadonlySet = new Set([ "node_modules", "scripts", "src", + "systemd", ]); /** Discovered executable sources plus fail-closed repository-layout findings. */ diff --git a/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts b/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts index 3e697217d..d49ddf800 100644 --- a/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts +++ b/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts @@ -5,6 +5,7 @@ export type SourceRole = | "browser" | "contracts" | "environment-source" + | "maintenance-app" | "scripts" | "server" | "shared" @@ -24,8 +25,37 @@ const webApplicationFiles = new Set([ const applicationCompositionTestFiles: ReadonlySet = new Set([ "src/app/dashboardServer.test.ts", + "src/app/dashboardServerProcess.test.ts", + "src/app/databaseMaintenance.test.ts", "src/app/trpcHttpHandler.test.ts", "src/app/trpcRequestPolicy.test.ts", + "src/app/worker.test.ts", +]); + +const reviewedApplicationServerTargets: ReadonlyMap< + string, + ReadonlySet +> = new Map([ + [ + "src/app/worker.ts", + new Set([ + "src/server/platform/configuration/workerConfiguration.ts", + "src/server/database/runtime/databaseRuntimeOwner.ts", + "src/server/platform/filesystem/projectLayout.ts", + "src/server/platform/observability/projectFileLogSink.ts", + "src/server/platform/observability/structuredLogger.ts", + "src/server/platform/release/runtimeRelease.ts", + "src/server/platform/runtime/processSignals.ts", + ]), + ], + [ + "src/app/databaseMaintenance.ts", + new Set([ + "src/server/database/runtime/databaseCandidateMigrationOwner.ts", + "src/server/database/runtime/databaseService.ts", + "src/server/database/runtime/databaseSnapshot.ts", + ]), + ], ]); /** Composition-owned runtime environment source. */ @@ -37,11 +67,25 @@ export const environmentSourceConsumers: ReadonlySet = new Set([ "src/app/worker.ts", ]); +/** + * Whether an application composition edge names one exact reviewed server primitive. + * @param importer Normalized application composition-root path. + * @param target Normalized server target path. + * @returns Whether the exact edge was reviewed. + */ +export function isReviewedApplicationServerTarget( + importer: string, + target: string +): boolean { + return reviewedApplicationServerTargets.get(importer)?.has(target) === true; +} + /** Reviewed dependency-direction matrix for every source role. */ export const allowedTargets: Readonly>> = { browser: new Set(["browser", "contracts", "shared"]), contracts: new Set(["contracts", "shared"]), "environment-source": new Set(["shared"]), + "maintenance-app": new Set(["maintenance-app", "shared"]), scripts: new Set(["contracts", "scripts", "shared"]), server: new Set(["contracts", "server", "shared"]), shared: new Set(["shared"]), @@ -49,6 +93,7 @@ export const allowedTargets: Readonly "browser", "contracts", "environment-source", + "maintenance-app", "scripts", "server", "shared", @@ -109,6 +154,7 @@ export function sourceRole(filePath: string): SourceRole { } if (isTestPath(filePath)) return "test"; if (filePath === environmentSourceFile) return "environment-source"; + if (filePath === "src/app/databaseMaintenance.ts") return "maintenance-app"; if (webApplicationFiles.has(filePath)) return "web-app"; if (filePath === "src/app/worker.ts") return "worker-app"; if (filePath.startsWith("src/app/")) return "unclassified-app"; diff --git a/greenfield/scripts/testSupport/productionDeliveryFixture.ts b/greenfield/scripts/testSupport/productionDeliveryFixture.ts new file mode 100644 index 000000000..48915a79f --- /dev/null +++ b/greenfield/scripts/testSupport/productionDeliveryFixture.ts @@ -0,0 +1,237 @@ +import { + chmod, + cp, + mkdir, + mkdtemp, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + parseDatabaseMaintenanceArguments, + runDashboardDatabaseMaintenance, +} from "../../src/app/databaseMaintenance.ts"; +import type { BuildSourceIdentity } from "../buildSourceIdentity.ts"; +import { + buildDashboardRelease, + type ReleaseBuildCommand, +} from "../delivery/buildRelease.ts"; +import type { DatabaseMaintenanceProcessOutput } from "../delivery/databaseMaintenanceProcess.ts"; +import type { DashboardDeploymentLease } from "../delivery/deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "../delivery/productionDeliveryFilesystem.ts"; +import { + publishProductionRelease, + type PublishedProductionRelease, +} from "../delivery/productionReleasePublication.ts"; +import { + installProductionRuntime, + type InstalledProductionRuntime, +} from "../delivery/productionRuntime.ts"; +import type { ReleaseRuntimeIdentity } from "../delivery/releaseIdentity.ts"; + +const encoder = new TextEncoder(); + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600); + } + } +} + +/** + * Restores immutable fixture permissions and removes every registered temporary root. + * @param temporaryDirectories Mutable registry of fixture roots owned by the caller. + * @returns Completion after every registered root is absent. + */ +export async function removeProductionDeliveryFixtures( + temporaryDirectories: string[] +): Promise { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +} + +async function materializeCommandOutput( + command: ReleaseBuildCommand, + repositoryRoot: string +): Promise { + if (command === "bun run build:browser") { + await mkdir(path.join(repositoryRoot, "dist/browser/assets"), { + recursive: true, + }); + await Promise.all([ + writeFile(path.join(repositoryRoot, "dist/browser/index.html"), "dashboard"), + writeFile( + path.join(repositoryRoot, "dist/browser/assets/app-a1b2c3d4.js"), + "app" + ), + ]); + } + if (command === "bun run build:processes") { + await mkdir(path.join(repositoryRoot, "dist/processes"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(repositoryRoot, "dist/processes/databaseMaintenance.js"), + "database-maintenance" + ), + writeFile(path.join(repositoryRoot, "dist/processes/web.js"), "web"), + writeFile(path.join(repositoryRoot, "dist/processes/worker.js"), "worker"), + ]); + } +} + +/** + * Builds one deterministic immutable release fixture with the real manifest pipeline. + * @param sourceProjectRoot Greenfield repository root supplying reviewed inputs. + * @param commitSha Synthetic clean source identity for this fixture. + * @param runtimeIdentity Exact runtime identity encoded into the manifest. + * @param temporaryDirectories Mutable registry receiving the owned fixture root. + * @returns Immutable local release root. + */ +export async function createLocalReleaseFixture( + sourceProjectRoot: string, + commitSha: string, + runtimeIdentity: ReleaseRuntimeIdentity, + temporaryDirectories: string[] +): Promise { + const repositoryRoot = await mkdtemp( + path.join(tmpdir(), "mira-release-activation-source-") + ); + temporaryDirectories.push(repositoryRoot); + await Promise.all([ + cp( + path.join(sourceProjectRoot, "docs/generated"), + path.join(repositoryRoot, "docs/generated"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, "migrations"), + path.join(repositoryRoot, "migrations"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, "systemd"), + path.join(repositoryRoot, "systemd"), + { recursive: true } + ), + cp( + path.join(sourceProjectRoot, ".bun-version"), + path.join(repositoryRoot, ".bun-version") + ), + cp( + path.join(sourceProjectRoot, "bun.lock"), + path.join(repositoryRoot, "bun.lock") + ), + cp( + path.join(sourceProjectRoot, "package.json"), + path.join(repositoryRoot, "package.json") + ), + ]); + const cleanSource: BuildSourceIdentity = Object.freeze({ + commitSha, + state: "clean", + }); + const release = await buildDashboardRelease(repositoryRoot, { + resolveSourceIdentity: () => cleanSource, + runCommand: materializeCommandOutput, + runtimeIdentity, + }); + return release.releaseRoot; +} + +/** + * Creates one project-local production target plus a harmless runtime source fixture. + * @param temporaryDirectories Mutable registry receiving both owned roots. + * @returns Project root and executable-shaped runtime source path. + */ +export async function createProductionTargetFixture( + temporaryDirectories: string[] +): Promise<{ projectRoot: string; runtimeSource: string }> { + const projectRoot = await mkdtemp( + path.join(tmpdir(), "mira-release-activation-target-") + ); + const runtimeRoot = await mkdtemp( + path.join(tmpdir(), "mira-release-activation-runtime-") + ); + temporaryDirectories.push(projectRoot, runtimeRoot); + const runtimeSource = path.join(runtimeRoot, "bun"); + await writeFile(runtimeSource, "test-bun-runtime"); + await chmod(runtimeSource, 0o500); + return { projectRoot, runtimeSource }; +} + +/** + * Executes the real database maintenance composition in-process and preserves its wire format. + * @param command Exact child-process argv generated by delivery code. + * @returns Bounded process-shaped output. + */ +export async function executeDatabaseMaintenanceFixture( + command: readonly string[] +): Promise { + try { + const parsed = parseDatabaseMaintenanceArguments(command.slice(2)); + const result = await runDashboardDatabaseMaintenance(parsed); + return Object.freeze({ + exitCode: 0, + stderr: new Uint8Array(), + stdout: encoder.encode( + `${JSON.stringify( + result === undefined + ? { status: "MAINTAINED" } + : { ...result, status: "SNAPSHOT" } + )}\n` + ), + }); + } catch { + return Object.freeze({ + exitCode: 1, + stderr: encoder.encode("maintenance failed\n"), + stdout: new Uint8Array(), + }); + } +} + +/** + * Installs one runtime and publishes an ordered pair of release fixtures. + * @param lease Active fixture deployment lease. + * @param paths Prepared fixture production paths. + * @param sourceReleases Ordered local release roots. + * @param runtimeSource Harmless executable-shaped source file. + * @param runtimeIdentity Exact fixture runtime identity. + * @returns Verified installed runtime and published release pair. + */ +export async function publishProductionDeliveryFixtures( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + sourceReleases: readonly [string, string], + runtimeSource: string, + runtimeIdentity: ReleaseRuntimeIdentity +): Promise<{ + first: PublishedProductionRelease; + probeRuntime: () => Promise; + runtime: InstalledProductionRuntime; + second: PublishedProductionRelease; +}> { + const probeRuntime = () => Promise.resolve(runtimeIdentity); + const runtime = await installProductionRuntime(lease, paths, runtimeIdentity, { + probeRuntime, + sourceExecutable: runtimeSource, + }); + const [first, second] = await Promise.all([ + publishProductionRelease(lease, paths, sourceReleases[0], runtimeIdentity), + publishProductionRelease(lease, paths, sourceReleases[1], runtimeIdentity), + ]); + return { first, probeRuntime, runtime, second }; +} diff --git a/greenfield/scripts/testSupport/rejection.ts b/greenfield/scripts/testSupport/rejection.ts new file mode 100644 index 000000000..efc489d0c --- /dev/null +++ b/greenfield/scripts/testSupport/rejection.ts @@ -0,0 +1,16 @@ +/** + * Awaits one expected rejected promise without relying on matcher-specific thenables. + * @param promise Operation that must reject with an Error. + * @returns The observed Error after the operation has settled. + */ +export async function rejectionError(promise: PromiseLike): Promise { + try { + await promise; + } catch (error) { + if (error instanceof Error) return error; + throw new Error("Expected the promise to reject with an Error", { + cause: error, + }); + } + throw new Error("Expected the promise to reject"); +} diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 298849588..12b1ed25a 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -1,3 +1,8 @@ +import { realpath } from "node:fs/promises"; +import path from "node:path"; + +import { Redacted } from "effect"; + import { createAuthenticationLifecycleService } from "../server/domains/security/authenticationLifecycle.ts"; import { createAuthenticationLifecycleRepository } from "../server/domains/security/authenticationLifecycleRepository.ts"; import { @@ -14,14 +19,50 @@ import { createAutomationLifecycleRepository } from "../server/domains/security/ import { createMfaAccountLifecycleService } from "../server/domains/security/mfa/accountLifecycle.ts"; import { createMfaLifecycleRepository } from "../server/domains/security/mfa/lifecycleRepository.ts"; import { createMfaLoginLifecycleService } from "../server/domains/security/mfa/loginLifecycle.ts"; -import type { TotpSecretCipher } from "../server/domains/security/mfa/totpSecretCipher.ts"; +import { + createTotpSecretCipher, + type TotpSecretCipher, +} from "../server/domains/security/mfa/totpSecretCipher.ts"; import { createWebAuthnAdapter } from "../server/domains/security/mfa/webauthn/adapter.ts"; import type { WebAuthnRelyingPartyConfiguration } from "../server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts"; import { createRequestAuthenticator } from "../server/domains/security/requestAuthentication.ts"; import { createRequestAuthenticationRepository } from "../server/domains/security/requestAuthenticationRepository.ts"; +import { + type WebConfiguration, + parseWebConfiguration, +} from "../server/platform/configuration/webConfiguration.ts"; +import { + type DashboardProjectLayout, + resolveDashboardProjectLayout, +} from "../server/platform/filesystem/projectLayout.ts"; import { createGatewayCredentialVerifier } from "../server/platform/gateway/gatewayCredentialVerifier.ts"; -import type { DashboardApplicationRuntime } from "../server/platform/runtime/applicationRuntime.ts"; +import { + createProjectFileLogDestination, + type ProjectFileLogDestination, +} from "../server/platform/observability/projectFileLogSink.ts"; +import { + createStructuredLogger, + type StructuredLogger, +} from "../server/platform/observability/structuredLogger.ts"; +import { createReadinessController } from "../server/platform/readiness/readinessState.ts"; +import { + loadRuntimeRelease, + type RuntimeRelease, +} from "../server/platform/release/runtimeRelease.ts"; +import { + createDashboardApplicationRuntime, + type DashboardApplicationRuntime, +} from "../server/platform/runtime/applicationRuntime.ts"; +import { + createProcessTerminationController, + type ProcessTerminationController, +} from "../server/platform/runtime/processSignals.ts"; +import { + createFrontendAssetHandler, + type FrontendAssetHandler, +} from "../server/rawHttp/frontendAssets.ts"; import { parseBrowserOrigin } from "../server/rawHttp/requestSecurity.ts"; +import { environmentSource } from "./environmentSource.ts"; import { createServer, type ApplicationServer, type ServerOptions } from "./server.ts"; /** Production composition inputs above the generic Bun/tRPC server primitive. */ @@ -197,6 +238,7 @@ export async function createDashboardServer( authenticationLifecycle, automationSecurityLifecycle, browserOrigin, + frontendAssets: options.frontendAssets, gracefulShutdownTimeoutMs: options.gracefulShutdownTimeoutMs, hostname: "127.0.0.1", mfaAccountLifecycle, @@ -223,3 +265,204 @@ export async function createDashboardServer( throw error; } } + +/** Explicit inputs owned by the executable web composition root. */ +export interface DashboardWebProcessOptions { + readonly configurationSource: Readonly>; + readonly releaseRoot: string; +} + +/** Injectable web-process boundaries used by deterministic composition tests. */ +export interface DashboardWebProcessDependencies { + readonly createFrontendAssets: ( + release: RuntimeRelease + ) => Promise; + readonly createLogDestination: ( + logsDirectory: string, + processRole: "web" + ) => ProjectFileLogDestination; + readonly createRuntime: ( + configuration: WebConfiguration, + layout: DashboardProjectLayout, + release: RuntimeRelease, + logger: StructuredLogger + ) => DashboardApplicationRuntime; + readonly createServer: ( + options: DashboardServerOptions + ) => Promise; + readonly createTerminationController: () => ProcessTerminationController; + readonly createTotpCipher: (serializedKeyring: string) => Promise; + readonly loadRelease: ( + releasesDirectory: string, + releaseRoot: string, + processRole: "web" + ) => Promise; + readonly resolveProjectLayout: ( + projectRoot: string + ) => Promise; +} + +const defaultWebProcessDependencies = Object.freeze({ + createFrontendAssets: (release) => createFrontendAssetHandler(release), + createLogDestination: (logsDirectory, processRole) => + createProjectFileLogDestination(logsDirectory, processRole), + createRuntime: (_configuration, layout, release, logger) => + createDashboardApplicationRuntime({ + database: { + migrationsDirectory: path.join(release.releaseRoot, "migrations"), + releaseId: release.manifest.source.commitSha, + startupMode: "validate-only", + stateDirectory: layout.production.state.root, + }, + logger, + }), + createServer: createDashboardServer, + createTerminationController: createProcessTerminationController, + createTotpCipher: (serializedKeyring) => createTotpSecretCipher(serializedKeyring), + loadRelease: (releasesDirectory, releaseRoot, processRole) => + loadRuntimeRelease(releasesDirectory, releaseRoot, processRole), + resolveProjectLayout: resolveDashboardProjectLayout, +} satisfies DashboardWebProcessDependencies); + +function createWebLogger( + configuration: WebConfiguration, + release: RuntimeRelease, + destination: ProjectFileLogDestination +): StructuredLogger { + const runtime = release.manifest.runtime; + return createStructuredLogger({ + fallbackWrite: destination.fallbackWrite, + identity: { + bun: `${runtime.version}+${runtime.revision.slice(0, 9)}`, + pid: process.pid, + processRole: "web", + release: release.manifest.source.commitSha, + service: "mira-dashboard", + }, + minimumLevel: configuration.logLevel, + sink: destination.sink, + }); +} + +function normalizeWebProcessFailure(error: unknown): Error { + return error instanceof Error + ? error + : new Error("Dashboard web process failed", { cause: error }); +} + +/** + * Starts the validated Dashboard web runtime, promotes readiness, and drains on signals. + * @param options Typed environment source and exact immutable release root. + * @param dependencies Injectable host/runtime boundaries. + */ +export async function runDashboardWebProcess( + options: DashboardWebProcessOptions, + dependencies: DashboardWebProcessDependencies = defaultWebProcessDependencies +): Promise { + const configuration = parseWebConfiguration(options.configurationSource); + const layout = await dependencies.resolveProjectLayout(configuration.projectRoot); + const release = await dependencies.loadRelease( + layout.production.releases, + options.releaseRoot, + "web" + ); + const destination = dependencies.createLogDestination( + layout.production.state.logs, + "web" + ); + const logger = createWebLogger(configuration, release, destination); + const termination = dependencies.createTerminationController(); + let applicationRuntime: DashboardApplicationRuntime | undefined; + let server: ApplicationServer | undefined; + let serverOwnsRuntime = false; + let forceStopPromise: Promise | undefined; + let failure: Error | undefined; + const forceStop = (): void => { + if (!server) return; + forceStopPromise ??= server.stop(true).catch(() => {}); + }; + termination.forceSignal.addEventListener("abort", forceStop, { once: true }); + try { + const frontendAssets = await dependencies.createFrontendAssets(release); + const totpSecretCipher = await dependencies.createTotpCipher( + Redacted.value(configuration.totpKeyring) + ); + const readiness = createReadinessController(); + applicationRuntime = dependencies.createRuntime( + configuration, + layout, + release, + logger + ); + serverOwnsRuntime = true; + server = await dependencies.createServer({ + applicationRuntime, + browserOrigin: configuration.publicOrigin, + frontendAssets, + gatewayUrl: configuration.gatewayUrl, + port: configuration.port, + readiness, + recentAuthenticationWindowMs: configuration.recentAuthenticationWindowMs, + sessionIdleDurationMs: configuration.sessionIdleDurationMs, + totpSecretCipher, + trustedProxyAddresses: configuration.trustedProxyAddresses, + webAuthnRelyingParty: configuration.webAuthnRelyingParty, + }); + readiness.markReady(); + logger.info({ + component: "runtime", + event: "runtime.started", + outcome: "success", + }); + await termination.termination; + await server.stop(false); + await forceStopPromise; + } catch (error) { + failure = normalizeWebProcessFailure(error); + if (server) { + try { + await server.stop(true); + } catch { + // Preserve the initiating process failure. + } + } else if (!serverOwnsRuntime && applicationRuntime) { + try { + await applicationRuntime.dispose(); + } catch { + // Preserve the initiating process failure. + } + logger.fatal({ + component: "runtime", + event: "runtime.start_failed", + failure, + outcome: "server-error", + }); + logger.flush(); + } else if (!serverOwnsRuntime) { + logger.fatal({ + component: "runtime", + event: "runtime.start_failed", + failure, + outcome: "server-error", + }); + logger.flush(); + } + } finally { + termination.forceSignal.removeEventListener("abort", forceStop); + termination.dispose(); + } + if (failure !== undefined) throw failure; +} + +if (import.meta.main) { + try { + const releaseRoot = await realpath(path.resolve(import.meta.dir, "..")); + await runDashboardWebProcess({ + configurationSource: environmentSource("web"), + releaseRoot, + }); + } catch { + process.stderr.write("Mira Dashboard web startup failed\n"); + process.exitCode = 1; + } +} diff --git a/greenfield/src/app/dashboardServerProcess.test.ts b/greenfield/src/app/dashboardServerProcess.test.ts new file mode 100644 index 000000000..7bd02d30e --- /dev/null +++ b/greenfield/src/app/dashboardServerProcess.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; + +import { rejectionError } from "../../scripts/testSupport/rejection.ts"; +import { testTotpSecretCipher } from "../server/domains/security/testSupport/authentication.ts"; +import { deriveDashboardProjectLayout } from "../server/platform/filesystem/projectLayout.ts"; +import type { ProjectFileLogDestination } from "../server/platform/observability/projectFileLogSink.ts"; +import type { DashboardApplicationRuntime } from "../server/platform/runtime/applicationRuntime.ts"; +import type { ProcessTerminationController } from "../server/platform/runtime/processSignals.ts"; +import { + parseReleaseManifest, + releaseBuildCommands, + releaseProcessRoles, +} from "../shared/releaseManifest.ts"; +import { + type DashboardWebProcessDependencies, + runDashboardWebProcess, +} from "./dashboardServer.ts"; +import type { ApplicationServer } from "./server.ts"; + +const projectRoot = "/srv/mira-dashboard"; +const releaseId = "b".repeat(40); +const revision = "a".repeat(40); +const checksum = "c".repeat(64); +const layout = deriveDashboardProjectLayout(projectRoot); +const release = Object.freeze({ + manifest: parseReleaseManifest({ + artifacts: [{ bytes: 3, path: "server/web.js", sha256: checksum }], + buildCommands: [...releaseBuildCommands], + documentationSha256: checksum, + formatVersion: 1, + lockfileSha256: checksum, + migrations: [ + { + id: "20260804022252_dashboard-foundation", + migrationSha256: checksum, + snapshotSha256: checksum, + }, + ], + packages: [{ name: "effect", scope: "dependency", version: "4.0.0-beta.104" }], + processRoles: [...releaseProcessRoles], + runtime: { revision, version: "1.4.0" }, + source: { commitSha: releaseId, treeState: "clean" }, + }), + releaseRoot: `${layout.production.releases}/${releaseId}`, +}); + +function encodedKey(byte: number): string { + return Buffer.alloc(32, byte).toString("base64"); +} + +const serializedKeyring = JSON.stringify({ + activeKeyId: "primary", + formatVersion: 1, + keys: [{ id: "primary", keyBase64: encodedKey(1) }], +}); + +const processOptions = Object.freeze({ + configurationSource: { + MIRA_DASHBOARD_LOG_LEVEL: "debug", + MIRA_DASHBOARD_PROJECT_ROOT: projectRoot, + MIRA_DASHBOARD_PUBLIC_ORIGIN: "https://dashboard.example.com", + MIRA_DASHBOARD_RECENT_AUTH_MINUTES: "10", + MIRA_DASHBOARD_SESSION_IDLE_MINUTES: "30", + MIRA_DASHBOARD_TOTP_KEYRING: serializedKeyring, + MIRA_DASHBOARD_TRUSTED_PROXY_IPS: "127.0.0.1,::1", + MIRA_DASHBOARD_WEBAUTHN_ORIGINS: "https://dashboard.example.com", + MIRA_DASHBOARD_WEBAUTHN_RP_ID: "example.com", + MIRA_DASHBOARD_WEBAUTHN_RP_NAME: "Mira Dashboard", + NODE_ENV: "production", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + PORT: "3100", + }, + releaseRoot: release.releaseRoot, +}); + +function unhandledFrontendAsset(): Promise { + return Promise.resolve(void 0); +} + +function processFixture(totpFailure?: Error) { + const events: string[] = []; + const logLines: string[] = []; + const destination = Object.freeze({ + fallbackWrite() { + events.push("log-fallback"); + }, + sink: Object.freeze({ + flush(): undefined { + events.push("log-flush"); + }, + write(line: string): undefined { + logLines.push(line); + }, + }), + } satisfies ProjectFileLogDestination); + const termination = Object.freeze({ + dispose() { + events.push("signals-dispose"); + }, + forceSignal: new AbortController().signal, + termination: Promise.resolve("SIGTERM" as const), + } satisfies ProcessTerminationController); + let runtime: DashboardApplicationRuntime | undefined; + const dependencies = Object.freeze({ + createFrontendAssets(observedRelease) { + expect(observedRelease).toBe(release); + events.push("frontend-create"); + return Promise.resolve(unhandledFrontendAsset); + }, + createLogDestination(logsDirectory, processRole) { + events.push(`logs:${processRole}:${logsDirectory}`); + return destination; + }, + createRuntime(_configuration, observedLayout, observedRelease, logger) { + expect(observedLayout).toBe(layout); + expect(observedRelease).toBe(release); + events.push("runtime-create"); + runtime = Object.freeze({ logger }) as DashboardApplicationRuntime; + return runtime; + }, + createServer(options) { + const observedRuntime = runtime; + if (!observedRuntime) throw new Error("Expected composed runtime"); + expect(options.applicationRuntime).toBe(observedRuntime); + expect(options.readiness.isReady()).toBe(false); + expect(options.browserOrigin).toBe("https://dashboard.example.com"); + expect(options.frontendAssets).toBeFunction(); + expect(options.port).toBe(3100); + events.push("server-create"); + const server = Object.freeze({ + port: 3100, + stop(force = false) { + expect(options.readiness.isReady()).toBe(true); + events.push(`server-stop:${force ? "force" : "graceful"}`); + options.readiness.markUnavailable(); + options.applicationRuntime.logger.flush(); + return Promise.resolve(); + }, + url: new URL("http://127.0.0.1:3100/"), + } satisfies ApplicationServer); + return Promise.resolve(server); + }, + createTerminationController() { + events.push("signals-create"); + return termination; + }, + createTotpCipher(serialized) { + events.push("totp-create"); + expect(serialized).toBe(serializedKeyring); + if (totpFailure) return Promise.reject(totpFailure); + return Promise.resolve(testTotpSecretCipher); + }, + loadRelease(releasesDirectory, releaseRoot, processRole) { + events.push(`release:${processRole}:${releasesDirectory}:${releaseRoot}`); + return Promise.resolve(release); + }, + resolveProjectLayout(observedProjectRoot) { + events.push(`layout:${observedProjectRoot}`); + return Promise.resolve(layout); + }, + } satisfies DashboardWebProcessDependencies); + return { dependencies, events, logLines }; +} + +describe("Dashboard web process", () => { + test("starts unavailable, promotes only after composition, and drains gracefully", async () => { + const fixture = processFixture(); + + await runDashboardWebProcess(processOptions, fixture.dependencies); + + expect(fixture.events).toEqual([ + `layout:${projectRoot}`, + `release:web:${layout.production.releases}:${release.releaseRoot}`, + `logs:web:${layout.production.state.logs}`, + "signals-create", + "frontend-create", + "totp-create", + "runtime-create", + "server-create", + "server-stop:graceful", + "log-flush", + "signals-dispose", + ]); + expect( + fixture.logLines.map((line) => (JSON.parse(line) as { event: string }).event) + ).toEqual(["runtime.started"]); + }); + + test("cleans pre-listener ownership and redacts a startup failure", async () => { + const failure = new Error("private totp startup failure"); + const fixture = processFixture(failure); + + const observedFailure = await rejectionError( + runDashboardWebProcess(processOptions, fixture.dependencies) + ); + + expect(observedFailure).toBe(failure); + + expect(fixture.events.slice(-2)).toEqual(["log-flush", "signals-dispose"]); + const fatal = JSON.parse(fixture.logLines.at(-1) ?? "null") as { + event: string; + }; + expect(fatal.event).toBe("runtime.start_failed"); + expect(JSON.stringify(fatal)).not.toContain("private totp startup failure"); + }); +}); diff --git a/greenfield/src/app/databaseMaintenance.test.ts b/greenfield/src/app/databaseMaintenance.test.ts new file mode 100644 index 000000000..718e78295 --- /dev/null +++ b/greenfield/src/app/databaseMaintenance.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; + +import { rejectionError } from "../../scripts/testSupport/rejection.ts"; +import { + parseDatabaseMaintenanceArguments, + runDashboardDatabaseMaintenance, +} from "./databaseMaintenance.ts"; + +const releaseId = "a".repeat(40); +const options = Object.freeze({ + migrationsDirectory: "/srv/mira/releases/release/migrations", + operation: "migrate-candidate" as const, + releaseId, + stateDirectory: "/srv/mira/production/state/candidate", +}); + +function unexpectedSnapshot(): Promise { + return Promise.reject(new Error("Unexpected snapshot call")); +} + +describe("Dashboard database maintenance process", () => { + test("parses one exact order-independent argument set", () => { + expect( + parseDatabaseMaintenanceArguments([ + "--operation=migrate-candidate", + `--release=${releaseId}`, + "--state=/srv/mira/production/state/candidate", + "--migrations=/srv/mira/releases/release/migrations", + ]) + ).toEqual(options); + + expect(() => + parseDatabaseMaintenanceArguments([ + "--operation=migrate-candidate", + `--release=${releaseId}`, + `--release=${releaseId}`, + "--state=relative", + ]) + ).toThrow("Usage:"); + }); + + test("initializes and always disposes the isolated Effect runtime", async () => { + const events: string[] = []; + await runDashboardDatabaseMaintenance(options, { + createSnapshot: unexpectedSnapshot, + createRuntime(observed) { + const { operation: _operation, ...expected } = options; + expect(observed).toEqual(expected); + return Object.freeze({ + dispose() { + events.push("dispose"); + return Promise.resolve(); + }, + initialize() { + events.push("initialize"); + return Promise.resolve(); + }, + }); + }, + }); + + expect(events).toEqual(["initialize", "dispose"]); + }); + + test("preserves initialization failure while still disposing", async () => { + const failure = new Error("private database initialization failure"); + const events: string[] = []; + const observed = await rejectionError( + runDashboardDatabaseMaintenance(options, { + createSnapshot: unexpectedSnapshot, + createRuntime() { + return Object.freeze({ + dispose() { + events.push("dispose"); + return Promise.reject( + new Error("secondary disposal failure") + ); + }, + initialize() { + events.push("initialize"); + return Promise.reject(failure); + }, + }); + }, + }) + ); + + expect(observed).toBe(failure); + expect(events).toEqual(["initialize", "dispose"]); + }); + + test("routes an exact expected-state snapshot without constructing a runtime", async () => { + const transitionId = Bun.randomUUIDv7(); + const command = parseDatabaseMaintenanceArguments([ + "--operation=snapshot", + "--expected-state=absent", + `--transition=${transitionId}`, + "--state=/srv/mira/production/state", + ]); + const result = await runDashboardDatabaseMaintenance(command, { + createRuntime() { + throw new Error("Unexpected runtime construction"); + }, + createSnapshot(observed) { + expect(observed).toEqual({ + expectedState: "absent", + stateDirectory: "/srv/mira/production/state", + transitionId, + }); + return Promise.resolve({ state: "absent", transitionId }); + }, + }); + + expect(result).toEqual({ state: "absent", transitionId }); + }); +}); diff --git a/greenfield/src/app/databaseMaintenance.ts b/greenfield/src/app/databaseMaintenance.ts new file mode 100644 index 000000000..352af8d42 --- /dev/null +++ b/greenfield/src/app/databaseMaintenance.ts @@ -0,0 +1,195 @@ +import path from "node:path"; + +import { Effect } from "effect"; +import * as v from "valibot"; + +import { + createDatabaseCandidateMigrationOwner, + type DatabaseRuntimeOwner, +} from "../server/database/runtime/databaseCandidateMigrationOwner.ts"; +import type { DatabaseCandidateMigrationLayerOptions } from "../server/database/runtime/databaseService.ts"; +import { + createVerifiedDatabaseSnapshot, + type DatabaseSnapshotOptions, + type DatabaseSnapshotResult, +} from "../server/database/runtime/databaseSnapshot.ts"; +import { fullCommitShaSchema, lowercaseUuidV7Schema } from "../shared/validation.ts"; + +const databaseMaintenanceFailureMessage = "Dashboard database maintenance failed"; +const databaseMaintenanceUsage = + "Usage: bun database-maintenance.js --operation=migrate-candidate|snapshot with the exact operation arguments"; +const absolutePathSchema = v.pipe( + v.string(), + v.maxLength(4096), + v.check( + (value) => + path.isAbsolute(value) && + path.resolve(value) === value && + !value.includes("\0"), + databaseMaintenanceUsage + ) +); +const candidateMigrationArgumentsSchema = v.strictObject({ + operation: v.literal("migrate-candidate"), + migrationsDirectory: absolutePathSchema, + releaseId: fullCommitShaSchema(databaseMaintenanceUsage), + stateDirectory: absolutePathSchema, +}); +const snapshotArgumentsSchema = v.variant("expectedState", [ + v.strictObject({ + expectedState: v.literal("absent"), + operation: v.literal("snapshot"), + stateDirectory: absolutePathSchema, + transitionId: lowercaseUuidV7Schema(databaseMaintenanceUsage), + }), + v.strictObject({ + expectedState: v.literal("present"), + migrationsDirectory: absolutePathSchema, + operation: v.literal("snapshot"), + releaseId: fullCommitShaSchema(databaseMaintenanceUsage), + stateDirectory: absolutePathSchema, + transitionId: lowercaseUuidV7Schema(databaseMaintenanceUsage), + }), +]); + +/** Validated command for candidate migration or live-state snapshot creation. */ +export type DashboardDatabaseMaintenanceCommand = + | Readonly> + | Readonly>; + +/** Injectable retained-runtime boundary used by deterministic lifecycle tests. */ +export interface DashboardDatabaseMaintenanceDependencies { + readonly createRuntime: ( + options: DatabaseCandidateMigrationLayerOptions + ) => DatabaseRuntimeOwner; + readonly createSnapshot: ( + options: DatabaseSnapshotOptions + ) => Promise; +} + +const defaultDependencies = Object.freeze({ + createRuntime: createDatabaseCandidateMigrationOwner, + createSnapshot: (options: DatabaseSnapshotOptions) => + Effect.runPromise(createVerifiedDatabaseSnapshot(options)), +} satisfies DashboardDatabaseMaintenanceDependencies); + +function databaseMaintenanceFailure(error?: unknown): Error { + return error instanceof Error + ? error + : new Error(databaseMaintenanceFailureMessage, { cause: error }); +} + +function readArgument(arguments_: readonly string[], name: string): string | undefined { + const prefix = `--${name}=`; + const matches = arguments_.filter((argument) => argument.startsWith(prefix)); + if (matches.length !== 1) return undefined; + return matches[0]?.slice(prefix.length); +} + +/** + * Parses the exact maintenance command arguments without reading ambient configuration. + * @param arguments_ Bun arguments after the executable entrypoint. + * @returns Frozen database runtime options. + */ +export function parseDatabaseMaintenanceArguments( + arguments_: readonly string[] +): DashboardDatabaseMaintenanceCommand { + const operation = readArgument(arguments_, "operation"); + const expectedState = readArgument(arguments_, "expected-state"); + let candidate: unknown; + if (operation === "migrate-candidate" && arguments_.length === 4) { + candidate = { + migrationsDirectory: readArgument(arguments_, "migrations"), + operation, + releaseId: readArgument(arguments_, "release"), + stateDirectory: readArgument(arguments_, "state"), + }; + } else if ( + operation === "snapshot" && + expectedState === "absent" && + arguments_.length === 4 + ) { + candidate = { + expectedState, + operation, + stateDirectory: readArgument(arguments_, "state"), + transitionId: readArgument(arguments_, "transition"), + }; + } else if ( + operation === "snapshot" && + expectedState === "present" && + arguments_.length === 6 + ) { + candidate = { + expectedState, + migrationsDirectory: readArgument(arguments_, "migrations"), + operation, + releaseId: readArgument(arguments_, "release"), + stateDirectory: readArgument(arguments_, "state"), + transitionId: readArgument(arguments_, "transition"), + }; + } else { + throw new TypeError(databaseMaintenanceUsage); + } + const schema = + operation === "migrate-candidate" + ? candidateMigrationArgumentsSchema + : snapshotArgumentsSchema; + const parsed = v.safeParse(schema, candidate, { + abortEarly: true, + }); + if (!parsed.success) throw new TypeError(databaseMaintenanceUsage); + return Object.freeze(parsed.output); +} + +/** + * Opens, migrates or validates, checkpoints, and closes one isolated database scope. + * @param command Exact candidate-migration or snapshot command. + * @param dependencies Injectable runtime owner boundary. + * @returns Snapshot result for snapshot commands; otherwise completion after migration. + */ +export async function runDashboardDatabaseMaintenance( + command: DashboardDatabaseMaintenanceCommand, + dependencies: DashboardDatabaseMaintenanceDependencies = defaultDependencies +): Promise { + if (command.operation === "snapshot") { + const { operation: _operation, ...options } = command; + return dependencies.createSnapshot(options); + } + const { operation: _operation, ...options } = command; + const runtime = dependencies.createRuntime(options); + let failure: Error | undefined; + try { + await runtime.initialize(); + } catch (error) { + failure = databaseMaintenanceFailure(error); + } + try { + await runtime.dispose(); + } catch (error) { + failure ??= databaseMaintenanceFailure(error); + } + if (failure) throw failure; + return undefined; +} + +if (import.meta.main) { + try { + const options = parseDatabaseMaintenanceArguments(Bun.argv.slice(2)); + const result = await runDashboardDatabaseMaintenance(options); + process.stdout.write( + `${JSON.stringify( + result === undefined + ? { status: "MAINTAINED" } + : { ...result, status: "SNAPSHOT" } + )}\n` + ); + } catch (error) { + const message = + error instanceof TypeError + ? error.message + : databaseMaintenanceFailureMessage; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/greenfield/src/app/server.ts b/greenfield/src/app/server.ts index b0f549a25..3e864266b 100644 --- a/greenfield/src/app/server.ts +++ b/greenfield/src/app/server.ts @@ -9,6 +9,7 @@ import type { MfaLoginLifecycleService } from "../server/domains/security/mfa/lo import type { ReadinessController } from "../server/platform/readiness/readinessState.ts"; import type { ApplicationRuntime } from "../server/platform/runtime/applicationRuntime.ts"; import { readRuntimeIdentity } from "../server/platform/runtime/readRuntimeIdentity.ts"; +import type { FrontendAssetHandler } from "../server/rawHttp/frontendAssets.ts"; import { type HealthProbeMethod, livenessResponse, @@ -61,7 +62,15 @@ async function disposeRuntimeAndFlush( function responseWithRequestId(response: Response, requestId: string): Response { const headers = new Headers(response.headers); - headers.set("x-request-id", requestId); + const cacheDirectives = new Set( + (headers.get("cache-control") ?? "") + .toLowerCase() + .split(",") + .map((directive) => directive.trim()) + ); + if (!(cacheDirectives.has("public") && cacheDirectives.has("immutable"))) { + headers.set("x-request-id", requestId); + } return new Response(response.body, { headers, status: response.status, @@ -116,6 +125,8 @@ export interface ServerOptions { readonly authenticateCredential: AuthenticateCredential; /** Explicit public browser origin when TLS terminates at a trusted proxy. */ readonly browserOrigin?: string; + /** Manifest-indexed browser artifacts and controlled SPA navigation. */ + readonly frontendAssets?: FrontendAssetHandler; /** Graceful request-drain budget before active connections are forced closed. */ readonly gracefulShutdownTimeoutMs?: number; readonly hostname?: string; @@ -194,7 +205,13 @@ export async function createServer(options: ServerOptions): Promise { + test("validates its release and database before waiting for shutdown", async () => { + const fixture = processFixture(); + + await runDashboardWorkerProcess(processOptions, fixture.dependencies); + + expect(fixture.events).toEqual([ + `layout:${projectRoot}`, + `release:worker:${layout.production.releases}:${release.releaseRoot}`, + `logs:worker:${layout.production.state.logs}`, + "signals-create", + "runtime-create", + "runtime-initialize", + "runtime-dispose", + "signals-dispose", + "log-flush", + ]); + expect( + fixture.logLines.map((line) => (JSON.parse(line) as { event: string }).event) + ).toEqual(["runtime.started", "runtime.stopped"]); + }); + + test("disposes partial ownership and reports a redacted startup failure", () => { + const failure = new Error("private worker failure"); + const fixture = processFixture(failure); + + expect( + runDashboardWorkerProcess(processOptions, fixture.dependencies) + ).rejects.toBe(failure); + + expect(fixture.events).toContain("runtime-dispose"); + expect(fixture.events.slice(-2)).toEqual(["signals-dispose", "log-flush"]); + const fatal = JSON.parse(fixture.logLines.at(-1) ?? "null") as { + event: string; + failure?: unknown; + }; + expect(fatal.event).toBe("runtime.start_failed"); + expect(JSON.stringify(fatal)).not.toContain("private worker failure"); + }); +}); diff --git a/greenfield/src/app/worker.ts b/greenfield/src/app/worker.ts new file mode 100644 index 000000000..7fe4720bd --- /dev/null +++ b/greenfield/src/app/worker.ts @@ -0,0 +1,176 @@ +import { realpath } from "node:fs/promises"; +import path from "node:path"; + +import { createDatabaseRuntimeOwner } from "../server/database/runtime/databaseRuntimeOwner.ts"; +import { + parseWorkerConfiguration, + type WorkerConfiguration, +} from "../server/platform/configuration/workerConfiguration.ts"; +import { + type DashboardProjectLayout, + resolveDashboardProjectLayout, +} from "../server/platform/filesystem/projectLayout.ts"; +import { + createProjectFileLogDestination, + type ProjectFileLogDestination, +} from "../server/platform/observability/projectFileLogSink.ts"; +import { + createStructuredLogger, + type StructuredLogger, +} from "../server/platform/observability/structuredLogger.ts"; +import { + loadRuntimeRelease, + type RuntimeRelease, +} from "../server/platform/release/runtimeRelease.ts"; +import { + createProcessTerminationController, + type ProcessTerminationController, +} from "../server/platform/runtime/processSignals.ts"; +import { type DashboardWorkerRuntime } from "../worker/runtime.ts"; +import { environmentSource } from "./environmentSource.ts"; + +/** Explicit inputs owned by the executable worker composition root. */ +export interface DashboardWorkerProcessOptions { + readonly configurationSource: Readonly>; + readonly releaseRoot: string; +} + +/** Injectable process boundaries used by deterministic composition tests. */ +export interface DashboardWorkerProcessDependencies { + readonly createLogDestination: ( + logsDirectory: string, + processRole: "worker" + ) => ProjectFileLogDestination; + readonly createRuntime: ( + configuration: WorkerConfiguration, + layout: DashboardProjectLayout, + release: RuntimeRelease, + logger: StructuredLogger + ) => DashboardWorkerRuntime; + readonly createTerminationController: () => ProcessTerminationController; + readonly loadRelease: ( + releasesDirectory: string, + releaseRoot: string, + processRole: "worker" + ) => Promise; + readonly resolveProjectLayout: ( + projectRoot: string + ) => Promise; +} + +const defaultDependencies = Object.freeze({ + createLogDestination: (logsDirectory, processRole) => + createProjectFileLogDestination(logsDirectory, processRole), + createRuntime: (_configuration, layout, release) => + createDatabaseRuntimeOwner({ + migrationsDirectory: path.join(release.releaseRoot, "migrations"), + releaseId: release.manifest.source.commitSha, + startupMode: "validate-only", + stateDirectory: layout.production.state.root, + }), + createTerminationController: createProcessTerminationController, + loadRelease: (releasesDirectory, releaseRoot, processRole) => + loadRuntimeRelease(releasesDirectory, releaseRoot, processRole), + resolveProjectLayout: resolveDashboardProjectLayout, +} satisfies DashboardWorkerProcessDependencies); + +function createWorkerLogger( + configuration: WorkerConfiguration, + release: RuntimeRelease, + destination: ProjectFileLogDestination +): StructuredLogger { + const runtime = release.manifest.runtime; + return createStructuredLogger({ + fallbackWrite: destination.fallbackWrite, + identity: { + bun: `${runtime.version}+${runtime.revision.slice(0, 9)}`, + pid: process.pid, + processRole: "worker", + release: release.manifest.source.commitSha, + service: "mira-dashboard", + }, + minimumLevel: configuration.logLevel, + sink: destination.sink, + }); +} + +function normalizeWorkerProcessFailure(error: unknown): Error { + return error instanceof Error + ? error + : new Error("Dashboard worker process failed", { cause: error }); +} + +/** + * Runs the database-validating worker lifecycle until a process signal requests shutdown. + * Job capabilities are intentionally absent until their Phase 3 ports are composed. + * @param options Typed environment source and exact immutable release root. + * @param dependencies Injectable host/runtime boundaries. + */ +export async function runDashboardWorkerProcess( + options: DashboardWorkerProcessOptions, + dependencies: DashboardWorkerProcessDependencies = defaultDependencies +): Promise { + const configuration = parseWorkerConfiguration(options.configurationSource); + const layout = await dependencies.resolveProjectLayout(configuration.projectRoot); + const release = await dependencies.loadRelease( + layout.production.releases, + options.releaseRoot, + "worker" + ); + const destination = dependencies.createLogDestination( + layout.production.state.logs, + "worker" + ); + const logger = createWorkerLogger(configuration, release, destination); + const termination = dependencies.createTerminationController(); + let runtime: DashboardWorkerRuntime | undefined; + let failure: Error | undefined; + try { + runtime = dependencies.createRuntime(configuration, layout, release, logger); + await runtime.initialize(); + logger.info({ + component: "runtime", + event: "runtime.started", + outcome: "success", + }); + await termination.termination; + await runtime.dispose(); + logger.info({ + component: "runtime", + event: "runtime.stopped", + outcome: "success", + }); + } catch (error) { + failure = normalizeWorkerProcessFailure(error); + if (runtime) { + try { + await runtime.dispose(); + } catch { + // Preserve the initiating process failure. + } + } + logger.fatal({ + component: "runtime", + event: "runtime.start_failed", + failure, + outcome: "server-error", + }); + } finally { + termination.dispose(); + logger.flush(); + } + if (failure !== undefined) throw failure; +} + +if (import.meta.main) { + try { + const releaseRoot = await realpath(path.resolve(import.meta.dir, "..")); + await runDashboardWorkerProcess({ + configurationSource: environmentSource("worker"), + releaseRoot, + }); + } catch { + process.stderr.write("Mira Dashboard worker startup failed\n"); + process.exitCode = 1; + } +} diff --git a/greenfield/src/browser/application.test.tsx b/greenfield/src/browser/application.test.tsx new file mode 100644 index 000000000..b57c857b2 --- /dev/null +++ b/greenfield/src/browser/application.test.tsx @@ -0,0 +1,67 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; + +import { createMemoryHistory } from "@tanstack/react-router"; + +import { DashboardBrowserApplication } from "./application.tsx"; +import { createDashboardQueryClient } from "./queryClient.ts"; +import { createDashboardRouter } from "./router.tsx"; +import { acquireBrowserTestEnvironment } from "./testSupport/browserTestEnvironment.ts"; + +const browserEnvironment = await acquireBrowserTestEnvironment(); +const { cleanup, render, screen } = await import("@testing-library/react"); + +afterEach(() => { + cleanup(); +}); + +afterAll(async () => { + await browserEnvironment.release(); +}); + +describe("Dashboard browser application", () => { + test("renders the accessible overview through the real providers and router", async () => { + const queryClient = createDashboardQueryClient(); + const router = createDashboardRouter( + createMemoryHistory({ initialEntries: ["/"] }) + ); + + try { + render( + + ); + + const heading = await screen.findByRole("heading", { + level: 1, + name: "Mira Dashboard", + }); + expect(heading.textContent).toBe("Mira Dashboard"); + expect( + screen.getByRole("link", { name: "Skip to content" }).getAttribute("href") + ).toBe("#dashboard-content"); + expect( + screen.getByRole("status", { name: "Application status" }).textContent + ).toContain("Application shell ready"); + expect(queryClient.getQueryCache().getAll()).toEqual([]); + } finally { + queryClient.clear(); + } + }); + + test("creates isolated query caches with the reviewed browser defaults", () => { + const first = createDashboardQueryClient(); + const second = createDashboardQueryClient(); + + expect(first).not.toBe(second); + expect(first.getDefaultOptions()).toMatchObject({ + mutations: { retry: false }, + queries: { + gcTime: 300_000, + refetchOnWindowFocus: false, + retry: 2, + staleTime: 30_000, + }, + }); + first.clear(); + second.clear(); + }); +}); diff --git a/greenfield/src/browser/application.tsx b/greenfield/src/browser/application.tsx new file mode 100644 index 000000000..89db717b2 --- /dev/null +++ b/greenfield/src/browser/application.tsx @@ -0,0 +1,59 @@ +import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; + +import type { DashboardRouter } from "./router.tsx"; + +function DashboardErrorFallback({ resetErrorBoundary }: FallbackProps) { + return ( +
+
+

Application error

+

+ Dashboard unavailable +

+

+ The browser application could not finish rendering. No private error + details were displayed. +

+ +
+
+ ); +} + +/** Browser application dependencies constructed once by `main.tsx`. */ +export interface DashboardBrowserApplicationProps { + readonly queryClient: QueryClient; + readonly router: DashboardRouter; +} + +/** + * Renders the root error, query, and routing boundaries. + * @returns The composed browser application. + */ +export function DashboardBrowserApplication({ + queryClient, + router, +}: DashboardBrowserApplicationProps) { + return ( + + + + + + ); +} diff --git a/greenfield/src/browser/bootstrap.tsx b/greenfield/src/browser/bootstrap.tsx new file mode 100644 index 000000000..395fe1506 --- /dev/null +++ b/greenfield/src/browser/bootstrap.tsx @@ -0,0 +1,14 @@ +import { DashboardBrowserApplication } from "./application.tsx"; +import { createDashboardQueryClient } from "./queryClient.ts"; +import { createDashboardRouter } from "./router.tsx"; + +const queryClient = createDashboardQueryClient(); +const router = createDashboardRouter(); + +/** + * Owns browser services constructed once for the application lifetime. + * @returns The composed Dashboard browser application. + */ +export default function DashboardBrowserBootstrap() { + return ; +} diff --git a/greenfield/src/browser/index.css b/greenfield/src/browser/index.css new file mode 100644 index 000000000..335dd7429 --- /dev/null +++ b/greenfield/src/browser/index.css @@ -0,0 +1,25 @@ +@import "tailwindcss"; + +:root { + color-scheme: dark; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + background: #020617; +} + +body { + min-width: 20rem; + min-height: 100vh; + margin: 0; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} diff --git a/greenfield/src/browser/index.html b/greenfield/src/browser/index.html new file mode 100644 index 000000000..83bdc350c --- /dev/null +++ b/greenfield/src/browser/index.html @@ -0,0 +1,14 @@ + + + + + + + Mira Dashboard + + + +
+ + + diff --git a/greenfield/src/browser/lazyBootstrap.tsx b/greenfield/src/browser/lazyBootstrap.tsx new file mode 100644 index 000000000..12d74a11a --- /dev/null +++ b/greenfield/src/browser/lazyBootstrap.tsx @@ -0,0 +1,11 @@ +import { lazy } from "react"; + +const DashboardBrowserBootstrap = lazy(() => import("./bootstrap.tsx")); + +/** + * Defers the application providers and route graph behind the minimal document bootstrap. + * @returns The lazy Dashboard application boundary. + */ +export default function LazyDashboardBrowserBootstrap() { + return ; +} diff --git a/greenfield/src/browser/main.test.tsx b/greenfield/src/browser/main.test.tsx new file mode 100644 index 000000000..bb4835a61 --- /dev/null +++ b/greenfield/src/browser/main.test.tsx @@ -0,0 +1,34 @@ +import { afterAll, describe, expect, test } from "bun:test"; + +import { act } from "react"; + +import { acquireBrowserTestEnvironment } from "./testSupport/browserTestEnvironment.ts"; + +const browserEnvironment = await acquireBrowserTestEnvironment(); +const { waitFor } = await import("@testing-library/react"); + +afterAll(async () => { + await browserEnvironment.release(); +}); + +describe("Dashboard browser entrypoint", () => { + test("mounts the lazy application graph into the document root", async () => { + document.body.innerHTML = '
'; + let entrypoint: typeof import("./main.tsx") | undefined; + try { + entrypoint = await act(async () => import("./main.tsx")); + await waitFor( + () => { + const heading = document.querySelector("h1"); + expect(heading).not.toBeNull(); + expect(heading?.textContent).toBe("Mira Dashboard"); + }, + { container: document.body } + ); + } finally { + const mountedRoot = entrypoint?.dashboardBrowserRoot; + if (mountedRoot) act(() => mountedRoot.unmount()); + document.body.replaceChildren(); + } + }); +}); diff --git a/greenfield/src/browser/main.tsx b/greenfield/src/browser/main.tsx new file mode 100644 index 000000000..cc1e86550 --- /dev/null +++ b/greenfield/src/browser/main.tsx @@ -0,0 +1,29 @@ +import { StrictMode, Suspense } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import LazyDashboardBrowserBootstrap from "./lazyBootstrap.tsx"; + +const rootElement = document.querySelector("#root"); +if (!(rootElement instanceof HTMLElement)) { + throw new TypeError("Dashboard browser root is missing"); +} + +/** Root owned by the browser entrypoint for its complete document lifetime. */ +export const dashboardBrowserRoot: Root = createRoot(rootElement); + +dashboardBrowserRoot.render( + + + Loading Dashboard… + + } + > + + + +); diff --git a/greenfield/src/browser/queryClient.ts b/greenfield/src/browser/queryClient.ts new file mode 100644 index 000000000..f0f0d3544 --- /dev/null +++ b/greenfield/src/browser/queryClient.ts @@ -0,0 +1,19 @@ +import { QueryClient } from "@tanstack/react-query"; + +/** + * Creates one browser-owned query cache with bounded retry and retention defaults. + * @returns A fresh QueryClient for one application or isolated test lifetime. + */ +export function createDashboardQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 2, + staleTime: 30 * 1000, + }, + }, + }); +} diff --git a/greenfield/src/browser/routeComponents.tsx b/greenfield/src/browser/routeComponents.tsx new file mode 100644 index 000000000..3b97d034e --- /dev/null +++ b/greenfield/src/browser/routeComponents.tsx @@ -0,0 +1,68 @@ +import { Link, Outlet } from "@tanstack/react-router"; + +/** + * Renders the persistent Dashboard navigation and route outlet. + * @returns The accessible application shell. + */ +export function DashboardShell() { + return ( +
+ + Skip to content + +
+
+ + Mira Dashboard + +

Secure operations workspace

+
+
+
+ +
+
+ ); +} + +/** + * Renders the Phase 1 browser entry route. + * @returns The initial Dashboard overview. + */ +export function OverviewRoute() { + return ( +
+

Dashboard foundation

+

+ Mira Dashboard +

+

+ The secure browser workspace is ready for the rewritten Dashboard + features. +

+ +

Application shell ready

+

+ Feature routes will appear here as their contracts and services are + completed. +

+
+
+ ); +} diff --git a/greenfield/src/browser/router.tsx b/greenfield/src/browser/router.tsx new file mode 100644 index 000000000..a69b5e9d2 --- /dev/null +++ b/greenfield/src/browser/router.tsx @@ -0,0 +1,33 @@ +import { + createRootRoute, + createRoute, + createRouter, + type RouterHistory, +} from "@tanstack/react-router"; + +import { DashboardShell, OverviewRoute } from "./routeComponents.tsx"; + +const rootRoute = createRootRoute({ component: DashboardShell }); +const overviewRoute = createRoute({ + component: OverviewRoute, + getParentRoute: () => rootRoute, + path: "/", +}); +const routeTree = rootRoute.addChildren([overviewRoute]); + +/** + * Creates one browser router owned by the browser composition root. + * @param history Optional memory history for deterministic browser tests. + * @returns Typed Dashboard browser router. + */ +export function createDashboardRouter(history?: RouterHistory) { + return createRouter({ + defaultPreload: "intent", + defaultPreloadStaleTime: 30_000, + ...(history === undefined ? {} : { history }), + routeTree, + scrollRestoration: true, + }); +} + +export type DashboardRouter = ReturnType; diff --git a/greenfield/src/browser/testSupport/browserTestEnvironment.ts b/greenfield/src/browser/testSupport/browserTestEnvironment.ts new file mode 100644 index 000000000..a744bda81 --- /dev/null +++ b/greenfield/src/browser/testSupport/browserTestEnvironment.ts @@ -0,0 +1,71 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +interface BrowserTestEnvironmentLease { + /** Releases this test module's ownership of the shared browser environment. */ + readonly release: () => Promise; +} + +let activeLeases = 0; +let ownsRegisteredEnvironment = false; +let previousActEnvironment: unknown; +let previouslyHadActEnvironment = false; +let environmentTransition: Promise = Promise.resolve(); + +async function serializeEnvironmentTransition( + transition: () => Result | Promise +): Promise { + const result = environmentTransition.then(transition, transition); + environmentTransition = result.then( + () => null, + () => null + ); + return result; +} + +/** + * Acquires a reference-counted Happy DOM environment for browser tests. + * @returns A lease that restores globals after the last local owner releases it. + */ +export async function acquireBrowserTestEnvironment(): Promise { + await serializeEnvironmentTransition(() => { + if (activeLeases === 0) { + ownsRegisteredEnvironment = globalThis.document === undefined; + if (ownsRegisteredEnvironment) { + GlobalRegistrator.register({ url: "https://dashboard.test/" }); + } + previouslyHadActEnvironment = Object.hasOwn( + globalThis, + "IS_REACT_ACT_ENVIRONMENT" + ); + previousActEnvironment = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + } + activeLeases += 1; + }); + let released = false; + + return { + release: async () => { + if (released) return; + released = true; + await serializeEnvironmentTransition(async () => { + activeLeases -= 1; + if (activeLeases !== 0) return; + + if (previouslyHadActEnvironment) { + Reflect.set( + globalThis, + "IS_REACT_ACT_ENVIRONMENT", + previousActEnvironment + ); + } else { + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + } + if (ownsRegisteredEnvironment) await GlobalRegistrator.unregister(); + ownsRegisteredEnvironment = false; + previousActEnvironment = undefined; + previouslyHadActEnvironment = false; + }); + }, + }; +} diff --git a/greenfield/src/browser/testSupport/browserTestPreload.ts b/greenfield/src/browser/testSupport/browserTestPreload.ts new file mode 100644 index 000000000..b581f55b2 --- /dev/null +++ b/greenfield/src/browser/testSupport/browserTestPreload.ts @@ -0,0 +1,3 @@ +import { acquireBrowserTestEnvironment } from "./browserTestEnvironment.ts"; + +await acquireBrowserTestEnvironment(); diff --git a/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts index 7219a3f4a..110cb16a8 100644 --- a/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts +++ b/greenfield/src/server/database/migrations/applyVerifiedMigrations.ts @@ -3,6 +3,7 @@ import { Database } from "bun:sqlite"; import { getTime } from "date-fns"; import * as v from "valibot"; +import { migrationManifest } from "../../../shared/databaseMigrationManifest.ts"; import { timestampMillisecondsSchema } from "../../../shared/dateTime.ts"; import { fullCommitShaSchema, @@ -14,7 +15,6 @@ import { drizzleStatementBreakpoint, type VerifiedMigration, } from "./loadVerifiedMigrations.ts"; -import { migrationManifest } from "./manifest.ts"; import { migrationIdSchema } from "./validation.ts"; import { assertConstraintEnforcement, diff --git a/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts b/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts index a8568fff5..84f6eaf2f 100644 --- a/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts +++ b/greenfield/src/server/database/migrations/loadVerifiedMigrations.test.ts @@ -15,6 +15,7 @@ import { import os from "node:os"; import path from "node:path"; +import { migrationManifest } from "../../../shared/databaseMigrationManifest.ts"; import { sha256Hex } from "../../shared/crypto.ts"; import { migrationsDirectory } from "../../test/support/freshDatabase.ts"; import { @@ -23,7 +24,6 @@ import { type MigrationArtifactVerificationTestHooks, type MigrationArtifactVerificationTestStage, } from "./loadVerifiedMigrations.ts"; -import { migrationManifest } from "./manifest.ts"; const temporaryDirectories: string[] = []; diff --git a/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts b/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts index f06293272..a0dbe4130 100644 --- a/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts +++ b/greenfield/src/server/database/migrations/loadVerifiedMigrations.ts @@ -1,8 +1,11 @@ import * as v from "valibot"; +import { + migrationManifest, + type MigrationManifestEntry, +} from "../../../shared/databaseMigrationManifest.ts"; import { lowercaseSha256Schema } from "../../../shared/validation.ts"; import { sha256Hex } from "../../shared/crypto.ts"; -import { migrationManifest, type MigrationManifestEntry } from "./manifest.ts"; import { type MigrationArtifactVerificationTestHooks, readStableMigrationArtifactGraph, diff --git a/greenfield/src/server/database/runtime/databaseCandidateMigrationOwner.ts b/greenfield/src/server/database/runtime/databaseCandidateMigrationOwner.ts new file mode 100644 index 000000000..f647f69d2 --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseCandidateMigrationOwner.ts @@ -0,0 +1,22 @@ +import { + createOwnedDatabaseLayer, + type DatabaseRuntimeOwner, +} from "./databaseRuntimeOwner.ts"; +import { + databaseCandidateMigrationLayer, + type DatabaseCandidateMigrationLayerOptions, +} from "./databaseService.ts"; + +export type { DatabaseRuntimeOwner } from "./databaseRuntimeOwner.ts"; + +/** + * Creates a delivery-only owner that advances an isolated database copy. + * Normal web and worker composition cannot select this migration strategy. + * @param candidate Exact private candidate state and immutable release inputs. + * @returns Candidate migration lifecycle owner. + */ +export function createDatabaseCandidateMigrationOwner( + candidate: DatabaseCandidateMigrationLayerOptions +): DatabaseRuntimeOwner { + return createOwnedDatabaseLayer(databaseCandidateMigrationLayer(candidate)); +} diff --git a/greenfield/src/server/database/runtime/databasePolicy.ts b/greenfield/src/server/database/runtime/databasePolicy.ts index d33b67771..1d267b0ae 100644 --- a/greenfield/src/server/database/runtime/databasePolicy.ts +++ b/greenfield/src/server/database/runtime/databasePolicy.ts @@ -1,4 +1,4 @@ -import type { Database } from "bun:sqlite"; +import { constants as sqliteConstants, type Database } from "bun:sqlite"; import { Data, Duration, Effect, Predicate, Schedule } from "effect"; import * as v from "valibot"; @@ -366,3 +366,40 @@ export function checkpointDatabasePassive( }, }); } + +/** + * Disables persistent WAL and requires one complete truncating checkpoint. + * Reserved for stopped, delivery-owned candidate/snapshot database scopes. + * @param database Retained native connection with no concurrent writers. + * @returns Validated zero-busy WAL checkpoint counters. + */ +export function checkpointDatabaseTruncate( + database: Database +): Effect.Effect { + return Effect.try({ + catch: () => + new DatabaseRuntimeCheckpointError({ + message: "Database truncating checkpoint failed", + }), + try: () => { + database.fileControl(sqliteConstants.SQLITE_FCNTL_PERSIST_WAL, 0); + const row = parsePolicyRow( + checkpointRowSchema, + database.query("PRAGMA wal_checkpoint(TRUNCATE)").get() + ); + if ( + row.busy !== 0 || + row.log < 0 || + row.checkpointed < 0 || + row.checkpointed !== row.log + ) { + throw new Error("Invalid truncating checkpoint diagnostics"); + } + return Object.freeze({ + busy: row.busy, + checkpointedFrames: row.checkpointed, + logFrames: row.log, + }); + }, + }); +} diff --git a/greenfield/src/server/database/runtime/databaseRuntimeOwner.ts b/greenfield/src/server/database/runtime/databaseRuntimeOwner.ts new file mode 100644 index 000000000..58f781687 --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseRuntimeOwner.ts @@ -0,0 +1,54 @@ +import { type Layer, ManagedRuntime } from "effect"; + +import { + databaseRuntimeLayer, + type DatabaseRuntimeLayerOptions, +} from "./databaseService.ts"; + +/** Minimal process-owned lifecycle for one retained database runtime scope. */ +export interface DatabaseRuntimeOwner { + dispose(): Promise; + initialize(): Promise; +} + +/** + * Creates one idempotent owner around a database-scoped Effect layer. + * @param layer Scoped database layer without external requirements. + * @returns Memoized initialize/dispose ownership boundary. + * @internal + */ +export function createOwnedDatabaseLayer( + layer: Layer.Layer +): DatabaseRuntimeOwner { + const runtime = ManagedRuntime.make(layer); + let initializePromise: Promise | undefined; + let disposePromise: Promise | undefined; + const initialize = async (): Promise => { + await runtime.context(); + }; + + return Object.freeze({ + dispose() { + disposePromise ??= runtime.dispose(); + return disposePromise; + }, + initialize() { + if (disposePromise !== undefined) { + return Promise.reject(new Error("Database runtime owner is disposed")); + } + initializePromise ??= initialize(); + return initializePromise; + }, + }); +} + +/** + * Creates an idempotent owner for one migration-verified database runtime scope. + * @param database Exact release/state database runtime options. + * @returns Initialization and disposal boundary without leaking ORM authority. + */ +export function createDatabaseRuntimeOwner( + database: DatabaseRuntimeLayerOptions +): DatabaseRuntimeOwner { + return createOwnedDatabaseLayer(databaseRuntimeLayer(database)); +} diff --git a/greenfield/src/server/database/runtime/databaseService.test.ts b/greenfield/src/server/database/runtime/databaseService.test.ts index 276ac6117..b43cf0688 100644 --- a/greenfield/src/server/database/runtime/databaseService.test.ts +++ b/greenfield/src/server/database/runtime/databaseService.test.ts @@ -22,6 +22,7 @@ import { } from "./databasePolicy.ts"; import { DatabaseRuntimeService, + databaseCandidateMigrationLayer, databaseRuntimeLayer, type DatabaseRuntimeLayerOptions, } from "./databaseService.ts"; @@ -77,6 +78,22 @@ async function buildRuntime(runtimeOptions: DatabaseRuntimeLayerOptions) { return { runtime, service }; } +async function migrateCandidate( + stateDirectory: string, + candidateReleaseId = releaseId +): Promise { + const runtime = ManagedRuntime.make( + databaseCandidateMigrationLayer({ + migrationsDirectory, + releaseId: candidateReleaseId, + stateDirectory, + }) + ); + runtimes.push(runtime); + await runtime.context(); + await runtime.dispose(); +} + afterEach(async () => { await Promise.allSettled(runtimes.splice(0).map((runtime) => runtime.dispose())); await Promise.all( @@ -87,6 +104,54 @@ afterEach(async () => { }); describe("database runtime service", () => { + test("initializes and revalidates an isolated delivery candidate without rewriting history", async () => { + const stateDirectory = await privateTemporaryDirectory(); + await migrateCandidate(stateDirectory); + + const databasePath = path.join(stateDirectory, "mira-dashboard.db"); + let database = new Database(databasePath, { strict: true }); + expect( + database + .query<{ count: number; releaseId: string }, []>(` + SELECT COUNT(*) AS count, MIN(release_id) AS releaseId + FROM schema_migrations + `) + .get() + ).toEqual({ count: 1, releaseId }); + database.close(true); + + await migrateCandidate(stateDirectory, "1".repeat(40)); + database = new Database(databasePath, { strict: true }); + expect( + database + .query<{ count: number; releaseId: string }, []>(` + SELECT COUNT(*) AS count, MIN(release_id) AS releaseId + FROM schema_migrations + `) + .get() + ).toEqual({ count: 1, releaseId }); + database.close(true); + }); + + test("rejects a drifted delivery candidate without changing normal startup modes", async () => { + const stateDirectory = await privateTemporaryDirectory(); + const databasePath = path.join(stateDirectory, "mira-dashboard.db"); + const database = new Database(databasePath, { create: true, strict: true }); + database.run("CREATE TABLE unreviewed (id INTEGER PRIMARY KEY) STRICT"); + database.close(true); + await chmod(databasePath, 0o600); + + expect(await rejectionOf(migrateCandidate(stateDirectory))).toBeInstanceOf( + DatabaseRuntimeStartupError + ); + expect(() => + normalizeDatabaseRuntimeOptions({ + ...options(stateDirectory), + startupMode: "migrate-candidate" as never, + }) + ).toThrow(DatabaseRuntimeStartupError); + }); + test("initializes a fresh strict WAL database through one native Drizzle handle", async () => { const stateDirectory = await privateTemporaryDirectory(); const { service } = await buildRuntime(options(stateDirectory)); diff --git a/greenfield/src/server/database/runtime/databaseService.ts b/greenfield/src/server/database/runtime/databaseService.ts index 5ae53a41f..730bad927 100644 --- a/greenfield/src/server/database/runtime/databaseService.ts +++ b/greenfield/src/server/database/runtime/databaseService.ts @@ -18,10 +18,12 @@ import { } from "./databasePath.ts"; import { checkpointDatabasePassive, + checkpointDatabaseTruncate, type DatabaseCheckpointDiagnostics, retryDatabaseWriteOperation, } from "./databasePolicy.ts"; import { + initializeDatabaseCandidateMigration, initializeDatabaseRuntime, loadDatabaseRuntimeMigrations, normalizeDatabaseRuntimeOptions, @@ -43,6 +45,11 @@ export type { export type RuntimeOwnedDatabase = SQLiteBunDatabase & { readonly $client: Database }; +/** Delivery-only inputs for migrating one isolated copied database candidate. */ +export type DatabaseCandidateMigrationLayerOptions = Readonly< + Omit +>; + export interface DatabaseRuntimeDiagnostics extends DatabaseStartupDiagnostics { readonly databaseFileName: typeof dashboardDatabaseFileName; } @@ -73,7 +80,8 @@ function emptyDatabaseFailure(): DatabaseRuntimeStartupError { } function prepareRuntimeDatabasePath( - options: NormalizedDatabaseRuntimeOptions + options: NormalizedDatabaseRuntimeOptions, + createIfMissing = options.startupMode === "initialize-empty" ): Effect.Effect< PreparedDatabasePath, DatabaseRuntimePathError | DatabaseRuntimeStartupError @@ -86,11 +94,7 @@ function prepareRuntimeDatabasePath( message: "Database path validation failed", reason: "database-file-invalid", }), - try: () => - prepareDatabasePath( - options.stateDirectory, - options.startupMode === "initialize-empty" - ), + try: () => prepareDatabasePath(options.stateDirectory, createIfMissing), }).pipe( Effect.flatMap((prepared) => prepared === undefined @@ -176,6 +180,25 @@ function releaseRuntimeDatabase( }); } +function releaseCandidateDatabase( + database: Database, + checkpointBeforeClose: boolean +): Effect.Effect { + if (!checkpointBeforeClose) return closeRuntimeDatabase(database).pipe(Effect.ignore); + + return Effect.gen(function* () { + const checkpointResult = yield* Effect.result( + checkpointDatabaseTruncate(database) + ); + const closeResult = yield* Effect.result(closeRuntimeDatabase(database)); + + if (Result.isFailure(closeResult)) return yield* Effect.die(closeResult.failure); + if (Result.isFailure(checkpointResult)) { + return yield* Effect.die(checkpointResult.failure); + } + }); +} + function acquireDatabaseRuntime(unverifiedOptions: DatabaseRuntimeLayerOptions) { return Effect.gen(function* () { const options = yield* Effect.try({ @@ -229,3 +252,54 @@ export function databaseRuntimeLayer( ): Layer.Layer { return Layer.effect(DatabaseRuntimeService, acquireDatabaseRuntime(options)); } + +function acquireDatabaseCandidateMigration( + candidate: DatabaseCandidateMigrationLayerOptions +) { + return Effect.gen(function* () { + const options = yield* Effect.try({ + catch: (error) => + error instanceof DatabaseRuntimeStartupError + ? error + : new DatabaseRuntimeStartupError({ + message: "Database candidate options are invalid", + reason: "options-invalid", + }), + try: () => + normalizeDatabaseRuntimeOptions({ + ...candidate, + startupMode: "initialize-empty", + }), + }); + const migrations = yield* loadDatabaseRuntimeMigrations( + options.migrationsDirectory + ); + const prepared = yield* prepareRuntimeDatabasePath(options, true); + let checkpointOnRelease = false; + const database = yield* Effect.acquireRelease( + openRuntimeDatabase(prepared), + (openedDatabase) => + releaseCandidateDatabase(openedDatabase, checkpointOnRelease) + ); + yield* verifyOpenDatabasePath(prepared); + yield* initializeDatabaseCandidateMigration( + database, + migrations, + options.releaseId + ); + yield* verifyOpenDatabasePath(prepared); + checkpointOnRelease = true; + }); +} + +/** + * Creates a scoped delivery-only layer that migrates an isolated database candidate. + * It intentionally provides no ORM service to the caller. + * @param options Exact candidate state, migration graph, and release identity. + * @returns Scoped no-service candidate migration layer. + */ +export function databaseCandidateMigrationLayer( + options: DatabaseCandidateMigrationLayerOptions +): Layer.Layer { + return Layer.effectDiscard(acquireDatabaseCandidateMigration(options)); +} diff --git a/greenfield/src/server/database/runtime/databaseSnapshot.test.ts b/greenfield/src/server/database/runtime/databaseSnapshot.test.ts new file mode 100644 index 000000000..a85327dbc --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseSnapshot.test.ts @@ -0,0 +1,211 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + mkdtemp, + readFile, + readdir, + rm, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { Effect, ManagedRuntime } from "effect"; + +import { withDeploymentLease } from "../../../../scripts/delivery/deploymentLease.ts"; +import { prepareProtectedProductionStatePath } from "../../../../scripts/delivery/productionStateFilesystem.ts"; +import { rejectionError } from "../../../../scripts/testSupport/rejection.ts"; +import { parseDatabaseSnapshotManifest } from "../../../shared/databaseSnapshotManifest.ts"; +import { databaseRuntimeLayer } from "./databaseService.ts"; +import { + createVerifiedDatabaseSnapshot, + DatabaseSnapshotError, +} from "./databaseSnapshot.ts"; + +const migrationsDirectory = path.resolve(import.meta.dir, "../../../../migrations"); +const releaseId = "a".repeat(40); +const temporaryDirectories: string[] = []; +const runtimes: Array<{ dispose(): Promise }> = []; + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600); + } + } +} + +afterEach(async () => { + await Promise.allSettled(runtimes.splice(0).map((runtime) => runtime.dispose())); + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function fixture() { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-db-snapshot-")); + temporaryDirectories.push(projectRoot); + const state = await prepareProtectedProductionStatePath(projectRoot); + return { projectRoot, state }; +} + +async function initializeDatabase(stateDirectory: string): Promise { + const runtime = ManagedRuntime.make( + databaseRuntimeLayer({ + migrationsDirectory, + releaseId, + startupMode: "initialize-empty", + stateDirectory, + }) + ); + runtimes.push(runtime); + await runtime.context(); + await runtime.dispose(); +} + +describe("verified database snapshots", () => { + test("records an expected absent state without creating a backup artifact", async () => { + const { state } = await fixture(); + const transitionId = Bun.randomUUIDv7(); + const result = await withDeploymentLease(state.stateDirectory, async () => + Effect.runPromise( + createVerifiedDatabaseSnapshot({ + expectedState: "absent", + stateDirectory: state.stateDirectory, + transitionId, + }) + ) + ); + + expect(result).toEqual({ state: "absent", transitionId }); + expect(await readdir(state.backupsDirectory)).toEqual([]); + }); + + test("creates one immutable release-bound WAL-safe snapshot", async () => { + const { state } = await fixture(); + await initializeDatabase(state.stateDirectory); + const transitionId = Bun.randomUUIDv7(); + const result = await withDeploymentLease(state.stateDirectory, async () => + Effect.runPromise( + createVerifiedDatabaseSnapshot({ + expectedState: "present", + migrationsDirectory, + releaseId, + stateDirectory: state.stateDirectory, + transitionId, + }) + ) + ); + if (result.state !== "present") throw new Error("Expected snapshot artifact"); + + expect(result.snapshotDirectory).toBe( + path.join(state.backupsDirectory, transitionId) + ); + const [snapshotDirectoryStatus, snapshotFileStatus] = await Promise.all([ + stat(result.snapshotDirectory), + stat(result.snapshotFile), + ]); + expect(snapshotDirectoryStatus.mode & 0o777).toBe(0o500); + expect(snapshotFileStatus.mode & 0o777).toBe(0o400); + expect(result.manifest.releaseId).toBe(releaseId); + expect(result.manifest.database.bytes).toBeGreaterThan(0); + expect(result.manifest.migrations).toHaveLength(1); + const manifestPath = path.join( + result.snapshotDirectory, + "snapshot-manifest.json" + ); + const manifestText = await readFile(manifestPath, "utf8"); + const manifestValue: unknown = JSON.parse(manifestText); + const storedManifest = parseDatabaseSnapshotManifest(manifestValue); + expect(storedManifest).toEqual(result.manifest); + + const snapshot = new Database(result.snapshotFile, { + readonly: true, + strict: true, + }); + try { + expect( + snapshot + .query<{ count: number; releaseId: string }, []>(` + SELECT COUNT(*) AS count, MIN(release_id) AS releaseId + FROM schema_migrations + `) + .get() + ).toEqual({ count: 1, releaseId }); + } finally { + snapshot.close(true); + } + for (const suffix of ["-journal", "-shm", "-wal"] as const) { + expect( + await stat( + path.join(state.stateDirectory, `mira-dashboard.db${suffix}`) + ).catch(() => null) + ).toBeNull(); + } + }); + + test("fails closed on expectation mismatch and removes only its owned stage", async () => { + const { state } = await fixture(); + await initializeDatabase(state.stateDirectory); + const absentTransitionId = Bun.randomUUIDv7(); + const absentFailure = await Effect.runPromise( + Effect.result( + createVerifiedDatabaseSnapshot({ + expectedState: "absent", + stateDirectory: state.stateDirectory, + transitionId: absentTransitionId, + }) + ) + ); + expect(absentFailure._tag).toBe("Failure"); + + const transitionId = Bun.randomUUIDv7(); + const tamperFailure = await rejectionError( + Effect.runPromise( + createVerifiedDatabaseSnapshot( + { + expectedState: "present", + migrationsDirectory, + releaseId, + stateDirectory: state.stateDirectory, + transitionId, + }, + { + afterSnapshotCreated: (snapshotFile) => + writeFile(snapshotFile, "tampered"), + } + ) + ) + ); + + expect(tamperFailure).toBeInstanceOf(DatabaseSnapshotError); + expect(await readdir(state.backupsDirectory)).toEqual([]); + + const replacementFailure = await rejectionError( + Effect.runPromise( + createVerifiedDatabaseSnapshot( + { + expectedState: "present", + migrationsDirectory, + releaseId, + stateDirectory: state.stateDirectory, + transitionId: Bun.randomUUIDv7(), + }, + { afterSnapshotFileOpen: unlink } + ) + ) + ); + expect(replacementFailure).toBeInstanceOf(DatabaseSnapshotError); + expect(await readdir(state.backupsDirectory)).toEqual([]); + }); +}); diff --git a/greenfield/src/server/database/runtime/databaseSnapshot.ts b/greenfield/src/server/database/runtime/databaseSnapshot.ts new file mode 100644 index 000000000..7a69ac0f2 --- /dev/null +++ b/greenfield/src/server/database/runtime/databaseSnapshot.ts @@ -0,0 +1,859 @@ +import { Database } from "bun:sqlite"; +import { constants, type BigIntStats } from "node:fs"; +import { + chmod, + lstat, + mkdir, + open, + readdir, + realpath, + rename, + rm, + statfs, + type FileHandle, +} from "node:fs/promises"; +import path from "node:path"; + +import { Effect, Schema } from "effect"; +import * as v from "valibot"; + +import { + currentDatabaseSnapshotMigrations, + parseDatabaseSnapshotManifest, + serializeDatabaseSnapshotManifest, + type DatabaseSnapshotManifest, +} from "../../../shared/databaseSnapshotManifest.ts"; +import { + fullCommitShaSchema, + lowercaseUuidV7Schema, +} from "../../../shared/validation.ts"; +import { validateVerifiedMigrations } from "../migrations/applyVerifiedMigrations.ts"; +import { + loadVerifiedMigrations, + type VerifiedMigration, +} from "../migrations/loadVerifiedMigrations.ts"; +import { + assertDatabasePathStillValid, + dashboardDatabaseFileName, + prepareDatabasePath, + type PreparedDatabasePath, +} from "./databasePath.ts"; +import { + checkpointDatabaseTruncate, + configureDatabaseConnection, +} from "./databasePolicy.ts"; + +const TaggedErrorClass = Schema.TaggedError; +const snapshotFailureMessage = "Database snapshot creation failed"; +const snapshotDatabaseFileName = dashboardDatabaseFileName; +const snapshotManifestFileName = "snapshot-manifest.json"; +const maximumSnapshotBytes = 64 * 1024 * 1024 * 1024; +const maximumSnapshotManifestBytes = 64 * 1024; +const snapshotCopyBufferBytes = 1024 * 1024; +const freeSpaceReserveBytes = 64 * 1024 * 1024; +const privateDirectoryMode = 0o700; +const privateFileMode = 0o600; +const immutableDirectoryMode = 0o500; +const immutableFileMode = 0o400; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const snapshotFileFlags = constants.O_RDWR | constants.O_NOFOLLOW; +const absolutePathSchema = v.pipe( + v.string(), + v.maxLength(4096), + v.check( + (value) => + path.isAbsolute(value) && + path.resolve(value) === value && + !value.includes("\0"), + snapshotFailureMessage + ) +); +const snapshotOptionsSchema = v.variant("expectedState", [ + v.strictObject({ + expectedState: v.literal("absent"), + stateDirectory: absolutePathSchema, + transitionId: lowercaseUuidV7Schema(snapshotFailureMessage), + }), + v.strictObject({ + expectedState: v.literal("present"), + migrationsDirectory: absolutePathSchema, + releaseId: fullCommitShaSchema(snapshotFailureMessage), + stateDirectory: absolutePathSchema, + transitionId: lowercaseUuidV7Schema(snapshotFailureMessage), + }), +]); + +export type DatabaseSnapshotOptions = Readonly< + v.InferOutput +>; + +export type DatabaseSnapshotResult = + | Readonly<{ state: "absent"; transitionId: string }> + | Readonly<{ + manifest: DatabaseSnapshotManifest; + snapshotDirectory: string; + snapshotFile: string; + sourceDatabase: DatabaseSnapshotSourceIdentity; + state: "present"; + }>; + +/** Stable live-file identity observed after checkpoint and before snapshot publication. */ +export interface DatabaseSnapshotSourceIdentity { + readonly ctimeNs: string; + readonly device: string; + readonly inode: string; + readonly mtimeNs: string; + readonly size: string; +} + +/** Deterministic mutation boundaries exposed only to adversarial tests. */ +export interface DatabaseSnapshotTestHooks { + readonly afterSnapshotCreated?: (snapshotFile: string) => Promise | void; + readonly afterSnapshotFileOpen?: (snapshotFile: string) => Promise | void; + readonly afterSnapshotFrozen?: (snapshotDirectory: string) => Promise | void; +} + +/** Sanitized failure from the delivery-owned snapshot boundary. */ +export class DatabaseSnapshotError extends TaggedErrorClass( + "mira-dashboard/server/database/runtime/DatabaseSnapshotError" +)("DatabaseSnapshotError", { message: Schema.String }) {} + +interface DirectoryIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + +interface OpenedDirectory { + readonly handle: FileHandle; + readonly identity: DirectoryIdentity; + readonly path: string; +} + +interface SnapshotFileIdentity { + readonly bytes: number; + readonly dev: bigint; + readonly ino: bigint; + readonly sha256: string; +} + +function sourceDatabaseIdentity(status: BigIntStats): DatabaseSnapshotSourceIdentity { + if (!status.isFile() || status.isSymbolicLink() || status.nlink !== 1n) { + throw snapshotFailure(); + } + return Object.freeze({ + ctimeNs: status.ctimeNs.toString(), + device: status.dev.toString(), + inode: status.ino.toString(), + mtimeNs: status.mtimeNs.toString(), + size: status.size.toString(), + }); +} + +function sameSourceDatabaseIdentity( + left: DatabaseSnapshotSourceIdentity, + right: DatabaseSnapshotSourceIdentity +): boolean { + return ( + left.ctimeNs === right.ctimeNs && + left.device === right.device && + left.inode === right.inode && + left.mtimeNs === right.mtimeNs && + left.size === right.size + ); +} + +function snapshotFailure(): Error { + return new Error(snapshotFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function identity(status: BigIntStats): DirectoryIdentity { + return Object.freeze({ dev: status.dev, ino: status.ino }); +} + +function sameIdentity(status: BigIntStats, expected: DirectoryIdentity): boolean { + return status.dev === expected.dev && status.ino === expected.ino; +} + +function validDirectory( + status: BigIntStats, + expectedMode: bigint, + userId: number +): boolean { + return ( + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(userId) && + (status.mode & 0o7777n) === expectedMode + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function openStableDirectory( + directory: string, + expectedMode: bigint, + expectedDevice?: bigint +): Promise { + if (process.platform !== "linux" || typeof process.getuid !== "function") { + throw snapshotFailure(); + } + let handle: FileHandle | undefined; + let opened: OpenedDirectory | undefined; + try { + handle = await open(directory, directoryFlags); + const [held, after, canonical] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(directory, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + const heldIdentity = identity(held); + if ( + canonical !== directory || + !validDirectory(held, expectedMode, process.getuid()) || + !validDirectory(after, expectedMode, process.getuid()) || + !sameIdentity(after, heldIdentity) || + (expectedDevice !== undefined && held.dev !== expectedDevice) + ) { + throw snapshotFailure(); + } + opened = Object.freeze({ handle, identity: heldIdentity, path: directory }); + } catch { + await closeHandle(handle); + throw snapshotFailure(); + } + return opened; +} + +async function revalidateDirectory( + directory: OpenedDirectory, + expectedMode: bigint +): Promise { + if (typeof process.getuid !== "function") throw snapshotFailure(); + const [held, current, canonical] = await Promise.all([ + directory.handle.stat({ bigint: true }), + lstat(directory.path, { bigint: true }), + realpath(`/proc/self/fd/${directory.handle.fd}`), + ]); + if ( + canonical !== directory.path || + !validDirectory(held, expectedMode, process.getuid()) || + !validDirectory(current, expectedMode, process.getuid()) || + !sameIdentity(held, directory.identity) || + !sameIdentity(current, directory.identity) + ) { + throw snapshotFailure(); + } +} + +async function requireMissing(candidate: string): Promise { + try { + await lstat(candidate); + } catch (error) { + if (errorCode(error) === "ENOENT") return; + throw snapshotFailure(); + } + throw snapshotFailure(); +} + +async function requireSidecarsAbsent(databaseFile: string): Promise { + for (const suffix of ["-journal", "-shm", "-wal"] as const) { + await requireMissing(`${databaseFile}${suffix}`); + } +} + +async function requireSnapshotCapacity( + sourceFile: string, + backupsDirectory: string +): Promise { + const [source, filesystem] = await Promise.all([ + lstat(sourceFile, { bigint: true }), + statfs(backupsDirectory, { bigint: true }), + ]); + const maximum = BigInt(maximumSnapshotBytes); + const reserve = BigInt(freeSpaceReserveBytes); + const available = filesystem.bavail * filesystem.bsize; + if ( + !source.isFile() || + source.isSymbolicLink() || + source.size <= 0n || + source.size > maximum || + available < source.size + reserve + ) { + throw snapshotFailure(); + } +} + +function configureSnapshotValidationConnection(database: Database): void { + database.run("PRAGMA busy_timeout = 0"); + database.run("PRAGMA foreign_keys = ON"); + database.run("PRAGMA ignore_check_constraints = OFF"); + database.run("PRAGMA trusted_schema = OFF"); +} + +function checkpointSourceDatabase(database: Database): void { + Effect.runSync(checkpointDatabaseTruncate(database)); +} + +function vacuumInto(database: Database, destination: string): void { + const statement = database.prepare("VACUUM INTO ?"); + try { + statement.run(destination); + } finally { + statement.finalize(); + } +} + +function verifySnapshotDatabase( + snapshotFile: string, + migrations: readonly VerifiedMigration[] +): void { + const database = new Database(snapshotFile, { readonly: true, strict: true }); + try { + if (database.filename !== snapshotFile) throw snapshotFailure(); + configureSnapshotValidationConnection(database); + validateVerifiedMigrations(database, migrations); + } finally { + database.close(true); + } +} + +async function hashAndFreezeSnapshotFile( + snapshotFile: string, + expectedDevice: bigint, + afterOpen?: (snapshotFile: string) => Promise | void +): Promise { + if (typeof process.getuid !== "function") throw snapshotFailure(); + let handle: FileHandle | undefined; + let result: SnapshotFileIdentity | undefined; + try { + handle = await open(snapshotFile, snapshotFileFlags); + const held = await handle.stat({ bigint: true }); + const canonical = await realpath(`/proc/self/fd/${handle.fd}`); + if ( + canonical !== snapshotFile || + !held.isFile() || + held.isSymbolicLink() || + held.nlink !== 1n || + held.uid !== BigInt(process.getuid()) || + held.dev !== expectedDevice || + held.size <= 0n || + held.size > BigInt(maximumSnapshotBytes) + ) { + throw snapshotFailure(); + } + await afterOpen?.(snapshotFile); + await handle.sync(); + const hasher = new Bun.CryptoHasher("sha256"); + const buffer = Buffer.alloc(Math.min(snapshotCopyBufferBytes, Number(held.size))); + let offset = 0; + while (offset < Number(held.size)) { + const length = Math.min(buffer.byteLength, Number(held.size) - offset); + const read = await handle.read(buffer, 0, length, offset); + if (read.bytesRead <= 0) throw snapshotFailure(); + hasher.update(buffer.subarray(0, read.bytesRead)); + offset += read.bytesRead; + } + await handle.chmod(immutableFileMode); + await handle.sync(); + const [after, pathAfter] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(snapshotFile, { bigint: true }), + ]); + if ( + after.dev !== held.dev || + after.ino !== held.ino || + after.size !== held.size || + after.nlink !== 1n || + (after.mode & 0o7777n) !== 0o400n || + !pathAfter.isFile() || + pathAfter.isSymbolicLink() || + pathAfter.dev !== held.dev || + pathAfter.ino !== held.ino || + pathAfter.size !== held.size || + pathAfter.nlink !== 1n || + pathAfter.uid !== BigInt(process.getuid()) || + (pathAfter.mode & 0o7777n) !== 0o400n + ) { + throw snapshotFailure(); + } + result = Object.freeze({ + bytes: Number(held.size), + dev: held.dev, + ino: held.ino, + sha256: hasher.digest("hex"), + }); + } catch { + throw snapshotFailure(); + } finally { + const closed = await closeHandle(handle); + if (!closed) result = undefined; + } + if (!result) throw snapshotFailure(); + return result; +} + +async function writeSnapshotManifest( + manifestFile: string, + manifest: DatabaseSnapshotManifest +): Promise { + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open( + manifestFile, + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_WRONLY, + privateFileMode + ); + const bytes = new TextEncoder().encode( + serializeDatabaseSnapshotManifest(manifest) + ); + if (bytes.byteLength > maximumSnapshotManifestBytes) throw snapshotFailure(); + await handle.writeFile(bytes); + await handle.sync(); + await handle.chmod(immutableFileMode); + await handle.sync(); + const status = await handle.stat({ bigint: true }); + if ( + typeof process.getuid !== "function" || + !status.isFile() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.size !== BigInt(bytes.byteLength) || + (status.mode & 0o7777n) !== 0o400n + ) { + throw snapshotFailure(); + } + } catch { + failed = true; + } + const closed = await closeHandle(handle); + if (failed || !closed) throw snapshotFailure(); +} + +async function verifyFrozenSnapshot( + snapshotDirectory: string, + expected: DatabaseSnapshotManifest, + expectedFile: SnapshotFileIdentity +): Promise { + if (typeof process.getuid !== "function") throw snapshotFailure(); + const snapshotFile = path.join(snapshotDirectory, snapshotDatabaseFileName); + const manifestFile = path.join(snapshotDirectory, snapshotManifestFileName); + const [directory, entries] = await Promise.all([ + lstat(snapshotDirectory, { bigint: true }), + readdir(snapshotDirectory), + ]); + if ( + !validDirectory(directory, 0o500n, process.getuid()) || + directory.dev !== expectedFile.dev || + entries.length !== 2 || + entries.toSorted().join("\0") !== + [snapshotDatabaseFileName, snapshotManifestFileName].toSorted().join("\0") + ) { + throw snapshotFailure(); + } + const rawManifest = await readImmutableSnapshotManifest( + manifestFile, + expectedFile.dev + ); + const parsed = parseDatabaseSnapshotManifest(JSON.parse(rawManifest) as unknown); + if (JSON.stringify(parsed) !== JSON.stringify(expected)) throw snapshotFailure(); + + const observed = await hashImmutableSnapshot(snapshotFile, expectedFile); + if ( + observed.bytes !== expectedFile.bytes || + observed.sha256 !== expectedFile.sha256 + ) { + throw snapshotFailure(); + } +} + +async function readImmutableSnapshotManifest( + manifestFile: string, + expectedDevice: bigint +): Promise { + let handle: FileHandle | undefined; + let result: string | undefined; + try { + if (typeof process.getuid !== "function") throw snapshotFailure(); + handle = await open( + manifestFile, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const held = await handle.stat({ bigint: true }); + const canonical = await realpath(`/proc/self/fd/${handle.fd}`); + if ( + canonical !== manifestFile || + !held.isFile() || + held.isSymbolicLink() || + held.nlink !== 1n || + held.uid !== BigInt(process.getuid()) || + held.dev !== expectedDevice || + held.size <= 0n || + held.size > BigInt(maximumSnapshotManifestBytes) || + (held.mode & 0o7777n) !== 0o400n + ) { + throw snapshotFailure(); + } + const contents = Buffer.alloc(Number(held.size) + 1); + let offset = 0; + while (offset < contents.byteLength) { + const read = await handle.read( + contents, + offset, + contents.byteLength - offset, + offset + ); + if (read.bytesRead === 0) break; + offset += read.bytesRead; + } + const [heldAfter, pathAfter] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(manifestFile, { bigint: true }), + ]); + if ( + offset !== Number(held.size) || + heldAfter.dev !== held.dev || + heldAfter.ino !== held.ino || + heldAfter.size !== held.size || + heldAfter.ctimeNs !== held.ctimeNs || + heldAfter.mtimeNs !== held.mtimeNs || + pathAfter.dev !== held.dev || + pathAfter.ino !== held.ino || + pathAfter.size !== held.size || + pathAfter.ctimeNs !== held.ctimeNs || + pathAfter.mtimeNs !== held.mtimeNs + ) { + throw snapshotFailure(); + } + result = new TextDecoder("utf-8", { fatal: true }).decode( + contents.subarray(0, offset) + ); + } catch { + throw snapshotFailure(); + } finally { + const closed = await closeHandle(handle); + if (!closed) result = undefined; + } + if (result === undefined) throw snapshotFailure(); + return result; +} + +async function hashImmutableSnapshot( + snapshotFile: string, + expected: SnapshotFileIdentity +): Promise> { + let handle: FileHandle | undefined; + let result: Pick | undefined; + try { + handle = await open( + snapshotFile, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const status = await handle.stat({ bigint: true }); + const canonical = await realpath(`/proc/self/fd/${handle.fd}`); + if ( + typeof process.getuid !== "function" || + canonical !== snapshotFile || + !status.isFile() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.dev !== expected.dev || + status.ino !== expected.ino || + status.size !== BigInt(expected.bytes) || + (status.mode & 0o7777n) !== 0o400n + ) { + throw snapshotFailure(); + } + const hasher = new Bun.CryptoHasher("sha256"); + const buffer = Buffer.alloc( + Math.min(snapshotCopyBufferBytes, Number(status.size)) + ); + let offset = 0; + while (offset < Number(status.size)) { + const length = Math.min(buffer.byteLength, Number(status.size) - offset); + const read = await handle.read(buffer, 0, length, offset); + if (read.bytesRead <= 0) throw snapshotFailure(); + hasher.update(buffer.subarray(0, read.bytesRead)); + offset += read.bytesRead; + } + const [heldAfter, pathAfter] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(snapshotFile, { bigint: true }), + ]); + if ( + heldAfter.dev !== status.dev || + heldAfter.ino !== status.ino || + heldAfter.size !== status.size || + heldAfter.ctimeNs !== status.ctimeNs || + heldAfter.mtimeNs !== status.mtimeNs || + pathAfter.dev !== status.dev || + pathAfter.ino !== status.ino || + pathAfter.size !== status.size || + pathAfter.ctimeNs !== status.ctimeNs || + pathAfter.mtimeNs !== status.mtimeNs + ) { + throw snapshotFailure(); + } + result = Object.freeze({ + bytes: Number(status.size), + sha256: hasher.digest("hex"), + }); + } catch { + throw snapshotFailure(); + } finally { + const closed = await closeHandle(handle); + if (!closed) result = undefined; + } + if (!result) throw snapshotFailure(); + return result; +} + +async function removeOwnedSnapshot( + backupsDirectory: string, + ownedName: string +): Promise { + if ( + ownedName !== path.basename(ownedName) || + (!ownedName.startsWith(".stage-") && !v.is(lowercaseUuidV7Schema(), ownedName)) + ) { + throw snapshotFailure(); + } + const ownedPath = path.join(backupsDirectory, ownedName); + try { + const status = await lstat(ownedPath, { bigint: true }); + if ( + typeof process.getuid !== "function" || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) + ) { + throw snapshotFailure(); + } + await chmod(ownedPath, privateDirectoryMode); + const entries = await readdir(ownedPath, { withFileTypes: true }); + for (const entry of entries) { + if ( + !entry.isFile() || + (entry.name !== snapshotDatabaseFileName && + entry.name !== snapshotManifestFileName) + ) { + throw snapshotFailure(); + } + await chmod(path.join(ownedPath, entry.name), privateFileMode); + } + await rm(ownedPath, { recursive: true }); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw snapshotFailure(); + } +} + +async function snapshotPresentDatabase( + options: Extract, + prepared: PreparedDatabasePath, + hooks: DatabaseSnapshotTestHooks +): Promise { + const state = await openStableDirectory(options.stateDirectory, 0o700n); + const backupsPath = path.join(options.stateDirectory, "backups"); + let backups: OpenedDirectory | undefined; + let stage: OpenedDirectory | undefined; + let database: Database | undefined; + let ownedName: string | undefined; + let result: DatabaseSnapshotResult | undefined; + let expectedSourceIdentity: DatabaseSnapshotSourceIdentity | undefined; + let failure = false; + try { + backups = await openStableDirectory(backupsPath, 0o700n, state.identity.dev); + await requireSnapshotCapacity(prepared.filePath, backupsPath); + const migrations = await loadVerifiedMigrations({ + directory: options.migrationsDirectory, + }); + const finalName = options.transitionId; + const stageName = `.stage-${options.transitionId}`; + const backupsDescriptor = `/proc/self/fd/${backups.handle.fd}`; + await requireMissing(path.join(backupsDescriptor, finalName)); + await requireMissing(path.join(backupsDescriptor, stageName)); + await mkdir(path.join(backupsDescriptor, stageName), { + mode: privateDirectoryMode, + }); + ownedName = stageName; + const stagePath = path.join(backupsPath, stageName); + stage = await openStableDirectory(stagePath, 0o700n, backups.identity.dev); + const snapshotFile = path.join(stagePath, snapshotDatabaseFileName); + + database = new Database(prepared.filePath, { + create: false, + readwrite: true, + strict: true, + }); + if (database.filename !== prepared.filePath) throw snapshotFailure(); + configureDatabaseConnection(database); + validateVerifiedMigrations(database, migrations); + checkpointSourceDatabase(database); + await assertDatabasePathStillValid(prepared); + vacuumInto(database, snapshotFile); + await hooks.afterSnapshotCreated?.(snapshotFile); + await assertDatabasePathStillValid(prepared); + expectedSourceIdentity = sourceDatabaseIdentity( + await lstat(prepared.filePath, { bigint: true }) + ); + verifySnapshotDatabase(snapshotFile, migrations); + const fileIdentity = await hashAndFreezeSnapshotFile( + snapshotFile, + backups.identity.dev, + hooks.afterSnapshotFileOpen + ); + const manifest = parseDatabaseSnapshotManifest({ + database: { + bytes: fileIdentity.bytes, + sha256: fileIdentity.sha256, + }, + formatVersion: 1, + migrations: currentDatabaseSnapshotMigrations(), + releaseId: options.releaseId, + transitionId: options.transitionId, + }); + await writeSnapshotManifest( + path.join(stagePath, snapshotManifestFileName), + manifest + ); + await stage.handle.sync(); + await stage.handle.chmod(immutableDirectoryMode); + await stage.handle.sync(); + await hooks.afterSnapshotFrozen?.(stagePath); + await revalidateDirectory(stage, 0o500n); + await revalidateDirectory(backups, 0o700n); + await rename( + path.join(backupsDescriptor, stageName), + path.join(backupsDescriptor, finalName) + ); + ownedName = finalName; + await backups.handle.sync(); + const finalDirectory = path.join(backupsPath, finalName); + await verifyFrozenSnapshot(finalDirectory, manifest, fileIdentity); + await revalidateDirectory(state, 0o700n); + await revalidateDirectory(backups, 0o700n); + ownedName = undefined; + result = Object.freeze({ + manifest, + snapshotDirectory: finalDirectory, + snapshotFile: path.join(finalDirectory, snapshotDatabaseFileName), + sourceDatabase: expectedSourceIdentity, + state: "present" as const, + }); + } catch { + failure = true; + } + + let closeFailed = false; + if (database) { + try { + database.close(true); + } catch { + closeFailed = true; + } + } + if (!closeFailed) { + try { + await requireSidecarsAbsent(prepared.filePath); + const observedSourceIdentity = sourceDatabaseIdentity( + await lstat(prepared.filePath, { bigint: true }) + ); + if ( + !expectedSourceIdentity || + !sameSourceDatabaseIdentity( + expectedSourceIdentity, + observedSourceIdentity + ) + ) { + throw snapshotFailure(); + } + } catch { + closeFailed = true; + } + } + const [stageClosed, backupsClosed, stateClosed] = await Promise.all([ + closeHandle(stage?.handle), + closeHandle(backups?.handle), + closeHandle(state.handle), + ]); + if (ownedName) { + try { + await removeOwnedSnapshot(backupsPath, ownedName); + } catch { + closeFailed = true; + } + } + if ( + failure || + closeFailed || + !stageClosed || + !backupsClosed || + !stateClosed || + !result + ) { + throw snapshotFailure(); + } + return result; +} + +async function createSnapshot( + untrustedOptions: DatabaseSnapshotOptions, + hooks: DatabaseSnapshotTestHooks +): Promise { + const parsed = v.safeParse(snapshotOptionsSchema, untrustedOptions, { + abortEarly: true, + }); + if (!parsed.success) throw snapshotFailure(); + const options = Object.freeze(parsed.output); + const prepared = await prepareDatabasePath(options.stateDirectory, false); + if (options.expectedState === "absent") { + if (prepared !== undefined) throw snapshotFailure(); + await requireSidecarsAbsent( + path.join(options.stateDirectory, dashboardDatabaseFileName) + ); + return Object.freeze({ + state: "absent" as const, + transitionId: options.transitionId, + }); + } + if (!prepared) throw snapshotFailure(); + return snapshotPresentDatabase(options, prepared, hooks); +} + +/** + * Creates and verifies one release/schema-bound WAL-safe production snapshot. + * The caller must hold the wider deployment lease and keep all writers stopped. + * @param options Exact expected live state and current immutable release inputs. + * @param hooks Deterministic adversarial test boundaries. + * @returns Typed Effect yielding an absent marker or immutable snapshot artifact. + */ +export function createVerifiedDatabaseSnapshot( + options: DatabaseSnapshotOptions, + hooks: DatabaseSnapshotTestHooks = {} +): Effect.Effect { + return Effect.tryPromise({ + catch: () => new DatabaseSnapshotError({ message: snapshotFailureMessage }), + try: () => createSnapshot(options, hooks), + }); +} diff --git a/greenfield/src/server/database/runtime/databaseStartup.ts b/greenfield/src/server/database/runtime/databaseStartup.ts index f344b9a32..0cd3648f9 100644 --- a/greenfield/src/server/database/runtime/databaseStartup.ts +++ b/greenfield/src/server/database/runtime/databaseStartup.ts @@ -45,6 +45,13 @@ export interface DatabaseStartupDiagnostics { readonly startupMode: DatabaseRuntimeStartupMode; } +/** Diagnostics from a delivery-owned candidate database migration. */ +export interface DatabaseCandidateMigrationDiagnostics { + readonly appliedMigrations: number; + readonly connection: DatabaseConnectionDiagnostics; + readonly migrationCount: number; +} + const startupModeSchema = v.picklist( ["initialize-empty", "validate-only"] as const, "Database startup mode is invalid" @@ -262,6 +269,32 @@ function startOrValidateDatabase( }); } +function startCandidateDatabaseMigration( + database: Database, + migrations: readonly VerifiedMigration[], + releaseId: string +): DatabaseCandidateMigrationDiagnostics { + const connection = configureDatabaseConnection(database); + const state = inspectMigrationState(database, migrations); + let appliedMigrations = 0; + + if (state === "current") { + validateVerifiedMigrations(database, migrations); + } else { + if (state === "pending") assertDatabaseIntegrity(database); + appliedMigrations = applyVerifiedMigrations(database, migrations, { releaseId }); + if (appliedMigrations < 1 || appliedMigrations > migrations.length) { + throw invalidHistory(); + } + } + + return Object.freeze({ + appliedMigrations, + connection, + migrationCount: migrations.length, + }); +} + /** * Initializes an empty file or validates an already-current reviewed database. * @param database Retained process-owned native connection. @@ -278,3 +311,21 @@ export function initializeDatabaseRuntime( startOrValidateDatabase(database, migrations, options) ); } + +/** + * Initializes or advances one isolated delivery candidate to the complete reviewed graph. + * This boundary must never receive the live production state directory. + * @param database Retained native connection to a private candidate database. + * @param migrations Complete checksum-verified canonical migration graph. + * @param releaseId Candidate release identity recorded for newly applied nodes. + * @returns Candidate migration diagnostics after exact schema/integrity validation. + */ +export function initializeDatabaseCandidateMigration( + database: Database, + migrations: readonly VerifiedMigration[], + releaseId: string +) { + return retryDatabaseStartupOperation(() => + startCandidateDatabaseMigration(database, migrations, releaseId) + ); +} diff --git a/greenfield/src/server/platform/configuration/processConfiguration.ts b/greenfield/src/server/platform/configuration/processConfiguration.ts new file mode 100644 index 000000000..5d5f4a03e --- /dev/null +++ b/greenfield/src/server/platform/configuration/processConfiguration.ts @@ -0,0 +1,138 @@ +import path from "node:path"; + +import { + applicationConfigurationLimits, + configurationMetadata, + type ApplicationConfigurationEnvironmentName, + type ApplicationProcessRole, +} from "../../../shared/configuration/applicationConfigurationRegistry.ts"; +import { + ApplicationConfigurationError, + type ApplicationConfigurationFailureReason, +} from "./applicationConfigurationError.ts"; + +export type ApplicationNodeEnvironment = "development" | "production" | "test"; +export type ApplicationLogLevel = "debug" | "error" | "info" | "warn"; + +export type PickedApplicationEnvironment = Readonly< + Partial> +>; + +type RuntimeApplicationRole = Extract; +type ProjectedEnvironment = Readonly>>; + +const unsafeTextPattern = /[\p{Cc}\p{Cf}]/u; + +/** Throws one redacted immutable-configuration failure. */ +export function configurationError( + field: ApplicationConfigurationEnvironmentName, + reason: ApplicationConfigurationFailureReason +): never { + throw new ApplicationConfigurationError(field, reason); +} + +/** + * Reads only an explicit process role's own data properties and applies registry defaults. + * @param role Runtime process role being composed. + * @param environmentNames Exact registered names for the role. + * @param source Untrusted environment-like source. + * @param parseProjection Role schema parser for the observed projection. + * @returns Frozen selected values with defaults applied. + */ +export function pickApplicationEnvironment( + role: RuntimeApplicationRole, + environmentNames: readonly ApplicationConfigurationEnvironmentName[], + source: Readonly>, + parseProjection: ( + projection: Readonly> + ) => ProjectedEnvironment +): PickedApplicationEnvironment { + const sourceProjection = Object.create(null) as Record; + for (const environmentName of environmentNames) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(source, environmentName); + } catch { + configurationError(environmentName, "invalid"); + } + if (descriptor === undefined) continue; + if (!("value" in descriptor)) configurationError(environmentName, "invalid"); + sourceProjection[environmentName] = descriptor.value; + } + + const projected = parseProjection(sourceProjection); + const picked = Object.create(null) as Record< + ApplicationConfigurationEnvironmentName, + unknown + >; + for (const environmentName of environmentNames) { + const metadata = configurationMetadata(environmentName); + if (!metadata.roles.includes(role)) + configurationError(environmentName, "invalid"); + const supplied = projected[environmentName]; + picked[environmentName] = + supplied === undefined ? metadata.defaultValue : supplied; + } + return Object.freeze(picked); +} + +/** + * Reads one bounded trimmed string without retaining rejected input. + * @returns Validated string value. + */ +export function requiredConfigurationString( + input: PickedApplicationEnvironment, + field: ApplicationConfigurationEnvironmentName, + maximumLength: number, + allowEmpty = false +): string { + const value = input[field]; + if (value === null || value === undefined || value === "") { + if (allowEmpty && value === "") return value; + configurationError(field, "missing"); + } + if ( + typeof value !== "string" || + value.length > maximumLength || + value !== value.trim() || + unsafeTextPattern.test(value) + ) { + configurationError(field, "invalid"); + } + return value; +} + +/** + * Parses one exact enumerated configuration value. + * @returns Exact selected choice. + */ +export function configurationChoice( + input: PickedApplicationEnvironment, + field: ApplicationConfigurationEnvironmentName, + choices: readonly T[] +): T { + const value = requiredConfigurationString(input, field, 32); + if (!choices.includes(value as T)) configurationError(field, "invalid"); + return value as T; +} + +/** + * Parses the lexical project root before the composition root performs realpath validation. + * @returns Normalized absolute project-root candidate. + */ +export function configurationProjectRoot(input: PickedApplicationEnvironment): string { + const field = "MIRA_DASHBOARD_PROJECT_ROOT" as const; + const value = requiredConfigurationString( + input, + field, + applicationConfigurationLimits.projectRootMaximumLength + ); + if ( + !path.isAbsolute(value) || + value === path.parse(value).root || + path.resolve(value) !== value + ) { + configurationError(field, "invalid"); + } + return value; +} diff --git a/greenfield/src/server/platform/configuration/webConfiguration.ts b/greenfield/src/server/platform/configuration/webConfiguration.ts index f57669a9f..f575809ff 100644 --- a/greenfield/src/server/platform/configuration/webConfiguration.ts +++ b/greenfield/src/server/platform/configuration/webConfiguration.ts @@ -1,5 +1,4 @@ import { isIP } from "node:net"; -import path from "node:path"; import { minutesToMilliseconds } from "date-fns"; import { Redacted } from "effect"; @@ -8,7 +7,6 @@ import * as v from "valibot"; import { webAuthnRpIdSchema } from "../../../contracts/webauthn.ts"; import { applicationConfigurationLimits, - configurationMetadata, configurationEnvironmentNamesForRole, type ApplicationConfigurationEnvironmentName, } from "../../../shared/configuration/applicationConfigurationRegistry.ts"; @@ -22,12 +20,15 @@ import { parseRecentAuthenticationWindowMs } from "../../domains/security/recent import { parseBrowserOrigin } from "../../rawHttp/requestSecurity.ts"; import { parseGatewayCredentialVerifierUrl } from "../gateway/gatewayCredentialVerifier.ts"; import { - ApplicationConfigurationError, - type ApplicationConfigurationFailureReason, -} from "./applicationConfigurationError.ts"; - -export type ApplicationNodeEnvironment = "development" | "production" | "test"; -export type ApplicationLogLevel = "debug" | "error" | "info" | "warn"; + type ApplicationLogLevel, + type ApplicationNodeEnvironment, + configurationChoice, + configurationError, + configurationProjectRoot, + pickApplicationEnvironment, + type PickedApplicationEnvironment, + requiredConfigurationString, +} from "./processConfiguration.ts"; /** Immutable, validated configuration consumed by the greenfield web process. */ export interface WebConfiguration { @@ -44,7 +45,6 @@ export interface WebConfiguration { readonly webAuthnRelyingParty: WebAuthnRelyingPartyConfiguration; } -const unsafeTextPattern = /[\p{Cc}\p{Cf}]/u; const canonicalUnsignedIntegerPattern = /^(?:0|[1-9][0-9]*)$/u; const optionalEnvironmentValueSchema = v.optional(v.unknown()); @@ -69,84 +69,13 @@ export const webConfigurationEnvironmentSchema = v.object({ export const webConfigurationEnvironmentNames = configurationEnvironmentNamesForRole("web"); -type PickedEnvironment = Readonly< - Partial> ->; - -function configurationError( - field: ApplicationConfigurationEnvironmentName, - reason: ApplicationConfigurationFailureReason -): never { - throw new ApplicationConfigurationError(field, reason); -} - -function pickEnvironment(source: Readonly>): PickedEnvironment { - const sourceProjection = Object.create(null) as Record; - for (const environmentName of webConfigurationEnvironmentNames) { - let descriptor: PropertyDescriptor | undefined; - try { - descriptor = Object.getOwnPropertyDescriptor(source, environmentName); - } catch { - configurationError(environmentName, "invalid"); - } - if (descriptor === undefined) continue; - if (!("value" in descriptor)) { - configurationError(environmentName, "invalid"); - } - sourceProjection[environmentName] = descriptor.value; - } - const projected = v.parse(webConfigurationEnvironmentSchema, sourceProjection); - const picked = Object.create(null) as Record< - ApplicationConfigurationEnvironmentName, - unknown - >; - for (const environmentName of webConfigurationEnvironmentNames) { - const supplied = projected[environmentName]; - const fallback = configurationMetadata(environmentName).defaultValue; - picked[environmentName] = supplied === undefined ? fallback : supplied; - } - return picked; -} - -function requiredString( - input: PickedEnvironment, - field: ApplicationConfigurationEnvironmentName, - maximumLength: number, - allowEmpty = false -): string { - const value = input[field]; - if (value === null || value === undefined || value === "") { - if (allowEmpty && value === "") return value; - configurationError(field, "missing"); - } - if ( - typeof value !== "string" || - value.length > maximumLength || - value !== value.trim() || - unsafeTextPattern.test(value) - ) { - configurationError(field, "invalid"); - } - return value; -} - -function choice( - input: PickedEnvironment, - field: ApplicationConfigurationEnvironmentName, - choices: readonly T[] -): T { - const value = requiredString(input, field, 32); - if (!choices.includes(value as T)) configurationError(field, "invalid"); - return value as T; -} - function canonicalInteger( - input: PickedEnvironment, + input: PickedApplicationEnvironment, field: ApplicationConfigurationEnvironmentName, minimum: number, maximum: number ): number { - const value = requiredString(input, field, 16); + const value = requiredConfigurationString(input, field, 16); if (!canonicalUnsignedIntegerPattern.test(value)) { configurationError(field, "invalid"); } @@ -157,23 +86,6 @@ function canonicalInteger( return parsed; } -function projectRoot(input: PickedEnvironment): string { - const field = "MIRA_DASHBOARD_PROJECT_ROOT" as const; - const value = requiredString( - input, - field, - applicationConfigurationLimits.projectRootMaximumLength - ); - if ( - !path.isAbsolute(value) || - value === path.parse(value).root || - path.resolve(value) !== value - ) { - configurationError(field, "invalid"); - } - return value; -} - function parseIpAddress(value: string): string | undefined { if (isIP(value) === 4) return value; if (isIP(value) !== 6) return undefined; @@ -186,9 +98,9 @@ function parseIpAddress(value: string): string | undefined { } } -function trustedProxyAddresses(input: PickedEnvironment): readonly string[] { +function trustedProxyAddresses(input: PickedApplicationEnvironment): readonly string[] { const field = "MIRA_DASHBOARD_TRUSTED_PROXY_IPS" as const; - const raw = requiredString( + const raw = requiredConfigurationString( input, field, applicationConfigurationLimits.trustedProxyAddresses.maximumLength, @@ -213,9 +125,9 @@ function trustedProxyAddresses(input: PickedEnvironment): readonly string[] { return Object.freeze(canonical.toSorted()); } -function publicOrigin(input: PickedEnvironment): string { +function publicOrigin(input: PickedApplicationEnvironment): string { const field = "MIRA_DASHBOARD_PUBLIC_ORIGIN" as const; - const value = requiredString( + const value = requiredConfigurationString( input, field, applicationConfigurationLimits.publicOriginMaximumLength @@ -227,9 +139,9 @@ function publicOrigin(input: PickedEnvironment): string { } } -function gatewayUrl(input: PickedEnvironment): string { +function gatewayUrl(input: PickedApplicationEnvironment): string { const field = "OPENCLAW_GATEWAY_URL" as const; - const value = requiredString( + const value = requiredConfigurationString( input, field, applicationConfigurationLimits.gatewayUrlMaximumLength @@ -241,9 +153,9 @@ function gatewayUrl(input: PickedEnvironment): string { } } -function webAuthnOrigins(input: PickedEnvironment): readonly string[] { +function webAuthnOrigins(input: PickedApplicationEnvironment): readonly string[] { const field = "MIRA_DASHBOARD_WEBAUTHN_ORIGINS" as const; - const raw = requiredString( + const raw = requiredConfigurationString( input, field, applicationConfigurationLimits.webAuthnOrigins.maximumLength @@ -266,9 +178,9 @@ function webAuthnOrigins(input: PickedEnvironment): readonly string[] { return Object.freeze(values); } -function webAuthnRelyingPartyName(input: PickedEnvironment): string { +function webAuthnRelyingPartyName(input: PickedApplicationEnvironment): string { const field = "MIRA_DASHBOARD_WEBAUTHN_RP_NAME" as const; - const value = requiredString( + const value = requiredConfigurationString( input, field, applicationConfigurationLimits.webAuthnRpNameMaximumLength @@ -278,12 +190,12 @@ function webAuthnRelyingPartyName(input: PickedEnvironment): string { } function webAuthnConfiguration( - input: PickedEnvironment, + input: PickedApplicationEnvironment, origin: string ): WebAuthnRelyingPartyConfiguration { const rpIdField = "MIRA_DASHBOARD_WEBAUTHN_RP_ID" as const; const originsField = "MIRA_DASHBOARD_WEBAUTHN_ORIGINS" as const; - const rpId = requiredString( + const rpId = requiredConfigurationString( input, rpIdField, applicationConfigurationLimits.webAuthnRpIdMaximumLength @@ -312,9 +224,9 @@ function webAuthnConfiguration( return configuration; } -function totpKeyring(input: PickedEnvironment): Redacted.Redacted { +function totpKeyring(input: PickedApplicationEnvironment): Redacted.Redacted { const field = "MIRA_DASHBOARD_TOTP_KEYRING" as const; - const raw = requiredString( + const raw = requiredConfigurationString( input, field, applicationConfigurationLimits.totpKeyringMaximumLength @@ -328,7 +240,7 @@ function totpKeyring(input: PickedEnvironment): Redacted.Redacted { } function durationMs( - input: PickedEnvironment, + input: PickedApplicationEnvironment, field: "MIRA_DASHBOARD_RECENT_AUTH_MINUTES" | "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", parsePolicy: (value: number) => number ): number { @@ -354,8 +266,13 @@ function durationMs( export function parseWebConfiguration( source: Readonly> ): WebConfiguration { - const input = pickEnvironment(source); - const nodeEnvironment = choice(input, "NODE_ENV", [ + const input = pickApplicationEnvironment( + "web", + webConfigurationEnvironmentNames, + source, + (projection) => v.parse(webConfigurationEnvironmentSchema, projection) + ); + const nodeEnvironment = configurationChoice(input, "NODE_ENV", [ "development", "production", "test", @@ -366,7 +283,7 @@ export function parseWebConfiguration( } const configuration = Object.freeze({ gatewayUrl: gatewayUrl(input), - logLevel: choice(input, "MIRA_DASHBOARD_LOG_LEVEL", [ + logLevel: configurationChoice(input, "MIRA_DASHBOARD_LOG_LEVEL", [ "debug", "error", "info", @@ -379,7 +296,7 @@ export function parseWebConfiguration( applicationConfigurationLimits.port.minimum, applicationConfigurationLimits.port.maximum ), - projectRoot: projectRoot(input), + projectRoot: configurationProjectRoot(input), publicOrigin: origin, recentAuthenticationWindowMs: durationMs( input, diff --git a/greenfield/src/server/platform/configuration/workerConfiguration.test.ts b/greenfield/src/server/platform/configuration/workerConfiguration.test.ts new file mode 100644 index 000000000..a2912c64b --- /dev/null +++ b/greenfield/src/server/platform/configuration/workerConfiguration.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; + +import { ApplicationConfigurationError } from "./applicationConfigurationError.ts"; +import { + parseWorkerConfiguration, + workerConfigurationEnvironmentNames, + workerConfigurationEnvironmentSchema, +} from "./workerConfiguration.ts"; + +function validEnvironment(): Record { + return { + MIRA_DASHBOARD_LOG_LEVEL: "warn", + MIRA_DASHBOARD_PROJECT_ROOT: "/srv/mira-dashboard", + NODE_ENV: "production", + }; +} + +function configurationFailure(environment: Readonly>): unknown { + try { + parseWorkerConfiguration(environment); + } catch (error) { + return error; + } + throw new Error("Expected worker configuration parsing to fail"); +} + +describe("worker application configuration", () => { + test("parses role defaults into frozen configuration", () => { + const environment = validEnvironment(); + delete environment.MIRA_DASHBOARD_LOG_LEVEL; + delete environment.NODE_ENV; + + const configuration = parseWorkerConfiguration(environment); + + expect(configuration).toEqual({ + logLevel: "info", + nodeEnvironment: "production", + projectRoot: "/srv/mira-dashboard", + }); + expect(Object.isFrozen(configuration)).toBe(true); + }); + + test("observes only the worker registry projection", () => { + const observed = new Set(); + const guarded = new Proxy(validEnvironment(), { + getOwnPropertyDescriptor(target, property) { + observed.add(property); + if ( + typeof property === "string" && + !workerConfigurationEnvironmentNames.includes( + property as (typeof workerConfigurationEnvironmentNames)[number] + ) + ) { + throw new Error("Unregistered environment key was observed"); + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + + expect(parseWorkerConfiguration(guarded).logLevel).toBe("warn"); + expect([...observed].map(String).toSorted()).toEqual( + [...workerConfigurationEnvironmentNames].toSorted() + ); + expect( + Object.keys(workerConfigurationEnvironmentSchema.entries).toSorted() + ).toEqual([...workerConfigurationEnvironmentNames].toSorted()); + }); + + test("does not observe web-only secrets", () => { + const environment = validEnvironment(); + Object.defineProperty(environment, "MIRA_DASHBOARD_TOTP_KEYRING", { + get() { + throw new Error("secret getter must not run"); + }, + }); + + expect(parseWorkerConfiguration(environment).projectRoot).toBe( + "/srv/mira-dashboard" + ); + }); + + test("rejects hostile role fields with redacted errors", () => { + for (const [field, value, reason] of [ + ["NODE_ENV", "staging", "invalid"], + ["MIRA_DASHBOARD_LOG_LEVEL", "verbose", "invalid"], + ["MIRA_DASHBOARD_PROJECT_ROOT", "relative", "invalid"], + ["MIRA_DASHBOARD_PROJECT_ROOT", undefined, "missing"], + ] as const) { + const environment = validEnvironment(); + if (value === undefined) delete environment[field]; + else environment[field] = value; + const failure = configurationFailure(environment); + expect(failure).toBeInstanceOf(ApplicationConfigurationError); + expect(failure).toMatchObject({ field, reason }); + expect(JSON.stringify(failure)).not.toContain(String(value)); + expect("cause" in (failure as object)).toBe(false); + } + }); + + test("rejects registered accessors without invoking them", () => { + let getterCalls = 0; + const environment = validEnvironment(); + Object.defineProperty(environment, "NODE_ENV", { + enumerable: true, + get() { + getterCalls += 1; + return "production"; + }, + }); + + expect(configurationFailure(environment)).toMatchObject({ + field: "NODE_ENV", + reason: "invalid", + }); + expect(getterCalls).toBe(0); + }); +}); diff --git a/greenfield/src/server/platform/configuration/workerConfiguration.ts b/greenfield/src/server/platform/configuration/workerConfiguration.ts new file mode 100644 index 000000000..801bbe789 --- /dev/null +++ b/greenfield/src/server/platform/configuration/workerConfiguration.ts @@ -0,0 +1,60 @@ +import * as v from "valibot"; + +import { configurationEnvironmentNamesForRole } from "../../../shared/configuration/applicationConfigurationRegistry.ts"; +import { + configurationChoice, + configurationProjectRoot, + pickApplicationEnvironment, + type ApplicationLogLevel, + type ApplicationNodeEnvironment, +} from "./processConfiguration.ts"; + +/** Immutable, validated configuration consumed by the greenfield worker process. */ +export interface WorkerConfiguration { + readonly logLevel: ApplicationLogLevel; + readonly nodeEnvironment: ApplicationNodeEnvironment; + readonly projectRoot: string; +} + +const optionalEnvironmentValueSchema = v.optional(v.unknown()); + +/** Valibot projection for the complete accepted worker-process environment surface. */ +export const workerConfigurationEnvironmentSchema = v.object({ + MIRA_DASHBOARD_LOG_LEVEL: optionalEnvironmentValueSchema, + MIRA_DASHBOARD_PROJECT_ROOT: optionalEnvironmentValueSchema, + NODE_ENV: optionalEnvironmentValueSchema, +}); + +/** Registered environment names consumed by the worker-process parser. */ +export const workerConfigurationEnvironmentNames = + configurationEnvironmentNamesForRole("worker"); + +/** + * Parses an injected untrusted environment record into immutable worker configuration. + * @param source Untrusted injected environment-like record. + * @returns Frozen worker configuration containing no web-only or secret fields. + */ +export function parseWorkerConfiguration( + source: Readonly> +): WorkerConfiguration { + const input = pickApplicationEnvironment( + "worker", + workerConfigurationEnvironmentNames, + source, + (projection) => v.parse(workerConfigurationEnvironmentSchema, projection) + ); + return Object.freeze({ + logLevel: configurationChoice(input, "MIRA_DASHBOARD_LOG_LEVEL", [ + "debug", + "error", + "info", + "warn", + ] as const), + nodeEnvironment: configurationChoice(input, "NODE_ENV", [ + "development", + "production", + "test", + ] as const), + projectRoot: configurationProjectRoot(input), + }); +} diff --git a/greenfield/src/server/platform/filesystem/immutableReleaseFile.ts b/greenfield/src/server/platform/filesystem/immutableReleaseFile.ts new file mode 100644 index 000000000..323348856 --- /dev/null +++ b/greenfield/src/server/platform/filesystem/immutableReleaseFile.ts @@ -0,0 +1,279 @@ +import { constants, type BigIntStats } from "node:fs"; +import { lstat, open, realpath, type FileHandle } from "node:fs/promises"; +import path from "node:path"; + +const immutableReleaseFileFailureMessage = "Immutable release file is invalid"; +const maximumBrowserAssetBytes = 8 * 1024 * 1024; +const readFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +const writePermissionBits = 0o222n; + +/** Expected immutable artifact identity taken from a verified release manifest. */ +export interface ImmutableReleaseFileIdentity { + readonly bytes: number; + readonly path: string; + readonly sha256: string; +} + +/** Deterministic mutation boundary exposed only to adversarial tests. */ +export interface ImmutableReleaseFileTestHooks { + readonly afterRead?: (artifactPath: string) => Promise | void; +} + +/** Bound no-follow reader for one immutable release's browser tree. */ +export interface ImmutableReleaseFileReader { + read(identity: ImmutableReleaseFileIdentity): Promise; +} + +interface ReleaseDirectorySnapshot { + readonly browser: BigIntStats; + readonly release: BigIntStats; + readonly userId: number; +} + +interface IntermediateDirectorySnapshot { + readonly path: string; + readonly status: BigIntStats; +} + +function immutableReleaseFileFailure(): Error { + return new Error(immutableReleaseFileFailureMessage); +} + +function currentUserId(): number { + if (process.platform !== "linux" || typeof process.getuid !== "function") { + throw immutableReleaseFileFailure(); + } + return process.getuid(); +} + +function sameSnapshot(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.ctimeNs === right.ctimeNs && + left.mtimeNs === right.mtimeNs + ); +} + +function validImmutableDirectory( + status: BigIntStats, + userId: number, + expectedDevice?: bigint +): boolean { + return ( + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(userId) && + (status.mode & writePermissionBits) === 0n && + (expectedDevice === undefined || status.dev === expectedDevice) + ); +} + +function validImmutableFile( + status: BigIntStats, + expectedBytes: number, + snapshot: ReleaseDirectorySnapshot +): boolean { + return ( + status.isFile() && + !status.isSymbolicLink() && + status.nlink === 1n && + status.uid === BigInt(snapshot.userId) && + status.dev === snapshot.release.dev && + status.size === BigInt(expectedBytes) && + (status.mode & writePermissionBits) === 0n + ); +} + +function validArtifactIdentity(identity: ImmutableReleaseFileIdentity): boolean { + return ( + identity.path.startsWith("browser/") && + !identity.path.includes("\0") && + !identity.path.includes("\\") && + !identity.path + .split("/") + .some( + (segment) => segment.length === 0 || segment === "." || segment === ".." + ) && + Number.isSafeInteger(identity.bytes) && + identity.bytes > 0 && + identity.bytes <= maximumBrowserAssetBytes && + /^[a-f\d]{64}$/u.test(identity.sha256) + ); +} + +async function snapshotIntermediateDirectories( + browserRoot: string, + snapshot: ReleaseDirectorySnapshot, + identity: ImmutableReleaseFileIdentity +): Promise { + const relativeDirectory = path.posix.dirname(identity.path); + const segments = relativeDirectory.split("/").slice(1); + const directories: IntermediateDirectorySnapshot[] = []; + let current = browserRoot; + for (const segment of segments) { + current = path.join(current, segment); + const [canonical, status] = await Promise.all([ + realpath(current), + lstat(current, { bigint: true }), + ]); + if ( + canonical !== current || + !validImmutableDirectory(status, snapshot.userId, snapshot.release.dev) + ) { + throw immutableReleaseFileFailure(); + } + directories.push(Object.freeze({ path: current, status })); + } + return Object.freeze(directories); +} + +function sha256(bytes: Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); +} + +async function closeFile(file: FileHandle | undefined): Promise { + if (!file) return true; + try { + await file.close(); + return true; + } catch { + return false; + } +} + +async function readExactFile( + releaseRoot: string, + browserRoot: string, + snapshot: ReleaseDirectorySnapshot, + identity: ImmutableReleaseFileIdentity, + testHooks: ImmutableReleaseFileTestHooks +): Promise { + if (!validArtifactIdentity(identity)) throw immutableReleaseFileFailure(); + const filePath = path.join(releaseRoot, identity.path); + if (!filePath.startsWith(`${browserRoot}${path.sep}`)) { + throw immutableReleaseFileFailure(); + } + + let file: FileHandle | undefined; + let contents: Buffer | undefined; + let failed = false; + try { + const intermediate = await snapshotIntermediateDirectories( + browserRoot, + snapshot, + identity + ); + file = await open(filePath, readFlags); + const held = await file.stat({ bigint: true }); + const descriptorPath = await realpath(`/proc/self/fd/${file.fd}`); + if ( + descriptorPath !== filePath || + !validImmutableFile(held, identity.bytes, snapshot) + ) { + throw immutableReleaseFileFailure(); + } + + const buffer = Buffer.alloc(identity.bytes + 1); + let bytesRead = 0; + while (bytesRead < buffer.byteLength) { + const result = await file.read( + buffer, + bytesRead, + buffer.byteLength - bytesRead, + bytesRead + ); + if (result.bytesRead === 0) break; + bytesRead += result.bytesRead; + } + await testHooks.afterRead?.(identity.path); + const [heldAfter, pathAfter, browserAfter, releaseAfter, intermediateAfter] = + await Promise.all([ + file.stat({ bigint: true }), + lstat(filePath, { bigint: true }), + lstat(browserRoot, { bigint: true }), + lstat(releaseRoot, { bigint: true }), + Promise.all( + intermediate.map((directory) => + lstat(directory.path, { bigint: true }) + ) + ), + ]); + if ( + bytesRead !== identity.bytes || + !sameSnapshot(held, heldAfter) || + !sameSnapshot(held, pathAfter) || + !sameSnapshot(snapshot.browser, browserAfter) || + !sameSnapshot(snapshot.release, releaseAfter) || + intermediate.some( + (directory, index) => + !sameSnapshot(directory.status, intermediateAfter[index]!) || + !validImmutableDirectory( + intermediateAfter[index]!, + snapshot.userId, + snapshot.release.dev + ) + ) || + sha256(buffer.subarray(0, bytesRead)) !== identity.sha256 + ) { + throw immutableReleaseFileFailure(); + } + contents = buffer.subarray(0, bytesRead); + } catch { + failed = true; + } + if (!(await closeFile(file))) failed = true; + if (failed || contents === undefined) throw immutableReleaseFileFailure(); + return contents; +} + +/** + * Revalidates one immutable runtime release and binds a browser-artifact reader to it. + * @param releaseRoot Canonical exact release directory, never a mutable pointer. + * @param testHooks Deterministic adversarial hooks used only by tests. + * @returns Reader that verifies manifest size/hash and filesystem identity per request. + */ +export async function createImmutableReleaseFileReader( + releaseRoot: string, + testHooks: ImmutableReleaseFileTestHooks = {} +): Promise { + if ( + !path.isAbsolute(releaseRoot) || + releaseRoot.includes("\0") || + path.resolve(releaseRoot) !== releaseRoot + ) { + throw immutableReleaseFileFailure(); + } + try { + const userId = currentUserId(); + const browserRoot = path.join(releaseRoot, "browser"); + const [canonicalRelease, canonicalBrowser, release, browser] = await Promise.all([ + realpath(releaseRoot), + realpath(browserRoot), + lstat(releaseRoot, { bigint: true }), + lstat(browserRoot, { bigint: true }), + ]); + if ( + canonicalRelease !== releaseRoot || + canonicalBrowser !== browserRoot || + !validImmutableDirectory(release, userId) || + !validImmutableDirectory(browser, userId, release.dev) + ) { + throw immutableReleaseFileFailure(); + } + const directorySnapshot = Object.freeze({ browser, release, userId }); + return Object.freeze({ + read: (identity: ImmutableReleaseFileIdentity) => + readExactFile( + releaseRoot, + browserRoot, + directorySnapshot, + identity, + testHooks + ), + }); + } catch { + throw immutableReleaseFileFailure(); + } +} diff --git a/greenfield/src/server/platform/filesystem/projectLayout.test.ts b/greenfield/src/server/platform/filesystem/projectLayout.test.ts new file mode 100644 index 000000000..1163153b7 --- /dev/null +++ b/greenfield/src/server/platform/filesystem/projectLayout.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + deriveDashboardProjectLayout, + resolveDashboardProjectLayout, +} from "./projectLayout.ts"; + +const temporaryRoots: string[] = []; + +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + throw new Error("Expected the promise to reject"); +} + +afterEach(async () => { + await Promise.all( + temporaryRoots + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function temporaryProjectRoot(): Promise { + const parent = await mkdtemp(path.join(tmpdir(), "mira-project-layout-")); + temporaryRoots.push(parent); + const root = path.join(parent, "dashboard"); + await mkdir(root, { mode: 0o700 }); + await chmod(root, 0o700); + return root; +} + +describe("Dashboard project layout", () => { + test("derives every persistent path beneath development or production", async () => { + const root = await temporaryProjectRoot(); + const layout = await resolveDashboardProjectLayout(root); + + expect(layout).toEqual(deriveDashboardProjectLayout(root)); + expect(layout.production.state.root).toBe(path.join(root, "production/state")); + expect(layout.production.state.logs).toBe( + path.join(root, "production/state/logs") + ); + expect(layout.production.state.backups).toBe( + path.join(root, "production/state/backups") + ); + expect(layout.production.state.jobOutput).toBe( + path.join(root, "production/state/job-output") + ); + expect(layout.development.worktrees).toBe( + path.join(root, "development/worktrees") + ); + expect(Object.isFrozen(layout.production.state)).toBe(true); + }); + + test("rejects relative, root, NUL-tainted and non-normalized candidates", () => { + for (const candidate of [ + ".", + path.parse(process.cwd()).root, + `${process.cwd()}\0escape`, + `${process.cwd()}/nested/..`, + ]) { + expect(() => deriveDashboardProjectLayout(candidate)).toThrow( + "Dashboard project root is invalid" + ); + } + }); + + test("rejects a symlinked project-root entry", async () => { + const target = await temporaryProjectRoot(); + const link = path.join(path.dirname(target), "dashboard-link"); + await symlink(target, link, "dir"); + + expect(await rejectionOf(resolveDashboardProjectLayout(link))).toMatchObject({ + message: "Dashboard project root is invalid", + }); + }); + + test("rejects a writable project root or non-sticky ancestor", async () => { + const root = await temporaryProjectRoot(); + const parent = path.dirname(root); + + await chmod(root, 0o770); + expect(await rejectionOf(resolveDashboardProjectLayout(root))).toBeInstanceOf( + TypeError + ); + + await chmod(root, 0o700); + await chmod(parent, 0o770); + expect(await rejectionOf(resolveDashboardProjectLayout(root))).toBeInstanceOf( + TypeError + ); + }); +}); diff --git a/greenfield/src/server/platform/filesystem/projectLayout.ts b/greenfield/src/server/platform/filesystem/projectLayout.ts new file mode 100644 index 000000000..a4bd40c75 --- /dev/null +++ b/greenfield/src/server/platform/filesystem/projectLayout.ts @@ -0,0 +1,153 @@ +import type { BigIntStats } from "node:fs"; +import { lstat, realpath } from "node:fs/promises"; +import path from "node:path"; + +/** Stable host paths derived from one Dashboard project root. */ +export interface DashboardProjectLayout { + readonly development: { + readonly root: string; + readonly state: string; + readonly worktrees: string; + }; + readonly production: { + readonly checkout: string; + readonly releases: string; + readonly root: string; + readonly runtimes: string; + readonly state: { + readonly backups: string; + readonly database: string; + readonly jobOutput: string; + readonly logs: string; + readonly root: string; + }; + }; + readonly root: string; +} + +function invalidProjectRoot(): TypeError { + return new TypeError("Dashboard project root is invalid"); +} + +function currentUserId(): number { + if (typeof process.getuid !== "function") throw invalidProjectRoot(); + return process.getuid(); +} + +function hasProtectedDirectoryEntry( + status: BigIntStats, + childOwnerId: bigint, + userId: number +): boolean { + const trustedOwner = status.uid === 0n || status.uid === BigInt(userId); + if (!status.isDirectory() || status.isSymbolicLink() || !trustedOwner) { + return false; + } + if ((status.mode & 0o022n) === 0n) return true; + const sticky = (status.mode & 0o1000n) !== 0n; + const protectedChildOwner = childOwnerId === 0n || childOwnerId === BigInt(userId); + return sticky && protectedChildOwner; +} + +async function assertProtectedAncestorChain( + root: string, + rootStatus: BigIntStats, + userId: number +): Promise { + let currentPath = root; + let currentStatus = rootStatus; + let childOwnerId = BigInt(userId); + + while (true) { + if (!hasProtectedDirectoryEntry(currentStatus, childOwnerId, userId)) { + throw invalidProjectRoot(); + } + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) return; + childOwnerId = currentStatus.uid; + currentPath = parentPath; + currentStatus = await lstat(currentPath, { bigint: true }); + } +} + +function normalizedAbsoluteProjectRoot(projectRoot: string): string { + if ( + projectRoot.includes("\0") || + !path.isAbsolute(projectRoot) || + projectRoot === path.parse(projectRoot).root || + path.resolve(projectRoot) !== projectRoot + ) { + throw invalidProjectRoot(); + } + return projectRoot; +} + +/** + * Derives the only supported development and production host layout. + * This is lexical and does not create, repair, or trust any filesystem entry. + * @param projectRoot Normalized absolute stable Dashboard project root. + * @returns Frozen project-local path inventory. + */ +export function deriveDashboardProjectLayout( + projectRoot: string +): DashboardProjectLayout { + const root = normalizedAbsoluteProjectRoot(projectRoot); + const developmentRoot = path.join(root, "development"); + const productionRoot = path.join(root, "production"); + const stateRoot = path.join(productionRoot, "state"); + const development = Object.freeze({ + root: developmentRoot, + state: path.join(developmentRoot, "state"), + worktrees: path.join(developmentRoot, "worktrees"), + }); + const state = Object.freeze({ + backups: path.join(stateRoot, "backups"), + database: path.join(stateRoot, "mira-dashboard.db"), + jobOutput: path.join(stateRoot, "job-output"), + logs: path.join(stateRoot, "logs"), + root: stateRoot, + }); + const production = Object.freeze({ + checkout: path.join(productionRoot, "checkout"), + releases: path.join(productionRoot, "releases"), + root: productionRoot, + runtimes: path.join(productionRoot, "runtimes"), + state, + }); + return Object.freeze({ + development, + production, + root, + }); +} + +/** + * Resolves and validates the existing stable project root before host paths are opened. + * Runtime startup never creates directories or repairs permissions. + * @param projectRoot Normalized absolute project-root candidate. + * @returns Frozen canonical project-local path inventory. + */ +export async function resolveDashboardProjectLayout( + projectRoot: string +): Promise { + const root = normalizedAbsoluteProjectRoot(projectRoot); + try { + const userId = currentUserId(); + const [canonicalRoot, status] = await Promise.all([ + realpath(root), + lstat(root, { bigint: true }), + ]); + if ( + canonicalRoot !== root || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(userId) + ) { + throw invalidProjectRoot(); + } + await assertProtectedAncestorChain(root, status, userId); + } catch { + throw invalidProjectRoot(); + } + return deriveDashboardProjectLayout(root); +} diff --git a/greenfield/src/server/platform/observability/projectFileLogSink.test.ts b/greenfield/src/server/platform/observability/projectFileLogSink.test.ts new file mode 100644 index 000000000..0de0ff578 --- /dev/null +++ b/greenfield/src/server/platform/observability/projectFileLogSink.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, renameSync } from "node:fs"; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { createProjectFileLogDestination } from "./projectFileLogSink.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function logsDirectory(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "mira-project-logs-")); + temporaryDirectories.push(root); + const logs = path.join(root, "logs"); + await mkdir(logs, { mode: 0o700 }); + await chmod(logs, 0o700); + return logs; +} + +describe("project-local log destination", () => { + test("writes primary and fallback logs with private bounded files", async () => { + const logs = await logsDirectory(); + const destination = createProjectFileLogDestination(logs, "web"); + + destination.sink.write("primary\n", "info"); + destination.fallbackWrite("fallback\n"); + destination.sink.flush?.(); + destination.sink.flush?.(); + + expect(await readFile(path.join(logs, "web.ndjson"), "utf8")).toBe("primary\n"); + expect(await readFile(path.join(logs, "web-fallback.ndjson"), "utf8")).toBe( + "fallback\n" + ); + const logStatus = await lstat(path.join(logs, "web.ndjson")); + expect(Number(logStatus.mode & 0o7777)).toBe(0o600); + expect(() => destination.sink.write("late\n", "info")).toThrow( + "Project-local log destination is invalid" + ); + }); + + test("rejects permissive directories and symbolic log files", async () => { + const permissiveLogs = await logsDirectory(); + await chmod(permissiveLogs, 0o755); + expect(() => createProjectFileLogDestination(permissiveLogs, "worker")).toThrow( + "Project-local log destination is invalid" + ); + + const linkedLogs = await logsDirectory(); + const outside = path.join(path.dirname(linkedLogs), "outside.ndjson"); + await symlink(outside, path.join(linkedLogs, "web.ndjson")); + expect(() => createProjectFileLogDestination(linkedLogs, "web")).toThrow( + "Project-local log destination is invalid" + ); + }); + + test("rejects a directory-entry swap after holding its descriptor", async () => { + const logs = await logsDirectory(); + const displaced = path.join(path.dirname(logs), "displaced-logs"); + let replaced = false; + + expect(() => + createProjectFileLogDestination(logs, "worker", { + afterDirectoryOpen() { + replaced = true; + renameSync(logs, displaced); + mkdirSync(logs, { mode: 0o700 }); + }, + }) + ).toThrow("Project-local log destination is invalid"); + expect(replaced).toBe(true); + }); +}); diff --git a/greenfield/src/server/platform/observability/projectFileLogSink.ts b/greenfield/src/server/platform/observability/projectFileLogSink.ts new file mode 100644 index 000000000..32e5217a4 --- /dev/null +++ b/greenfield/src/server/platform/observability/projectFileLogSink.ts @@ -0,0 +1,218 @@ +import { + closeSync, + constants, + fstatSync, + fsyncSync, + lstatSync, + openSync, + realpathSync, + writeSync, +} from "node:fs"; +import path from "node:path"; + +import type { StructuredLogLevel, StructuredLogSink } from "./structuredLogger.ts"; + +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const fileFlags = + constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW; +const privateDirectoryMode = 0o700; +const privateFileMode = 0o600; +const permissionBits = 0o7777; +const maximumPrimaryLogBytes = 128 * 1024 * 1024; +const maximumFallbackLogBytes = 1024 * 1024; + +/** Project-local logger sink and its independent direct-fallback writer. */ +export interface ProjectFileLogDestination { + readonly fallbackWrite: (line: string) => void; + readonly sink: StructuredLogSink; +} + +/** Synchronous deterministic mutation boundary used only by adversarial tests. */ +export interface ProjectFileLogDestinationTestHooks { + readonly afterDirectoryOpen?: () => void; +} + +interface OpenedLogFile { + readonly descriptor: number; + readonly maximumBytes: number; + writtenBytes: number; +} + +function invalidProjectLogDestination(): Error { + return new Error("Project-local log destination is invalid"); +} + +function currentUserId(): number { + if (process.platform !== "linux" || typeof process.getuid !== "function") { + throw invalidProjectLogDestination(); + } + return process.getuid(); +} + +function sameIdentity( + left: ReturnType, + right: ReturnType +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function writeAll(file: OpenedLogFile, line: string): void { + const bytes = Buffer.from(line, "utf8"); + if (file.writtenBytes + bytes.byteLength > file.maximumBytes) { + throw new RangeError("Project-local log byte budget exhausted"); + } + let written = 0; + while (written < bytes.byteLength) { + const count = writeSync( + file.descriptor, + bytes, + written, + bytes.byteLength - written, + null + ); + if (count <= 0) throw invalidProjectLogDestination(); + written += count; + } + file.writtenBytes += written; +} + +function openLogFile( + directoryDescriptor: number, + canonicalDirectory: string, + filename: string, + userId: number, + maximumBytes: number +): OpenedLogFile { + const descriptor = openSync( + path.join(`/proc/self/fd/${directoryDescriptor}`, filename), + fileFlags, + privateFileMode + ); + try { + const status = fstatSync(descriptor); + const canonicalFile = realpathSync(`/proc/self/fd/${descriptor}`); + const pathStatus = lstatSync(path.join(canonicalDirectory, filename)); + if ( + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1 || + status.uid !== userId || + (status.mode & permissionBits) !== privateFileMode || + status.size > maximumBytes || + path.dirname(canonicalFile) !== canonicalDirectory || + path.basename(canonicalFile) !== filename || + !pathStatus.isFile() || + pathStatus.isSymbolicLink() || + !sameIdentity(status, pathStatus) + ) { + throw invalidProjectLogDestination(); + } + return { descriptor, maximumBytes, writtenBytes: status.size }; + } catch (error) { + closeSync(descriptor); + throw error; + } +} + +/** + * Opens bounded append-only process logs beneath an already private state directory. + * Runtime startup validates but never repairs directory or file permissions. + * @param logsDirectory Canonical project-local `production/state/logs` path. + * @param processRole Fixed process identity used for stable log filenames. + * @param testHooks Deterministic adversarial hooks used only by tests. + * @returns Synchronous logger sink and independent fallback writer. + */ +export function createProjectFileLogDestination( + logsDirectory: string, + processRole: "web" | "worker", + testHooks: ProjectFileLogDestinationTestHooks = {} +): ProjectFileLogDestination { + if ( + !path.isAbsolute(logsDirectory) || + logsDirectory.includes("\0") || + path.resolve(logsDirectory) !== logsDirectory + ) { + throw invalidProjectLogDestination(); + } + const userId = currentUserId(); + let directoryDescriptor: number | undefined; + let primary: OpenedLogFile | undefined; + let fallback: OpenedLogFile | undefined; + try { + directoryDescriptor = openSync(logsDirectory, directoryFlags); + const held = fstatSync(directoryDescriptor); + const canonicalDirectory = realpathSync(`/proc/self/fd/${directoryDescriptor}`); + testHooks.afterDirectoryOpen?.(); + const after = lstatSync(logsDirectory); + if ( + canonicalDirectory !== logsDirectory || + !held.isDirectory() || + held.isSymbolicLink() || + held.uid !== userId || + (held.mode & permissionBits) !== privateDirectoryMode || + !after.isDirectory() || + after.isSymbolicLink() || + !sameIdentity(held, after) + ) { + throw invalidProjectLogDestination(); + } + primary = openLogFile( + directoryDescriptor, + canonicalDirectory, + `${processRole}.ndjson`, + userId, + maximumPrimaryLogBytes + ); + fallback = openLogFile( + directoryDescriptor, + canonicalDirectory, + `${processRole}-fallback.ndjson`, + userId, + maximumFallbackLogBytes + ); + } catch { + if (primary) closeSync(primary.descriptor); + if (directoryDescriptor !== undefined) closeSync(directoryDescriptor); + throw invalidProjectLogDestination(); + } + + let closed = false; + const destination = Object.freeze({ + fallbackWrite(line: string) { + if (closed) throw invalidProjectLogDestination(); + writeAll(fallback, line); + fsyncSync(fallback.descriptor); + }, + sink: Object.freeze({ + flush(): undefined { + if (closed) return undefined; + closed = true; + try { + fsyncSync(primary.descriptor); + fsyncSync(fallback.descriptor); + } finally { + try { + closeSync(fallback.descriptor); + } finally { + try { + closeSync(primary.descriptor); + } finally { + closeSync(directoryDescriptor); + } + } + } + return undefined; + }, + write(line: string, _level: StructuredLogLevel): undefined { + if (closed) throw invalidProjectLogDestination(); + writeAll(primary, line); + return undefined; + }, + }), + }); + return destination; +} diff --git a/greenfield/src/server/platform/observability/structuredLogger.test.ts b/greenfield/src/server/platform/observability/structuredLogger.test.ts index 9b426e324..3ef55e009 100644 --- a/greenfield/src/server/platform/observability/structuredLogger.test.ts +++ b/greenfield/src/server/platform/observability/structuredLogger.test.ts @@ -286,6 +286,38 @@ test("accepts the exact Bun canary version-with-revision identity", () => { }); }); +test("filters below the configured process log level", () => { + const lines: string[] = []; + const logger = createStructuredLogger({ + identity, + minimumLevel: "warn", + sink: { + write(line) { + lines.push(line); + }, + }, + }); + + logger.debug({ component: "runtime", event: "runtime.started" }); + logger.info({ component: "runtime", event: "runtime.started" }); + logger.warn({ component: "runtime", event: "runtime.started" }); + logger.error({ component: "runtime", event: "runtime.start_failed" }); + + expect( + lines.map((line) => { + const record = JSON.parse(line) as { readonly level?: unknown }; + return record.level; + }) + ).toEqual(["warn", "error"]); + expect(() => + createStructuredLogger({ + identity, + minimumLevel: "trace" as never, + sink: { write() {} }, + }) + ).toThrow("Structured logger minimum level is invalid"); +}); + test("snapshots limits and bound sink methods at construction", () => { const fallbacks: string[] = []; const limits = { maximumSerializedBytes: 1 }; diff --git a/greenfield/src/server/platform/observability/structuredLogger.ts b/greenfield/src/server/platform/observability/structuredLogger.ts index 4bca7fda8..5c8d7e837 100644 --- a/greenfield/src/server/platform/observability/structuredLogger.ts +++ b/greenfield/src/server/platform/observability/structuredLogger.ts @@ -94,6 +94,7 @@ export interface StructuredLoggerOptions { readonly fallbackWrite?: (line: string) => void; readonly identity: StructuredLoggerIdentity; readonly limits?: StructuredLogLimits; + readonly minimumLevel?: StructuredLogLevel; readonly now?: () => Date; readonly sink: StructuredLogSink; } @@ -112,7 +113,9 @@ const structuredEventComponents = Object.freeze({ "http.response.created": "http", "realtime.runner.failed": "realtime-event-pump", "runtime.logger.connected": "application-runtime", + "runtime.start_failed": "runtime", "runtime.started": "runtime", + "runtime.stopped": "runtime", "trpc.request.defect": "trpc", } as const); @@ -126,6 +129,8 @@ const structuredLogLevels = new Set([ "info", "warn", ]); +const structuredLogLevelPriorities: Readonly> = + Object.freeze({ debug: 0, error: 3, fatal: 4, info: 1, warn: 2 }); function validStructuredName(value: string): boolean { return value.length <= 128 && structuredNamePattern.test(value); @@ -340,6 +345,10 @@ export function createStructuredLogger( }); validateLoggerLimits(limits); const now = options.now ?? (() => new Date()); + const minimumLevel = options.minimumLevel ?? "debug"; + if (!structuredLogLevels.has(minimumLevel)) { + throw new TypeError("Structured logger minimum level is invalid"); + } const sinkWrite = options.sink.write.bind(options.sink); const sinkFlush = options.sink.flush?.bind(options.sink); let fallbackWritten = false; @@ -360,8 +369,14 @@ export function createStructuredLogger( } }; const log = (level: StructuredLogLevel, event: StructuredLogEvent): void => { + const safeLevel = normalizedLogLevel(level); + if ( + structuredLogLevelPriorities[safeLevel] < + structuredLogLevelPriorities[minimumLevel] + ) { + return; + } try { - const safeLevel = normalizedLogLevel(level); const result: unknown = sinkWrite( serializeRecord(makeRecord(identity, now, safeLevel, event), limits), safeLevel diff --git a/greenfield/src/server/platform/release/runtimeRelease.test.ts b/greenfield/src/server/platform/release/runtimeRelease.test.ts new file mode 100644 index 000000000..07d849fd6 --- /dev/null +++ b/greenfield/src/server/platform/release/runtimeRelease.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + releaseBuildCommands, + releaseProcessRoles, + serializeReleaseManifest, +} from "../../../shared/releaseManifest.ts"; +import { loadRuntimeRelease } from "./runtimeRelease.ts"; + +const temporaryDirectories: string[] = []; +const commitSha = "b".repeat(40); +const revision = "a".repeat(40); +const checksum = "c".repeat(64); +const observedRuntime = { revision, version: "1.4.0" } as const; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map(async (directory) => { + const releases = path.join(directory, "releases"); + await chmod(releases, 0o700).catch(() => {}); + const entries = await readdir(releases, { withFileTypes: true }).catch( + () => [] + ); + await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => chmod(path.join(releases, entry.name), 0o700)) + ); + await rm(directory, { force: true, recursive: true }); + }) + ); +}); + +function manifest() { + return { + artifacts: [{ bytes: 3, path: "server/web.js", sha256: checksum }], + buildCommands: [...releaseBuildCommands], + documentationSha256: checksum, + formatVersion: 1, + lockfileSha256: checksum, + migrations: [ + { + id: "20260804022252_dashboard-foundation", + migrationSha256: checksum, + snapshotSha256: checksum, + }, + ], + packages: [{ name: "react", scope: "dependency" as const, version: "19.2.8" }], + processRoles: [...releaseProcessRoles], + runtime: observedRuntime, + source: { commitSha, treeState: "clean" as const }, + }; +} + +async function releaseFixture(): Promise<{ + releaseRoot: string; + releasesDirectory: string; +}> { + const root = await mkdtemp(path.join(tmpdir(), "mira-runtime-release-")); + temporaryDirectories.push(root); + const releasesDirectory = path.join(root, "releases"); + const releaseRoot = path.join(releasesDirectory, commitSha); + await mkdir(releaseRoot, { recursive: true, mode: 0o700 }); + await writeFile( + path.join(releaseRoot, "release-manifest.json"), + serializeReleaseManifest(manifest()), + { mode: 0o600 } + ); + await chmod(path.join(releaseRoot, "release-manifest.json"), 0o400); + await chmod(releaseRoot, 0o500); + await chmod(releasesDirectory, 0o700); + return { releaseRoot, releasesDirectory }; +} + +describe("runtime release", () => { + test("loads an exact immutable release and runtime identity", async () => { + const fixture = await releaseFixture(); + + const release = await loadRuntimeRelease( + fixture.releasesDirectory, + fixture.releaseRoot, + "web", + observedRuntime + ); + + expect(release.releaseRoot).toBe(fixture.releaseRoot); + expect(release.manifest).toEqual(manifest()); + expect(Object.isFrozen(release)).toBe(true); + }); + + test("rejects writable manifests, symlink roots and runtime mismatch", async () => { + const writable = await releaseFixture(); + await chmod(path.join(writable.releaseRoot, "release-manifest.json"), 0o600); + expect( + loadRuntimeRelease( + writable.releasesDirectory, + writable.releaseRoot, + "worker", + observedRuntime + ) + ).rejects.toThrow("Runtime release is invalid"); + + const linked = await releaseFixture(); + const linkPath = path.join(linked.releasesDirectory, "linked-release"); + await symlink(linked.releaseRoot, linkPath, "dir"); + expect( + loadRuntimeRelease(linked.releasesDirectory, linkPath, "web", observedRuntime) + ).rejects.toThrow("Runtime release is invalid"); + + const mismatch = await releaseFixture(); + expect( + loadRuntimeRelease(mismatch.releasesDirectory, mismatch.releaseRoot, "web", { + revision: "d".repeat(40), + version: "1.4.0", + }) + ).rejects.toThrow("Runtime release is invalid"); + }); + + test("rejects release replacement after reading the held manifest", async () => { + const fixture = await releaseFixture(); + const displaced = `${fixture.releaseRoot}-displaced`; + let replaced = false; + + expect( + loadRuntimeRelease( + fixture.releasesDirectory, + fixture.releaseRoot, + "web", + observedRuntime, + { + afterManifestRead: async () => { + replaced = true; + await rename(fixture.releaseRoot, displaced); + await mkdir(fixture.releaseRoot, { mode: 0o500 }); + }, + } + ) + ).rejects.toThrow("Runtime release is invalid"); + expect(replaced).toBe(true); + }); +}); diff --git a/greenfield/src/server/platform/release/runtimeRelease.ts b/greenfield/src/server/platform/release/runtimeRelease.ts new file mode 100644 index 000000000..5ea48b869 --- /dev/null +++ b/greenfield/src/server/platform/release/runtimeRelease.ts @@ -0,0 +1,218 @@ +import { constants, type BigIntStats } from "node:fs"; +import { type FileHandle, lstat, open, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { + parseReleaseManifest, + type ReleaseManifest, +} from "../../../shared/releaseManifest.ts"; +import { + readRuntimeIdentity, + type ObservedRuntimeIdentity, +} from "../runtime/readRuntimeIdentity.ts"; + +const manifestFileName = "release-manifest.json"; +const maximumManifestBytes = 4 * 1024 * 1024; +const manifestOpenFlags = + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +const writePermissionBits = 0o222n; + +/** Verified immutable release identity consumed by one process composition root. */ +export interface RuntimeRelease { + readonly manifest: ReleaseManifest; + readonly releaseRoot: string; +} + +/** Deterministic read boundary exposed only to adversarial tests. */ +export interface RuntimeReleaseTestHooks { + readonly afterManifestRead?: () => Promise | void; +} + +function invalidRuntimeRelease(): Error { + return new Error("Runtime release is invalid"); +} + +function currentUserId(): number { + if (process.platform !== "linux" || typeof process.getuid !== "function") { + throw invalidRuntimeRelease(); + } + return process.getuid(); +} + +function sameSnapshot(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.ctimeNs === right.ctimeNs && + left.mtimeNs === right.mtimeNs + ); +} + +function validReleaseDirectory(status: BigIntStats, userId: number): boolean { + return ( + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(userId) && + (status.mode & writePermissionBits) === 0n + ); +} + +function validReleasesDirectory(status: BigIntStats, userId: number): boolean { + return ( + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(userId) && + (status.mode & 0o022n) === 0n + ); +} + +async function closeFile(file: FileHandle | undefined): Promise { + if (!file) return true; + try { + await file.close(); + return true; + } catch { + return false; + } +} + +async function readStableManifest( + releaseRoot: string, + releaseStatus: BigIntStats, + userId: number, + testHooks: RuntimeReleaseTestHooks +): Promise { + const manifestPath = path.join(releaseRoot, manifestFileName); + let file: FileHandle | undefined; + let text: string | undefined; + let failed = false; + try { + file = await open(manifestPath, manifestOpenFlags); + const held = await file.stat({ bigint: true }); + const descriptorPath = await realpath(`/proc/self/fd/${file.fd}`); + if ( + !held.isFile() || + held.isSymbolicLink() || + held.nlink !== 1n || + held.uid !== BigInt(userId) || + held.dev !== releaseStatus.dev || + held.size <= 0n || + held.size > BigInt(maximumManifestBytes) || + (held.mode & writePermissionBits) !== 0n || + path.dirname(descriptorPath) !== releaseRoot || + path.basename(descriptorPath) !== manifestFileName + ) { + throw invalidRuntimeRelease(); + } + + const expectedBytes = Number(held.size); + const contents = Buffer.alloc(expectedBytes + 1); + let bytesRead = 0; + while (bytesRead < contents.byteLength) { + const read = await file.read( + contents, + bytesRead, + contents.byteLength - bytesRead, + bytesRead + ); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + await testHooks.afterManifestRead?.(); + const [after, pathAfter, releaseAfter] = await Promise.all([ + file.stat({ bigint: true }), + lstat(manifestPath, { bigint: true }), + lstat(releaseRoot, { bigint: true }), + ]); + if ( + bytesRead !== expectedBytes || + !sameSnapshot(held, after) || + !sameSnapshot(held, pathAfter) || + !sameSnapshot(releaseStatus, releaseAfter) + ) { + throw invalidRuntimeRelease(); + } + text = new TextDecoder("utf-8", { fatal: true }).decode( + contents.subarray(0, bytesRead) + ); + } catch { + failed = true; + } + if (!(await closeFile(file))) failed = true; + if (failed || text === undefined) throw invalidRuntimeRelease(); + return text; +} + +/** + * Reads one immutable release manifest through a held no-follow descriptor. + * @param releasesDirectory Canonical project-local production releases directory. + * @param releaseRoot Canonical exact release directory, never the `current` symlink. + * @param processRole Process role that must be represented by the manifest. + * @param observedRuntime Optional deterministic runtime identity for tests. + * @param testHooks Deterministic adversarial hooks used only by tests. + * @returns Frozen verified runtime release. + */ +export async function loadRuntimeRelease( + releasesDirectory: string, + releaseRoot: string, + processRole: "web" | "worker", + observedRuntime?: ObservedRuntimeIdentity, + testHooks: RuntimeReleaseTestHooks = {} +): Promise { + if ( + !path.isAbsolute(releasesDirectory) || + !path.isAbsolute(releaseRoot) || + releasesDirectory.includes("\0") || + releaseRoot.includes("\0") || + path.resolve(releasesDirectory) !== releasesDirectory || + path.resolve(releaseRoot) !== releaseRoot || + path.dirname(releaseRoot) !== releasesDirectory + ) { + throw invalidRuntimeRelease(); + } + try { + const userId = currentUserId(); + const [canonicalReleases, canonicalRelease, releasesStatus, releaseStatus] = + await Promise.all([ + realpath(releasesDirectory), + realpath(releaseRoot), + lstat(releasesDirectory, { bigint: true }), + lstat(releaseRoot, { bigint: true }), + ]); + if ( + canonicalReleases !== releasesDirectory || + canonicalRelease !== releaseRoot || + !validReleasesDirectory(releasesStatus, userId) || + !validReleaseDirectory(releaseStatus, userId) || + releaseStatus.dev !== releasesStatus.dev + ) { + throw invalidRuntimeRelease(); + } + const manifestText = await readStableManifest( + releaseRoot, + releaseStatus, + userId, + testHooks + ); + let manifestValue: unknown; + try { + manifestValue = JSON.parse(manifestText) as unknown; + } catch { + throw invalidRuntimeRelease(); + } + const manifest = parseReleaseManifest(manifestValue); + const runtime = readRuntimeIdentity(observedRuntime); + if ( + path.basename(releaseRoot) !== manifest.source.commitSha || + manifest.runtime.version !== runtime.version || + manifest.runtime.revision !== runtime.revision || + !manifest.processRoles.includes(processRole) + ) { + throw invalidRuntimeRelease(); + } + return Object.freeze({ manifest, releaseRoot }); + } catch { + throw invalidRuntimeRelease(); + } +} diff --git a/greenfield/src/server/platform/runtime/processSignals.test.ts b/greenfield/src/server/platform/runtime/processSignals.test.ts new file mode 100644 index 000000000..3e72d527f --- /dev/null +++ b/greenfield/src/server/platform/runtime/processSignals.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { + createProcessTerminationController, + type DashboardTerminationSignal, + type ListenForTerminationSignal, +} from "./processSignals.ts"; + +function signalFixture() { + const listeners = new Map void>(); + const removed: DashboardTerminationSignal[] = []; + const listen: ListenForTerminationSignal = (signal, listener) => { + listeners.set(signal, listener); + return () => { + listeners.delete(signal); + removed.push(signal); + }; + }; + return { listen, listeners, removed }; +} + +describe("process termination controller", () => { + test("starts gracefully on the first signal and escalates on the second", async () => { + const fixture = signalFixture(); + const controller = createProcessTerminationController(fixture.listen); + + fixture.listeners.get("SIGTERM")?.(); + expect(await controller.termination).toBe("SIGTERM"); + expect(controller.forceSignal.aborted).toBe(false); + + fixture.listeners.get("SIGINT")?.(); + expect(controller.forceSignal.aborted).toBe(true); + expect(controller.forceSignal.reason).toBeInstanceOf(DOMException); + }); + + test("removes listeners exactly once and ignores later callbacks", () => { + const fixture = signalFixture(); + const controller = createProcessTerminationController(fixture.listen); + const sigterm = fixture.listeners.get("SIGTERM"); + + controller.dispose(); + controller.dispose(); + sigterm?.(); + + expect(fixture.removed.toSorted()).toEqual(["SIGINT", "SIGTERM"]); + expect(controller.forceSignal.aborted).toBe(false); + }); +}); diff --git a/greenfield/src/server/platform/runtime/processSignals.ts b/greenfield/src/server/platform/runtime/processSignals.ts new file mode 100644 index 000000000..27ce12555 --- /dev/null +++ b/greenfield/src/server/platform/runtime/processSignals.ts @@ -0,0 +1,66 @@ +export type DashboardTerminationSignal = "SIGINT" | "SIGTERM"; + +/** Signal listener registration boundary injected by deterministic tests. */ +export type ListenForTerminationSignal = ( + signal: DashboardTerminationSignal, + listener: () => void +) => () => void; + +/** One process-owned termination milestone plus repeated-signal escalation. */ +export interface ProcessTerminationController { + readonly forceSignal: AbortSignal; + readonly termination: Promise; + dispose(): void; +} + +function listenForProcessSignal( + signal: DashboardTerminationSignal, + listener: () => void +): () => void { + process.on(signal, listener); + return () => process.off(signal, listener); +} + +/** + * Installs bounded SIGINT/SIGTERM handlers without terminating the process directly. + * The first signal starts graceful shutdown; a later signal requests force escalation. + * @param listen Signal registration boundary. + * @returns Termination milestone, force signal, and idempotent cleanup. + */ +export function createProcessTerminationController( + listen: ListenForTerminationSignal = listenForProcessSignal +): ProcessTerminationController { + const forceController = new AbortController(); + let firstSignal: DashboardTerminationSignal | undefined; + let resolveTermination: ((signal: DashboardTerminationSignal) => void) | undefined; + const termination = new Promise((resolve) => { + resolveTermination = resolve; + }); + let disposed = false; + const receive = (signal: DashboardTerminationSignal): void => { + if (disposed) return; + if (firstSignal === undefined) { + firstSignal = signal; + resolveTermination?.(signal); + resolveTermination = undefined; + return; + } + forceController.abort( + new DOMException("Forced process shutdown requested", "AbortError") + ); + }; + const removeListeners = [ + listen("SIGINT", () => receive("SIGINT")), + listen("SIGTERM", () => receive("SIGTERM")), + ]; + + return Object.freeze({ + dispose() { + if (disposed) return; + disposed = true; + for (const remove of removeListeners) remove(); + }, + forceSignal: forceController.signal, + termination, + }); +} diff --git a/greenfield/src/server/rawHttp/frontendAssets.test.ts b/greenfield/src/server/rawHttp/frontendAssets.test.ts new file mode 100644 index 000000000..301966ed7 --- /dev/null +++ b/greenfield/src/server/rawHttp/frontendAssets.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { rejectionError } from "../../../scripts/testSupport/rejection.ts"; +import { + parseReleaseManifest, + releaseBuildCommands, + releaseProcessRoles, +} from "../../shared/releaseManifest.ts"; +import type { RuntimeRelease } from "../platform/release/runtimeRelease.ts"; +import { createFrontendAssetHandler } from "./frontendAssets.ts"; + +const temporaryDirectories: string[] = []; +const checksum = "c".repeat(64); +const indexContents = "Mira Dashboard"; +const indexBrotliContents = "compressed-index"; +const appContents = "globalThis.dashboard=true;"; +const brotliContents = "compressed-app"; +const appPublicPath = "/assets/app-a1b2c3d4.js"; +const appArtifactPath = `browser${appPublicPath}`; + +function sha256(value: string): string { + return new Bun.CryptoHasher("sha256").update(value).digest("hex"); +} + +async function restoreOwnerWrite(directory: string): Promise { + await chmod(directory, 0o700).catch(() => {}); + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (entry.isDirectory()) { + await restoreOwnerWrite(path.join(directory, entry.name)); + } else if (entry.isFile()) { + await chmod(path.join(directory, entry.name), 0o600); + } + } +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +function artifact(pathname: string, contents: string) { + return Object.freeze({ + bytes: Buffer.byteLength(contents), + path: pathname, + sha256: sha256(contents), + }); +} + +async function frontendReleaseFixture(): Promise { + const releaseRoot = await mkdtemp(path.join(tmpdir(), "mira-frontend-assets-")); + temporaryDirectories.push(releaseRoot); + const assetsRoot = path.join(releaseRoot, "browser/assets"); + await mkdir(assetsRoot, { recursive: true }); + const artifacts = [ + artifact(appArtifactPath, appContents), + artifact(`${appArtifactPath}.br`, brotliContents), + artifact("browser/bundle-metrics.json", "{}\n"), + artifact("browser/index.html", indexContents), + artifact("browser/index.html.br", indexBrotliContents), + ].toSorted((left, right) => left.path.localeCompare(right.path)); + await Promise.all([ + writeFile(path.join(releaseRoot, appArtifactPath), appContents), + writeFile(path.join(releaseRoot, `${appArtifactPath}.br`), brotliContents), + writeFile(path.join(releaseRoot, "browser/bundle-metrics.json"), "{}\n"), + writeFile(path.join(releaseRoot, "browser/index.html"), indexContents), + writeFile(path.join(releaseRoot, "browser/index.html.br"), indexBrotliContents), + ]); + for (const record of artifacts) { + await chmod(path.join(releaseRoot, record.path), 0o400); + } + await chmod(assetsRoot, 0o500); + await chmod(path.join(releaseRoot, "browser"), 0o500); + await chmod(releaseRoot, 0o500); + + return Object.freeze({ + manifest: parseReleaseManifest({ + artifacts, + buildCommands: [...releaseBuildCommands], + documentationSha256: checksum, + formatVersion: 1, + lockfileSha256: checksum, + migrations: [ + { + id: "20260804022252_dashboard-foundation", + migrationSha256: checksum, + snapshotSha256: checksum, + }, + ], + packages: [ + { + name: "effect", + scope: "dependency", + version: "4.0.0-beta.104", + }, + ], + processRoles: [...releaseProcessRoles], + runtime: { revision: "a".repeat(40), version: "1.4.0" }, + source: { commitSha: "b".repeat(40), treeState: "clean" }, + }), + releaseRoot, + }); +} + +async function handledResponse( + handler: Awaited>, + pathname: string, + init?: RequestInit +): Promise { + const request = new Request(`https://dashboard.example${pathname}`, init); + const response = await handler(request, new URL(request.url)); + if (!response) throw new Error("Expected the frontend handler to own the path"); + return response; +} + +describe("frontend release assets", () => { + test("serves immutable negotiated assets, index navigation, HEAD and validators", async () => { + const release = await frontendReleaseFixture(); + const handler = await createFrontendAssetHandler(release); + + const index = await handledResponse(handler, "/"); + const head = await handledResponse(handler, "/", { method: "HEAD" }); + const compressed = await handledResponse(handler, appPublicPath, { + headers: { "accept-encoding": "gzip;q=0.5, br;q=1" }, + }); + const cached = await handledResponse(handler, appPublicPath, { + headers: { + "accept-encoding": "br", + "if-none-match": compressed.headers.get("etag") ?? "missing", + }, + }); + const route = await handledResponse(handler, "/tasks/active", { + headers: { accept: "text/html" }, + }); + + expect(index.status).toBe(200); + expect(await index.text()).toBe(indexContents); + expect(index.headers.get("cache-control")).toContain("no-cache"); + expect(index.headers.get("vary")).toBe("Accept-Encoding"); + expect(index.headers.get("content-security-policy")).toContain( + "frame-ancestors 'none'" + ); + expect(index.headers.get("x-content-type-options")).toBe("nosniff"); + expect(head.status).toBe(200); + expect(await head.text()).toBe(""); + expect(head.headers.get("content-length")).toBe( + String(Buffer.byteLength(indexContents)) + ); + expect(compressed.status).toBe(200); + expect(compressed.headers.get("content-encoding")).toBe("br"); + expect(compressed.headers.get("vary")).toBe("Accept-Encoding"); + expect(compressed.headers.get("cache-control")).toContain("immutable"); + expect(Buffer.from(await compressed.arrayBuffer()).toString()).toBe( + brotliContents + ); + expect(cached.status).toBe(304); + expect(await cached.text()).toBe(""); + expect(route.status).toBe(200); + expect(await route.text()).toBe(indexContents); + expect(route.headers.get("vary")).toBe("Accept, Accept-Encoding"); + }); + + test("keeps protocol paths separate and bounds missing, method and encoding cases", async () => { + const release = await frontendReleaseFixture(); + const handler = await createFrontendAssetHandler(release); + const apiRequest = new Request("https://dashboard.example/api/unknown"); + + expect(await handler(apiRequest, new URL(apiRequest.url))).toBeUndefined(); + expect( + await handler( + new Request("https://dashboard.example/favicon.ico"), + new URL("https://dashboard.example/favicon.ico") + ) + ).toBeUndefined(); + + const missing = await handledResponse(handler, "/assets/missing.js"); + const method = await handledResponse(handler, "/", { + body: "ignored", + method: "POST", + }); + const unacceptable = await handledResponse(handler, appPublicPath, { + headers: { "accept-encoding": "br;q=0, identity;q=0" }, + }); + const encodedTraversal = await handledResponse( + handler, + "/assets/%2e%2e%2findex.html" + ); + const extensionlessAsset = await handledResponse(handler, "/assets/missing"); + const rejectedNavigation = new Request("https://dashboard.example/tasks/active", { + headers: { accept: "text/html;q=0, application/json" }, + }); + + expect(missing.status).toBe(404); + expect(method.status).toBe(405); + expect(method.headers.get("allow")).toBe("GET, HEAD"); + expect(unacceptable.status).toBe(406); + expect(encodedTraversal.status).toBe(404); + expect(extensionlessAsset.status).toBe(404); + expect( + await handler(rejectedNavigation, new URL(rejectedNavigation.url)) + ).toBeUndefined(); + }); + + test("fails closed for writable directories and a path replacement after read", async () => { + const writableRelease = await frontendReleaseFixture(); + await chmod(writableRelease.releaseRoot, 0o700); + const writableFailure = await rejectionError( + createFrontendAssetHandler(writableRelease) + ); + expect(writableFailure.message).toBe("Immutable release file is invalid"); + + const writableAssetsRelease = await frontendReleaseFixture(); + await chmod( + path.join(writableAssetsRelease.releaseRoot, "browser/assets"), + 0o700 + ); + const writableAssetsFailure = await rejectionError( + createFrontendAssetHandler(writableAssetsRelease) + ); + expect(writableAssetsFailure.message).toBe("Immutable release file is invalid"); + + const swappedRelease = await frontendReleaseFixture(); + let swapped = false; + const failure = await rejectionError( + createFrontendAssetHandler(swappedRelease, { + file: { + async afterRead(artifactPath) { + if (swapped || artifactPath !== appArtifactPath) return; + swapped = true; + const assetsRoot = path.join( + swappedRelease.releaseRoot, + "browser/assets" + ); + const target = path.join( + swappedRelease.releaseRoot, + artifactPath + ); + await chmod(assetsRoot, 0o700); + await rename(target, `${target}.displaced`); + await writeFile(target, "replacement"); + }, + }, + }) + ); + + expect(failure.message).toBe("Immutable release file is invalid"); + expect(swapped).toBeTrue(); + }); +}); diff --git a/greenfield/src/server/rawHttp/frontendAssets.ts b/greenfield/src/server/rawHttp/frontendAssets.ts new file mode 100644 index 000000000..0e31297b1 --- /dev/null +++ b/greenfield/src/server/rawHttp/frontendAssets.ts @@ -0,0 +1,426 @@ +import path from "node:path"; + +import { + createImmutableReleaseFileReader, + type ImmutableReleaseFileIdentity, + type ImmutableReleaseFileTestHooks, +} from "../platform/filesystem/immutableReleaseFile.ts"; +import type { RuntimeRelease } from "../platform/release/runtimeRelease.ts"; + +const frontendAssetFailureMessage = "Frontend release assets are invalid"; +const maximumHeaderBytes = 4096; +const maximumHeaderItems = 32; +const maximumPublicAssetCount = 1024; +const maximumPublicAssetBytes = 64 * 1024 * 1024; +const hashedAssetPattern = /-[a-z\d]{8}\.[A-Za-z0-9]+$/u; +const canonicalRequestPathPattern = /^\/(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]*$/u; +const contentTypes: Readonly> = Object.freeze({ + ".avif": "image/avif", + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +}); + +/** Raw HTTP handler that owns browser artifacts and controlled SPA navigation. */ +export type FrontendAssetHandler = ( + request: Request, + requestUrl: URL +) => Promise; + +/** Deterministic immutable-file hooks exposed only to adversarial tests. */ +export interface FrontendAssetHandlerTestHooks { + readonly file?: ImmutableReleaseFileTestHooks; +} + +interface FrontendAssetRepresentations { + readonly brotli?: ImmutableReleaseFileIdentity; + readonly gzip?: ImmutableReleaseFileIdentity; + readonly identity: ImmutableReleaseFileIdentity; + readonly publicPath: string; +} + +interface SelectedRepresentation { + readonly contentEncoding?: "br" | "gzip"; + readonly identity: ImmutableReleaseFileIdentity; +} + +function frontendAssetFailure(): Error { + return new Error(frontendAssetFailureMessage); +} + +function fixedSecurityHeaders(): Headers { + return new Headers({ + "content-security-policy": [ + "default-src 'none'", + "base-uri 'none'", + "connect-src 'self'", + "font-src 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "manifest-src 'self'", + "object-src 'none'", + "script-src 'self'", + "style-src 'self'", + ].join("; "), + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "permissions-policy": "camera=(), geolocation=(), microphone=(self)", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + }); +} + +function noStoreResponse(body: string | null, status: number): Response { + const headers = fixedSecurityHeaders(); + headers.set("cache-control", "no-store"); + return new Response(body, { headers, status }); +} + +function baseArtifactPath(artifactPath: string): string { + if (artifactPath.endsWith(".br")) return artifactPath.slice(0, -3); + if (artifactPath.endsWith(".gz")) return artifactPath.slice(0, -3); + return artifactPath; +} + +function publicPathForArtifact(artifactPath: string): string | undefined { + if (artifactPath === "browser/index.html") return "/"; + if (artifactPath.startsWith("browser/assets/")) { + return artifactPath.slice("browser".length); + } + return undefined; +} + +function buildFrontendAssetIndex( + release: RuntimeRelease +): ReadonlyMap { + const artifacts = new Map( + release.manifest.artifacts.map((artifact) => [artifact.path, artifact]) + ); + const publicAssets = new Map(); + for (const artifact of release.manifest.artifacts) { + if (!artifact.path.startsWith("browser/")) continue; + if (artifact.path === "browser/bundle-metrics.json") continue; + if (artifact.path.endsWith(".br") || artifact.path.endsWith(".gz")) { + if (!artifacts.has(baseArtifactPath(artifact.path))) { + throw frontendAssetFailure(); + } + continue; + } + const publicPath = publicPathForArtifact(artifact.path); + if ( + publicPath === undefined || + contentTypes[path.extname(artifact.path)] === undefined + ) { + throw frontendAssetFailure(); + } + const brotli = artifacts.get(`${artifact.path}.br`); + const gzip = artifacts.get(`${artifact.path}.gz`); + const entry = Object.freeze({ + ...(brotli === undefined ? {} : { brotli }), + ...(gzip === undefined ? {} : { gzip }), + identity: artifact, + publicPath, + }); + if (publicAssets.has(publicPath)) throw frontendAssetFailure(); + publicAssets.set(publicPath, entry); + if (publicPath === "/") publicAssets.set("/index.html", entry); + } + if (!publicAssets.has("/")) throw frontendAssetFailure(); + return publicAssets; +} + +function qualityValue(value: string): number | undefined { + if (!/^(?:0(?:\.\d{1,3})?|1(?:\.0{1,3})?)$/u.test(value)) return undefined; + const quality = Number(value); + return Number.isFinite(quality) ? quality : undefined; +} + +function acceptedEncodingQualities(header: string | null): ReadonlyMap { + const qualities = new Map(); + if (header === null || Buffer.byteLength(header) > maximumHeaderBytes) { + return qualities; + } + const items = header.split(","); + if (items.length > maximumHeaderItems) return qualities; + for (const item of items) { + const [rawName, ...parameters] = item.trim().split(";"); + const name = rawName?.trim().toLowerCase(); + if (!name || !/^(?:br|gzip|identity|\*)$/u.test(name)) continue; + let quality = 1; + let valid = true; + for (const parameter of parameters) { + const match = /^q\s*=\s*(.+)$/iu.exec(parameter.trim()); + if (!match) { + valid = false; + break; + } + const parsed = qualityValue(match[1] ?? ""); + if (parsed === undefined) { + valid = false; + break; + } + quality = parsed; + } + if (valid) qualities.set(name, quality); + } + return qualities; +} + +function effectiveEncodingQuality( + qualities: ReadonlyMap, + encoding: "br" | "gzip" | "identity" +): number { + const exact = qualities.get(encoding); + if (exact !== undefined) return exact; + if (encoding === "identity") return qualities.get("*") === 0 ? 0 : 1; + return qualities.get("*") ?? 0; +} + +function selectRepresentation( + asset: FrontendAssetRepresentations, + acceptEncoding: string | null +): SelectedRepresentation | undefined { + const qualities = acceptedEncodingQualities(acceptEncoding); + const candidates: Array< + Readonly<{ + contentEncoding?: "br" | "gzip"; + identity: ImmutableReleaseFileIdentity; + quality: number; + rank: number; + }> + > = [ + { + identity: asset.identity, + quality: effectiveEncodingQuality(qualities, "identity"), + rank: 0, + }, + ]; + if (asset.gzip) { + candidates.push({ + contentEncoding: "gzip", + identity: asset.gzip, + quality: effectiveEncodingQuality(qualities, "gzip"), + rank: 1, + }); + } + if (asset.brotli) { + candidates.push({ + contentEncoding: "br", + identity: asset.brotli, + quality: effectiveEncodingQuality(qualities, "br"), + rank: 2, + }); + } + const selected = candidates + .filter(({ quality }) => quality > 0) + .toSorted( + (left, right) => right.quality - left.quality || right.rank - left.rank + )[0]; + if (!selected) return undefined; + return Object.freeze({ + ...(selected.contentEncoding === undefined + ? {} + : { contentEncoding: selected.contentEncoding }), + identity: selected.identity, + }); +} + +function headerAcceptsHtml(header: string | null): boolean { + if (header === null) return true; + if (Buffer.byteLength(header) > maximumHeaderBytes) return false; + const items = header.split(","); + if (items.length > maximumHeaderItems) return false; + return items.some((item) => { + const [rawMediaType, ...parameters] = item.trim().split(";"); + const mediaType = rawMediaType?.trim().toLowerCase(); + if ( + mediaType !== "text/html" && + mediaType !== "application/xhtml+xml" && + mediaType !== "*/*" + ) { + return false; + } + let quality = 1; + let qualitySeen = false; + for (const parameter of parameters) { + const match = /^q\s*=\s*(.+)$/iu.exec(parameter.trim()); + if (!match) continue; + if (qualitySeen) return false; + qualitySeen = true; + const parsed = qualityValue(match[1] ?? ""); + if (parsed === undefined) return false; + quality = parsed; + } + return quality > 0; + }); +} + +function isReservedApplicationPath(pathname: string): boolean { + return ( + pathname === "/api" || + pathname.startsWith("/api/") || + pathname === "/assets" || + pathname.startsWith("/assets/") || + pathname === "/trpc" || + pathname.startsWith("/trpc") + ); +} + +function controlledSpaFallback(request: Request, pathname: string): boolean { + return ( + !isReservedApplicationPath(pathname) && + canonicalRequestPathPattern.test(pathname) && + !path.posix.basename(pathname).includes(".") && + headerAcceptsHtml(request.headers.get("accept")) + ); +} + +function requestMatchesEtag(request: Request, etag: string): boolean { + const header = request.headers.get("if-none-match"); + if (header === null || Buffer.byteLength(header) > maximumHeaderBytes) { + return false; + } + const items = header.split(","); + if (items.length > maximumHeaderItems) return false; + return items.some((item) => { + const candidate = item.trim().replace(/^W\//u, ""); + return candidate === "*" || candidate === etag; + }); +} + +async function cancelUnexpectedBody(request: Request): Promise { + if (request.body === null) return; + await request.body + .cancel("Static asset request method is not allowed") + .catch(() => {}); +} + +function assetHeaders( + asset: FrontendAssetRepresentations, + selected: SelectedRepresentation, + spaFallback: boolean +): Headers { + const headers = fixedSecurityHeaders(); + const contentType = contentTypes[path.extname(asset.identity.path)]; + if (contentType === undefined) throw frontendAssetFailure(); + headers.set( + "cache-control", + asset.publicPath.startsWith("/assets/") && + hashedAssetPattern.test(path.posix.basename(asset.publicPath)) + ? "public, max-age=31536000, immutable" + : "no-cache, max-age=0, must-revalidate" + ); + headers.set("content-length", String(selected.identity.bytes)); + headers.set("content-type", contentType); + headers.set("etag", `"${selected.identity.sha256}"`); + const vary = [ + ...(spaFallback ? ["Accept"] : []), + ...(asset.brotli || asset.gzip ? ["Accept-Encoding"] : []), + ]; + if (vary.length > 0) headers.set("vary", vary.join(", ")); + if (selected.contentEncoding) { + headers.set("content-encoding", selected.contentEncoding); + } + return headers; +} + +async function preloadFrontendAssetBodies( + assets: ReadonlyMap, + reader: Awaited> +): Promise> { + const identities = new Map(); + for (const asset of assets.values()) { + for (const identity of [asset.identity, asset.brotli, asset.gzip]) { + if (identity) identities.set(identity.path, identity); + } + } + const totalBytes = [...identities.values()].reduce( + (sum, identity) => sum + identity.bytes, + 0 + ); + if ( + identities.size === 0 || + identities.size > maximumPublicAssetCount || + !Number.isSafeInteger(totalBytes) || + totalBytes > maximumPublicAssetBytes + ) { + throw frontendAssetFailure(); + } + + const bodies = new Map(); + for (const identity of identities.values()) { + const bytes = await reader.read(identity); + bodies.set(identity.path, new Blob([bytes])); + } + return bodies; +} + +/** + * Creates a manifest-indexed, no-follow static asset and controlled SPA handler. + * @param release Verified immutable runtime release selected by the web composition root. + * @param testHooks Deterministic immutable-file hooks used only by tests. + * @returns Raw handler invoked after tRPC and health protocol ownership checks. + */ +export async function createFrontendAssetHandler( + release: RuntimeRelease, + testHooks: FrontendAssetHandlerTestHooks = {} +): Promise { + const assets = buildFrontendAssetIndex(release); + const reader = await createImmutableReleaseFileReader( + release.releaseRoot, + testHooks.file + ); + const bodies = await preloadFrontendAssetBodies(assets, reader); + const index = assets.get("/"); + if (!index) throw frontendAssetFailure(); + + return async (request, requestUrl) => { + const pathname = requestUrl.pathname; + const exact = assets.get(pathname); + const spaFallback = + exact === undefined && controlledSpaFallback(request, pathname); + const asset = exact ?? (spaFallback ? index : undefined); + const ownsAssetPath = pathname.startsWith("/assets/"); + if (asset === undefined) { + return ownsAssetPath ? noStoreResponse("Not found", 404) : undefined; + } + if (request.method !== "GET" && request.method !== "HEAD") { + await cancelUnexpectedBody(request); + const response = noStoreResponse(null, 405); + response.headers.set("allow", "GET, HEAD"); + return response; + } + const selected = selectRepresentation( + asset, + request.headers.get("accept-encoding") + ); + if (selected === undefined) return noStoreResponse(null, 406); + const headers = assetHeaders(asset, selected, spaFallback); + const etag = headers.get("etag"); + if (etag !== null && requestMatchesEtag(request, etag)) { + headers.delete("content-length"); + return new Response(null, { headers, status: 304 }); + } + const body = bodies.get(selected.identity.path); + if (!body) throw frontendAssetFailure(); + return new Response(request.method === "HEAD" ? null : body, { + headers, + status: 200, + }); + }; +} diff --git a/greenfield/src/server/test/system/serverFoundation.test.ts b/greenfield/src/server/test/system/serverFoundation.test.ts index 089235d4f..96fd9116c 100644 --- a/greenfield/src/server/test/system/serverFoundation.test.ts +++ b/greenfield/src/server/test/system/serverFoundation.test.ts @@ -127,6 +127,49 @@ describe("system foundation", () => { expect(await unavailable.json()).toEqual({ status: "not-ready" }); }); + test("dispatches frontend paths after tRPC and health ownership", async () => { + const frontendPaths: string[] = []; + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime: createTestApplicationRuntime(), + frontendAssets(_request, requestUrl) { + frontendPaths.push(requestUrl.pathname); + let response: Response | undefined; + if (requestUrl.pathname === "/") { + response = new Response("dashboard-browser"); + } else if (requestUrl.pathname === "/immutable-a1b2c3d4.js") { + response = new Response("immutable-browser-asset", { + headers: { + "cache-control": "public, max-age=31536000, immutable", + }, + }); + } + return Promise.resolve(response); + }, + hostname: "127.0.0.1", + port: 0, + readiness: createReadinessController(), + }); + servers.push(server); + + const browser = await fetch(server.url); + const immutable = await fetch(new URL("/immutable-a1b2c3d4.js", server.url)); + const health = await fetch(new URL("/api/health/live", server.url)); + const trpc = await fetch(new URL("/trpc/system.runtimeIdentity", server.url)); + const missing = await fetch(new URL("/unowned", server.url)); + + expect(browser.status).toBe(200); + expect(browser.headers.get("x-request-id")).toMatch(requestIdPattern); + expect(await browser.text()).toBe("dashboard-browser"); + expect(immutable.status).toBe(200); + expect(immutable.headers.get("x-request-id")).toBeNull(); + expect(await immutable.text()).toBe("immutable-browser-asset"); + expect(health.status).toBe(200); + expect(trpc.status).toBe(200); + expect(missing.status).toBe(404); + expect(frontendPaths).toEqual(["/", "/immutable-a1b2c3d4.js", "/unowned"]); + }); + test("correlates request bodies rejected by the application transport budget", async () => { const { server } = await startServer(); const response = await fetch(new URL("/trpc/auth.status", server.url), { diff --git a/greenfield/src/server/database/migrations/manifest.ts b/greenfield/src/shared/databaseMigrationManifest.ts similarity index 77% rename from greenfield/src/server/database/migrations/manifest.ts rename to greenfield/src/shared/databaseMigrationManifest.ts index 4b3291926..d156496d6 100644 --- a/greenfield/src/server/database/migrations/manifest.ts +++ b/greenfield/src/shared/databaseMigrationManifest.ts @@ -1,12 +1,12 @@ /** Reviewed file identity for one ordered Drizzle migration node. */ export interface MigrationManifestEntry { - id: string; - migrationSha256: string; - snapshotSha256: string; + readonly id: string; + readonly migrationSha256: string; + readonly snapshotSha256: string; } /** - * Reviewed migration files accepted by the application runtime. + * Reviewed migration files accepted by runtime and release tooling. * The unpublished rewrite keeps one evolving fresh-database baseline until cutover. */ export const migrationManifest = Object.freeze([ diff --git a/greenfield/src/shared/databaseSnapshotManifest.ts b/greenfield/src/shared/databaseSnapshotManifest.ts new file mode 100644 index 000000000..29d861c68 --- /dev/null +++ b/greenfield/src/shared/databaseSnapshotManifest.ts @@ -0,0 +1,80 @@ +import * as v from "valibot"; + +import { migrationManifest } from "./databaseMigrationManifest.ts"; +import { + fullCommitShaSchema, + lowercaseSha256Schema, + lowercaseUuidV7Schema, + positiveSafeIntegerSchema, +} from "./validation.ts"; + +const invalidSnapshotManifest = "Database snapshot manifest is invalid"; +const maximumSnapshotMigrations = 64; +const migrationIdentitySchema = v.strictObject({ + checksum: lowercaseSha256Schema(invalidSnapshotManifest), + id: v.pipe( + v.string(invalidSnapshotManifest), + v.maxLength(128, invalidSnapshotManifest) + ), +}); + +/** Strict durable identity stored beside one verified production database snapshot. */ +export const databaseSnapshotManifestSchema = v.strictObject({ + formatVersion: v.literal(1, invalidSnapshotManifest), + transitionId: lowercaseUuidV7Schema(invalidSnapshotManifest), + releaseId: fullCommitShaSchema(invalidSnapshotManifest), + database: v.strictObject({ + bytes: positiveSafeIntegerSchema(invalidSnapshotManifest), + sha256: lowercaseSha256Schema(invalidSnapshotManifest), + }), + migrations: v.pipe( + v.array(migrationIdentitySchema), + v.minLength(1, invalidSnapshotManifest), + v.maxLength(maximumSnapshotMigrations, invalidSnapshotManifest), + v.readonly() + ), +}); + +export type DatabaseSnapshotManifest = v.InferOutput< + typeof databaseSnapshotManifestSchema +>; + +/** + * Returns the canonical migration identity embedded in snapshots from this release. + * @returns Frozen ordered migration identities. + */ +export function currentDatabaseSnapshotMigrations() { + return Object.freeze( + migrationManifest.map((migration) => + Object.freeze({ + checksum: migration.migrationSha256, + id: migration.id, + }) + ) + ); +} + +/** + * Parses and freezes one untrusted snapshot manifest. + * @param input Unknown manifest boundary value. + * @returns Validated immutable snapshot identity. + */ +export function parseDatabaseSnapshotManifest(input: unknown): DatabaseSnapshotManifest { + const parsed = v.safeParse(databaseSnapshotManifestSchema, input, { + abortEarly: true, + }); + if (!parsed.success) throw new TypeError(invalidSnapshotManifest); + Object.freeze(parsed.output.database); + for (const migration of parsed.output.migrations) Object.freeze(migration); + Object.freeze(parsed.output.migrations); + return Object.freeze(parsed.output); +} + +/** + * Serializes one validated snapshot manifest canonically with one final newline. + * @param input Parsed or untrusted manifest boundary value. + * @returns Canonical JSON snapshot manifest. + */ +export function serializeDatabaseSnapshotManifest(input: unknown): string { + return `${JSON.stringify(parseDatabaseSnapshotManifest(input), null, 2)}\n`; +} diff --git a/greenfield/src/shared/productionActivationRecord.ts b/greenfield/src/shared/productionActivationRecord.ts new file mode 100644 index 000000000..e9bc1efc7 --- /dev/null +++ b/greenfield/src/shared/productionActivationRecord.ts @@ -0,0 +1,53 @@ +import * as v from "valibot"; + +import { fullCommitShaSchema, lowercaseUuidV7Schema } from "./validation.ts"; + +const invalidActivationRecord = "Production activation record is invalid"; +const releaseRuntimeSchema = v.strictObject({ + releaseId: fullCommitShaSchema(invalidActivationRecord), + runtimeRevision: fullCommitShaSchema(invalidActivationRecord), +}); + +/** Atomic authoritative identity for the active release/database pair. */ +export const productionActivationRecordSchema = v.strictObject({ + formatVersion: v.literal(1, invalidActivationRecord), + current: releaseRuntimeSchema, + previous: v.nullable( + v.strictObject({ + databaseSnapshotTransitionId: lowercaseUuidV7Schema(invalidActivationRecord), + releaseId: fullCommitShaSchema(invalidActivationRecord), + runtimeRevision: fullCommitShaSchema(invalidActivationRecord), + }) + ), + transitionId: lowercaseUuidV7Schema(invalidActivationRecord), +}); + +export type ProductionActivationRecord = v.InferOutput< + typeof productionActivationRecordSchema +>; + +/** + * Parses and deeply freezes one untrusted activation record. + * @param input Unknown JSON-compatible boundary value. + * @returns Immutable authoritative release/database pairing. + */ +export function parseProductionActivationRecord( + input: unknown +): ProductionActivationRecord { + const parsed = v.safeParse(productionActivationRecordSchema, input, { + abortEarly: true, + }); + if (!parsed.success) throw new TypeError(invalidActivationRecord); + Object.freeze(parsed.output.current); + if (parsed.output.previous) Object.freeze(parsed.output.previous); + return Object.freeze(parsed.output); +} + +/** + * Serializes one validated activation record canonically with one final newline. + * @param input Parsed or untrusted activation record. + * @returns Canonical JSON activation record. + */ +export function serializeProductionActivationRecord(input: unknown): string { + return `${JSON.stringify(parseProductionActivationRecord(input), null, 2)}\n`; +} diff --git a/greenfield/src/shared/productionActivationTransition.ts b/greenfield/src/shared/productionActivationTransition.ts new file mode 100644 index 000000000..56c071d42 --- /dev/null +++ b/greenfield/src/shared/productionActivationTransition.ts @@ -0,0 +1,127 @@ +import * as v from "valibot"; + +import { databaseSnapshotManifestSchema } from "./databaseSnapshotManifest.ts"; +import { productionActivationRecordSchema } from "./productionActivationRecord.ts"; +import { fullCommitShaSchema, lowercaseUuidV7Schema } from "./validation.ts"; + +const invalidActivationTransition = "Production activation transition is invalid"; +const releaseRuntimeSchema = v.strictObject({ + releaseId: fullCommitShaSchema(invalidActivationTransition), + runtimeRevision: fullCommitShaSchema(invalidActivationTransition), +}); +const sourceDatabaseIdentitySchema = v.strictObject({ + ctimeNs: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d{0,39})$/u)), + device: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d{0,39})$/u)), + inode: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d{0,39})$/u)), + mtimeNs: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d{0,39})$/u)), + size: v.pipe(v.string(), v.regex(/^[1-9]\d{0,39}$/u)), +}); +const transitionIdentitySchema = { + candidate: releaseRuntimeSchema, + formatVersion: v.literal(1, invalidActivationTransition), + previousActivation: v.nullable(productionActivationRecordSchema), + transitionId: lowercaseUuidV7Schema(invalidActivationTransition), +} as const; +const recordedPreviousDatabaseSchema = v.variant("state", [ + v.strictObject({ state: v.literal("absent") }), + v.strictObject({ + manifest: databaseSnapshotManifestSchema, + sourceDatabase: sourceDatabaseIdentitySchema, + state: v.literal("present"), + }), +]); + +/** Durable recovery journal spanning database promotion and activation-record commit. */ +export const productionActivationTransitionSchema = v.variant("phase", [ + v.strictObject({ + ...transitionIdentitySchema, + phase: v.literal("service-stop-requested"), + previousDatabase: v.strictObject({ state: v.literal("unrecorded") }), + }), + v.strictObject({ + ...transitionIdentitySchema, + phase: v.literal("prepared"), + previousDatabase: recordedPreviousDatabaseSchema, + }), + v.strictObject({ + ...transitionIdentitySchema, + phase: v.literal("database-promoted"), + previousDatabase: recordedPreviousDatabaseSchema, + }), + v.strictObject({ + ...transitionIdentitySchema, + phase: v.literal("rollback-required"), + previousDatabase: recordedPreviousDatabaseSchema, + }), +]); + +export type ProductionActivationTransition = v.InferOutput< + typeof productionActivationTransitionSchema +>; +export type ProductionActivationPreviousDatabase = Extract< + ProductionActivationTransition, + { phase: "prepared" } +>["previousDatabase"]; + +function freezeTransitionIdentity(transition: ProductionActivationTransition): void { + Object.freeze(transition.candidate); + if (transition.previousActivation) { + Object.freeze(transition.previousActivation.current); + if (transition.previousActivation.previous) { + Object.freeze(transition.previousActivation.previous); + } + Object.freeze(transition.previousActivation); + } +} + +/** + * Parses, validates semantic pairing, and freezes one recovery journal. + * @param input Unknown JSON-compatible transition value. + * @returns Immutable activation transition journal. + */ +export function parseProductionActivationTransition( + input: unknown +): ProductionActivationTransition { + const parsed = v.safeParse(productionActivationTransitionSchema, input, { + abortEarly: true, + }); + if (!parsed.success) throw new TypeError(invalidActivationTransition); + const transition = parsed.output; + freezeTransitionIdentity(transition); + if (transition.phase === "service-stop-requested") { + Object.freeze(transition.previousDatabase); + return Object.freeze(transition); + } + const validAbsentPair = + transition.previousDatabase.state === "absent" && + transition.previousActivation === null; + const validPresentPair = + transition.previousDatabase.state === "present" && + transition.previousActivation !== null && + transition.previousDatabase.manifest.transitionId === transition.transitionId && + transition.previousDatabase.manifest.releaseId === + transition.previousActivation.current.releaseId; + if (!validAbsentPair && !validPresentPair) { + throw new TypeError(invalidActivationTransition); + } + if (transition.previousDatabase.state === "present") { + Object.freeze(transition.previousDatabase.manifest.database); + for (const migration of transition.previousDatabase.manifest.migrations) { + Object.freeze(migration); + } + Object.freeze(transition.previousDatabase.manifest.migrations); + Object.freeze(transition.previousDatabase.manifest); + Object.freeze(transition.previousDatabase.sourceDatabase); + } + Object.freeze(transition.previousDatabase); + return Object.freeze(transition); +} + +/** + * Serializes one validated transition journal canonically with one final newline. + * @param input Parsed or untrusted transition journal. + * @returns Canonical JSON transition journal. + */ +export function serializeProductionActivationTransition(input: unknown): string { + return `${JSON.stringify(parseProductionActivationTransition(input), null, 2)}\n`; +} diff --git a/greenfield/src/shared/releaseManifest.test.ts b/greenfield/src/shared/releaseManifest.test.ts new file mode 100644 index 000000000..c24722097 --- /dev/null +++ b/greenfield/src/shared/releaseManifest.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; + +import { + parseReleaseManifest, + releaseBuildCommands, + releaseProcessRoles, + serializeReleaseManifest, +} from "./releaseManifest.ts"; + +const checksum = "a".repeat(64); +const commitSha = "b".repeat(40); + +function manifest() { + return { + formatVersion: 1, + source: { commitSha, treeState: "clean" }, + runtime: { revision: "c".repeat(40), version: "1.4.0" }, + lockfileSha256: checksum, + documentationSha256: "d".repeat(64), + buildCommands: [...releaseBuildCommands], + processRoles: [...releaseProcessRoles], + packages: [ + { name: "@trpc/server", scope: "dependency", version: "11.18.0" }, + { name: "typescript", scope: "devDependency", version: "7.0.2" }, + ], + migrations: [ + { + id: "20260804022252_dashboard-foundation", + migrationSha256: "e".repeat(64), + snapshotSha256: "f".repeat(64), + }, + ], + artifacts: [ + { bytes: 42, path: "browser/index.html", sha256: "1".repeat(64) }, + { bytes: 84, path: "server/web.js", sha256: "2".repeat(64) }, + ], + }; +} + +describe("release manifest", () => { + test("parses, deeply freezes and deterministically serializes the complete identity", () => { + const parsed = parseReleaseManifest(manifest()); + + expect(parsed).toEqual(manifest()); + expect(Object.isFrozen(parsed)).toBe(true); + expect(Object.isFrozen(parsed.artifacts)).toBe(true); + expect(Object.isFrozen(parsed.artifacts[0])).toBe(true); + expect(Object.isFrozen(parsed.packages[0])).toBe(true); + expect(serializeReleaseManifest(parsed)).toBe( + `${JSON.stringify(parsed, null, 2)}\n` + ); + }); + + test("rejects dirty sources, unknown fields and malformed runtime identity", () => { + expect(() => + parseReleaseManifest({ + ...manifest(), + source: { commitSha, treeState: "dirty" }, + }) + ).toThrow("Release manifest is invalid"); + expect(() => parseReleaseManifest({ ...manifest(), secret: "private" })).toThrow( + "Release manifest is invalid" + ); + expect(() => + parseReleaseManifest({ + ...manifest(), + runtime: { revision: "short", version: "1.4.0" }, + }) + ).toThrow("Release manifest is invalid"); + }); + + test("rejects unordered identities and path traversal", () => { + const candidate = manifest(); + expect(() => + parseReleaseManifest({ + ...candidate, + packages: candidate.packages.toReversed(), + }) + ).toThrow("Release manifest is invalid"); + expect(() => + parseReleaseManifest({ + ...candidate, + artifacts: [{ bytes: 1, path: "../secret", sha256: "1".repeat(64) }], + }) + ).toThrow("Release manifest is invalid"); + expect(() => + parseReleaseManifest({ + ...candidate, + artifacts: [ + { + bytes: 1, + path: "browser/control\u0001.js", + sha256: "1".repeat(64), + }, + ], + }) + ).toThrow("Release manifest is invalid"); + }); + + test("rejects a release without direct package identity", () => { + expect(() => + parseReleaseManifest({ + ...manifest(), + packages: [], + }) + ).toThrow("Release manifest is invalid"); + }); +}); diff --git a/greenfield/src/shared/releaseManifest.ts b/greenfield/src/shared/releaseManifest.ts new file mode 100644 index 000000000..8a3cd0df4 --- /dev/null +++ b/greenfield/src/shared/releaseManifest.ts @@ -0,0 +1,176 @@ +import * as v from "valibot"; + +import { bunRuntimePolicy } from "./bunRuntimePolicy.ts"; +import { + fullCommitShaSchema, + lowercaseSha256Schema, + noNulStringAction, + nonnegativeSafeIntegerSchema, +} from "./validation.ts"; + +const invalidReleaseManifest = "Release manifest is invalid"; +const maximumReleaseArtifacts = 4096; +const maximumReleasePackages = 256; +const maximumReleaseMigrations = 64; + +/** Commands whose successful output is represented by one release manifest. */ +export const releaseBuildCommands = Object.freeze([ + "bun run build:browser", + "bun run build:processes", + "bun run docs:check", + "bun run db:check", +] as const); + +/** Process roles that every production release must contain. */ +export const releaseProcessRoles = Object.freeze(["web", "worker"] as const); + +function boundedToken(maximumLength: number) { + return v.pipe( + v.string(invalidReleaseManifest), + v.minLength(1, invalidReleaseManifest), + v.maxLength(maximumLength, invalidReleaseManifest), + noNulStringAction(invalidReleaseManifest), + v.regex(/^[^\p{Cc}\p{Cf}\s]+$/u, invalidReleaseManifest) + ); +} + +function canonicalRelativePathSchema() { + return v.pipe( + boundedToken(4096), + v.check((value) => { + if (value.startsWith("/") || value.includes("\\")) return false; + const segments = value.split("/"); + return segments.every( + (segment) => + segment.length > 0 && + segment !== "." && + segment !== ".." && + /^[A-Za-z0-9._+-]+$/u.test(segment) + ); + }, invalidReleaseManifest) + ); +} + +function strictlySortedBy(values: readonly T[], key: (value: T) => string): boolean { + for (let index = 1; index < values.length; index += 1) { + if (key(values[index - 1]!) >= key(values[index]!)) return false; + } + return true; +} + +const releaseArtifactSchema = v.strictObject({ + bytes: nonnegativeSafeIntegerSchema(invalidReleaseManifest), + path: canonicalRelativePathSchema(), + sha256: lowercaseSha256Schema(invalidReleaseManifest), +}); + +const releaseMigrationSchema = v.strictObject({ + id: v.pipe( + boundedToken(128), + v.regex(/^\d{14}_[a-z0-9]+(?:-[a-z0-9]+)*$/u, invalidReleaseManifest) + ), + migrationSha256: lowercaseSha256Schema(invalidReleaseManifest), + snapshotSha256: lowercaseSha256Schema(invalidReleaseManifest), +}); + +const releasePackageSchema = v.strictObject({ + name: v.pipe( + boundedToken(214), + v.regex( + /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/u, + invalidReleaseManifest + ) + ), + scope: v.picklist(["dependency", "devDependency"], invalidReleaseManifest), + version: boundedToken(256), +}); + +const releaseBuildCommandTupleSchema = v.tuple( + releaseBuildCommands.map((command) => v.literal(command)) +); +const releaseProcessRoleTupleSchema = v.tuple( + releaseProcessRoles.map((role) => v.literal(role)) +); + +/** Strict, secret-free schema for one immutable production release identity. */ +export const releaseManifestSchema = v.strictObject({ + formatVersion: v.literal(1, invalidReleaseManifest), + source: v.strictObject({ + commitSha: fullCommitShaSchema(invalidReleaseManifest), + treeState: v.literal("clean", invalidReleaseManifest), + }), + runtime: v.strictObject({ + revision: fullCommitShaSchema(invalidReleaseManifest), + version: v.literal(bunRuntimePolicy.version, invalidReleaseManifest), + }), + lockfileSha256: lowercaseSha256Schema(invalidReleaseManifest), + documentationSha256: lowercaseSha256Schema(invalidReleaseManifest), + buildCommands: v.pipe(releaseBuildCommandTupleSchema, v.readonly()), + processRoles: v.pipe(releaseProcessRoleTupleSchema, v.readonly()), + packages: v.pipe( + v.array(releasePackageSchema), + v.minLength(1, invalidReleaseManifest), + v.maxLength(maximumReleasePackages, invalidReleaseManifest), + v.check( + (packages) => strictlySortedBy(packages, ({ name }) => name), + invalidReleaseManifest + ), + v.readonly() + ), + migrations: v.pipe( + v.array(releaseMigrationSchema), + v.minLength(1, invalidReleaseManifest), + v.maxLength(maximumReleaseMigrations, invalidReleaseManifest), + v.check( + (migrations) => strictlySortedBy(migrations, ({ id }) => id), + invalidReleaseManifest + ), + v.readonly() + ), + artifacts: v.pipe( + v.array(releaseArtifactSchema), + v.minLength(1, invalidReleaseManifest), + v.maxLength(maximumReleaseArtifacts, invalidReleaseManifest), + v.check( + (artifacts) => strictlySortedBy(artifacts, ({ path }) => path), + invalidReleaseManifest + ), + v.readonly() + ), +}); + +export type ReleaseManifest = v.InferOutput; + +function freezeManifest(manifest: ReleaseManifest): ReleaseManifest { + Object.freeze(manifest.source); + Object.freeze(manifest.runtime); + for (const packageIdentity of manifest.packages) Object.freeze(packageIdentity); + for (const migration of manifest.migrations) Object.freeze(migration); + for (const artifact of manifest.artifacts) Object.freeze(artifact); + Object.freeze(manifest.buildCommands); + Object.freeze(manifest.processRoles); + Object.freeze(manifest.packages); + Object.freeze(manifest.migrations); + Object.freeze(manifest.artifacts); + return Object.freeze(manifest); +} + +/** + * Parses and deeply freezes one untrusted release manifest value. + * @param input Unknown JSON-compatible manifest candidate. + * @returns Canonically ordered, immutable release identity. + */ +export function parseReleaseManifest(input: unknown): ReleaseManifest { + const result = v.safeParse(releaseManifestSchema, input, { abortEarly: true }); + if (!result.success) throw new TypeError(invalidReleaseManifest); + return freezeManifest(result.output); +} + +/** + * Serializes a validated manifest with deterministic key ordering and one final newline. + * @param input Parsed or untrusted manifest candidate. + * @returns Canonical checked-in/artifact representation. + */ +export function serializeReleaseManifest(input: unknown): string { + return `${JSON.stringify(parseReleaseManifest(input), null, 2)}\n`; +} diff --git a/greenfield/src/test/integration/build/frontendBuildScenario.test.ts b/greenfield/src/test/integration/build/frontendBuildScenario.test.ts index abec812a1..933899774 100644 --- a/greenfield/src/test/integration/build/frontendBuildScenario.test.ts +++ b/greenfield/src/test/integration/build/frontendBuildScenario.test.ts @@ -3,8 +3,8 @@ import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { assertSelfHostedFrontendHtml } from "../../../../scripts/frontendBuildArtifacts.ts"; import { - assertSelfHostedFrontendHtml, buildFrontendScenario, frontendBuildPluginOrder, } from "./frontendBuildScenario.ts"; diff --git a/greenfield/src/test/integration/build/frontendBuildScenario.ts b/greenfield/src/test/integration/build/frontendBuildScenario.ts index f39cd5fff..7ad2522d0 100644 --- a/greenfield/src/test/integration/build/frontendBuildScenario.ts +++ b/greenfield/src/test/integration/build/frontendBuildScenario.ts @@ -1,9 +1,10 @@ -import { mkdir, readFile, rm } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; import path from "node:path"; import tailwindPlugin from "bun-plugin-tailwind"; import { + assertSelfHostedFrontendHtml, assertFrontendBundleBudgets, initialFrontendOutputKeys, measureFrontendBundle, @@ -35,20 +36,6 @@ export const frontendBuildPluginOrder = [ tailwindPlugin.name, ] as const; -const frontendHtmlResourceAttributes = new Set([ - "action", - "background", - "cite", - "data", - "formaction", - "href", - "manifest", - "poster", - "src", - "xlink:href", -]); -const frontendHtmlSourceSetAttributes = new Set(["imagesrcset", "srcset"]); - /** * Builds a minimal HTML-entry frontend with the target compiler-first pipeline. * The fixture intentionally shares the production artifact policy helpers. @@ -97,6 +84,7 @@ export async function buildFrontendScenario( resolvedOutdir, frontendBuildFixtureAppInput ); + await assertSelfHostedFrontendHtml(path.join(resolvedOutdir, "index.html")); const initialOutputPaths = [ ...initialFrontendOutputKeys(result.metafile, frontendBuildFixtureAppInput), ].map((outputPath) => normalizedOutputPath(outputPath, resolvedOutdir)); @@ -132,140 +120,6 @@ export async function buildFrontendScenario( }; } -/** - * Fails when generated HTML would require inline or third-party script/style CSP. - * @param indexPath Generated HTML entrypoint. - * @returns Promise that resolves when the entrypoint is self-hosted. - */ -export async function assertSelfHostedFrontendHtml(indexPath: string): Promise { - const html = await readFile(indexPath, "utf8"); - const scripts: Array<{ body: string; source: string | null; type: string | null }> = - []; - let styleCount = 0; - let hasInlineEventHandler = false; - let hasInlineSourceDocument = false; - let hasInlineStyle = false; - let hasNonSelfHostedResource = false; - let hasBaseElement = false; - const rewriter = new HTMLRewriter() - .on("*", { - element(element) { - for (const [name, value] of element.attributes) { - const normalizedName = name.toLowerCase(); - if (normalizedName.startsWith("on")) { - hasInlineEventHandler = true; - } else if (normalizedName === "srcdoc") { - hasInlineSourceDocument = true; - } else if (normalizedName === "style") { - hasInlineStyle = true; - } else if ( - frontendHtmlResourceAttributes.has(normalizedName) && - !isSelfHostedResourceReference(value) - ) { - hasNonSelfHostedResource = true; - } else if ( - frontendHtmlSourceSetAttributes.has(normalizedName) && - !isSelfHostedSourceSet(value) - ) { - hasNonSelfHostedResource = true; - } - } - }, - }) - .on("base", { - element() { - hasBaseElement = true; - }, - }) - .on("script", { - element(element) { - scripts.push({ - body: "", - source: element.getAttribute("src"), - type: element.getAttribute("type"), - }); - }, - text(text) { - const script = scripts.at(-1); - if (script) script.body += text.text; - }, - }) - .on("style", { - element() { - styleCount += 1; - }, - }); - rewriter.transform(html); - - if ( - scripts.length !== 1 || - styleCount > 0 || - hasInlineEventHandler || - hasInlineSourceDocument || - hasInlineStyle || - hasBaseElement - ) { - throw new Error( - "Frontend HTML must contain one external script and no inline code" - ); - } - - const script = scripts[0]!; - if ( - script.type !== "module" || - !script.source?.startsWith("/assets/") || - script.body.trim().length > 0 - ) { - throw new Error("Frontend HTML module script must be external and self-hosted"); - } - - if (hasNonSelfHostedResource) { - throw new Error("Frontend HTML cannot depend on a third-party CSP origin"); - } -} - -function isSelfHostedResourceReference(value: string): boolean { - const reference = value.trim(); - if (reference.length === 0 || reference.includes("&") || reference.includes("\\")) { - return false; - } - if (/^[a-z][a-z\d+.-]*:/iu.test(reference) || reference.startsWith("//")) { - return false; - } - try { - const base = new URL("https://integration.invalid/"); - const resolved = new URL(reference, base); - return ( - resolved.origin === base.origin && resolved.pathname.startsWith("/assets/") - ); - } catch { - return false; - } -} - -function isSelfHostedSourceSet(value: string): boolean { - const candidates = value.split(","); - return ( - candidates.length > 0 && - candidates.every((candidate) => { - const tokens = candidate.trim().split(/\s+/u); - if ( - tokens.length === 0 || - tokens.length > 2 || - !isSelfHostedResourceReference(tokens[0] ?? "") - ) { - return false; - } - const descriptor = tokens[1]; - return ( - descriptor === undefined || - /^\d+w$/u.test(descriptor) || - /^(?:\d+|\d*\.\d+)x$/u.test(descriptor) - ); - }) - ); -} - function normalizedOutputPath(outputPath: string, outdir: string): string { return path.relative(outdir, path.resolve(outputPath)).replaceAll("\\", "/"); } diff --git a/greenfield/src/test/integration/delivery/productionReleaseLifecycle.test.ts b/greenfield/src/test/integration/delivery/productionReleaseLifecycle.test.ts new file mode 100644 index 000000000..a857c2c45 --- /dev/null +++ b/greenfield/src/test/integration/delivery/productionReleaseLifecycle.test.ts @@ -0,0 +1,269 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { cp, lstat, mkdtemp, readFile, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { Effect } from "effect"; + +import type { BuildSourceIdentity } from "../../../../scripts/buildSourceIdentity.ts"; +import { buildDashboardRelease } from "../../../../scripts/delivery/buildRelease.ts"; +import { withDeploymentLease } from "../../../../scripts/delivery/deploymentLease.ts"; +import { prepareProductionDeliveryDirectories } from "../../../../scripts/delivery/productionDeliveryFilesystem.ts"; +import { + activatePublishedProductionRelease, + type ProductionServiceController, +} from "../../../../scripts/delivery/productionReleaseActivation.ts"; +import { publishProductionRelease } from "../../../../scripts/delivery/productionReleasePublication.ts"; +import type { PublishedProductionRelease } from "../../../../scripts/delivery/productionReleasePublication.ts"; +import { installProductionRuntime } from "../../../../scripts/delivery/productionRuntime.ts"; +import type { InstalledProductionRuntime } from "../../../../scripts/delivery/productionRuntime.ts"; +import { pointProductionProcessesAtRelease } from "../../../../scripts/delivery/productionRuntimePointers.ts"; +import { prepareProtectedProductionStatePath } from "../../../../scripts/delivery/productionStateFilesystem.ts"; +import type { ReleaseRuntimeIdentity } from "../../../../scripts/delivery/releaseIdentity.ts"; +import { removeProductionDeliveryFixtures } from "../../../../scripts/testSupport/productionDeliveryFixture.ts"; + +const sourceProjectRoot = path.resolve(import.meta.dir, "../../../.."); +const releaseId = "d".repeat(40); +const temporaryDirectories: string[] = []; +const excludedBuildEntries = new Set([".git", "coverage", "dist", "node_modules"]); + +afterEach(async () => { + await removeProductionDeliveryFixtures(temporaryDirectories); +}); + +async function unusedLoopbackPort(): Promise { + const server = Bun.serve({ + fetch: () => new Response(null, { status: 503 }), + hostname: "127.0.0.1", + port: 0, + }); + const port = server.port; + await server.stop(true); + if (port === undefined) throw new Error("Bun did not assign a loopback port"); + return port; +} + +async function realReleaseFixture( + runtimeIdentity: ReleaseRuntimeIdentity +): Promise { + const fixtureParent = await mkdtemp( + path.join(tmpdir(), "mira-production-lifecycle-build-") + ); + temporaryDirectories.push(fixtureParent); + const repositoryRoot = path.join(fixtureParent, "checkout"); + await cp(sourceProjectRoot, repositoryRoot, { + filter(source) { + const relative = path.relative(sourceProjectRoot, source); + const rootEntry = relative.split(path.sep)[0]; + return relative.length === 0 || !excludedBuildEntries.has(rootEntry ?? ""); + }, + recursive: true, + }); + await symlink( + path.join(sourceProjectRoot, "node_modules"), + path.join(repositoryRoot, "node_modules"), + "dir" + ); + const sourceIdentity: BuildSourceIdentity = Object.freeze({ + commitSha: releaseId, + state: "clean", + }); + const release = await buildDashboardRelease(repositoryRoot, { + resolveSourceIdentity: () => sourceIdentity, + runtimeIdentity, + }); + return release.releaseRoot; +} + +function webEnvironment(projectRoot: string, port: number): Record { + const encodedKey = Buffer.alloc(32, 7).toString("base64"); + return { + MIRA_DASHBOARD_LOG_LEVEL: "debug", + MIRA_DASHBOARD_PROJECT_ROOT: projectRoot, + MIRA_DASHBOARD_PUBLIC_ORIGIN: "https://dashboard.example.com", + MIRA_DASHBOARD_RECENT_AUTH_MINUTES: "10", + MIRA_DASHBOARD_SESSION_IDLE_MINUTES: "30", + MIRA_DASHBOARD_TOTP_KEYRING: JSON.stringify({ + activeKeyId: "primary", + formatVersion: 1, + keys: [{ id: "primary", keyBase64: encodedKey }], + }), + MIRA_DASHBOARD_TRUSTED_PROXY_IPS: "127.0.0.1,::1", + MIRA_DASHBOARD_WEBAUTHN_ORIGINS: "https://dashboard.example.com", + MIRA_DASHBOARD_WEBAUTHN_RP_ID: "example.com", + MIRA_DASHBOARD_WEBAUTHN_RP_NAME: "Mira Dashboard", + NODE_ENV: "production", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:65530", + PORT: String(port), + }; +} + +async function stopChild( + child: Bun.Subprocess<"ignore", "ignore", "ignore"> | undefined +): Promise { + if (!child || child.exitCode !== null) return; + child.kill("SIGTERM"); + const exited = await Promise.race([ + child.exited.then(() => true), + Bun.sleep(5000).then(() => false), + ]); + if (!exited && child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } +} + +class DirectProcessController implements ProductionServiceController { + readonly #lease: Parameters[0]; + readonly #paths: Parameters[1]; + readonly #port: number; + readonly #projectRoot: string; + #web: Bun.Subprocess<"ignore", "ignore", "ignore"> | undefined; + #worker: Bun.Subprocess<"ignore", "ignore", "ignore"> | undefined; + + constructor( + lease: Parameters[0], + paths: Parameters[1], + projectRoot: string, + port: number + ) { + this.#lease = lease; + this.#paths = paths; + this.#projectRoot = projectRoot; + this.#port = port; + } + + prepare(): Promise { + return Promise.resolve(); + } + + async start( + release: PublishedProductionRelease, + runtime: InstalledProductionRuntime + ): Promise { + await this.stop(); + await pointProductionProcessesAtRelease( + this.#lease, + this.#paths, + release, + runtime + ); + const common = { + cwd: release.releaseRoot, + stderr: "ignore" as const, + stdin: "ignore" as const, + stdout: "ignore" as const, + }; + this.#worker = Bun.spawn( + [runtime.executable, path.join(release.releaseRoot, "server/worker.js")], + { + ...common, + env: { + MIRA_DASHBOARD_LOG_LEVEL: "debug", + MIRA_DASHBOARD_PROJECT_ROOT: this.#projectRoot, + NODE_ENV: "production", + }, + } + ); + await Bun.sleep(100); + if (this.#worker.exitCode !== null) throw new Error("Worker exited early"); + this.#web = Bun.spawn( + [runtime.executable, path.join(release.releaseRoot, "server/web.js")], + { ...common, env: webEnvironment(this.#projectRoot, this.#port) } + ); + await Bun.sleep(100); + if (this.#web.exitCode !== null) throw new Error("Web exited early"); + } + + async stop(): Promise { + const web = this.#web; + const worker = this.#worker; + this.#web = undefined; + this.#worker = undefined; + await stopChild(web); + await stopChild(worker); + } + + async verifyReady(): Promise { + const deadline = Date.now() + 15_000; + const readinessUrl = `http://127.0.0.1:${this.#port}/api/health/ready`; + while (Date.now() < deadline) { + if (this.#web?.exitCode !== null || this.#worker?.exitCode !== null) { + throw new Error("Production process exited before readiness"); + } + try { + const response = await fetch(readinessUrl, { + cache: "no-store", + signal: AbortSignal.timeout(1000), + }); + if (response.status === 200) return; + } catch { + // Retry only within the bounded activation readiness window. + } + await Bun.sleep(50); + } + throw new Error("Production readiness timed out"); + } +} + +describe("disposable production release lifecycle", () => { + test("builds, migrates, activates, serves, logs, and shuts down exact artifacts", async () => { + const runtimeIdentity = Object.freeze({ + revision: Bun.revision, + version: Bun.version, + }); + const sourceRelease = await realReleaseFixture(runtimeIdentity); + const projectRoot = await mkdtemp( + path.join(tmpdir(), "mira-production-lifecycle-target-") + ); + temporaryDirectories.push(projectRoot); + const state = await prepareProtectedProductionStatePath(projectRoot); + const port = await unusedLoopbackPort(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const runtime = await installProductionRuntime( + lease, + paths, + runtimeIdentity, + { sourceExecutable: process.execPath } + ); + const release = await publishProductionRelease( + lease, + paths, + sourceRelease, + runtimeIdentity + ); + const services = new DirectProcessController(lease, paths, projectRoot, port); + try { + const activation = await Effect.runPromise( + activatePublishedProductionRelease(lease, paths, release, runtime, { + services, + }) + ); + expect(activation.current).toEqual({ + releaseId, + runtimeRevision: Bun.revision, + }); + const browser = await fetch(`http://127.0.0.1:${port}/`); + expect(browser.status).toBe(200); + expect(await browser.text()).toContain("Mira Dashboard"); + const [webLog, workerLog, databaseStatus] = await Promise.all([ + readFile(path.join(paths.stateDirectory, "logs/web.ndjson"), "utf8"), + readFile( + path.join(paths.stateDirectory, "logs/worker.ndjson"), + "utf8" + ), + lstat(path.join(paths.stateDirectory, "mira-dashboard.db"), { + bigint: true, + }), + ]); + expect(webLog).toContain('"event":"runtime.started"'); + expect(workerLog).toContain('"event":"runtime.started"'); + expect(databaseStatus.isFile()).toBeTrue(); + expect(databaseStatus.mode & 0o777n).toBe(0o600n); + } finally { + await services.stop(); + } + }); + }, 120_000); +}); diff --git a/greenfield/src/worker/runtime.ts b/greenfield/src/worker/runtime.ts new file mode 100644 index 000000000..aa2ea05f1 --- /dev/null +++ b/greenfield/src/worker/runtime.ts @@ -0,0 +1,5 @@ +/** Database-validation lifecycle owned by the worker process. */ +export interface DashboardWorkerRuntime { + dispose(): Promise; + initialize(): Promise; +} diff --git a/greenfield/systemd/mira-dashboard-web.service b/greenfield/systemd/mira-dashboard-web.service new file mode 100644 index 000000000..45e2ca9c8 --- /dev/null +++ b/greenfield/systemd/mira-dashboard-web.service @@ -0,0 +1,29 @@ +[Unit] +Description=Mira Dashboard web +After=network-online.target openclaw-gateway.service +Wants=network-online.target + +[Service] +Type=simple +UMask=0077 +WorkingDirectory=%h/projects/mira-dashboard/production/releases/current +Environment=NODE_ENV=production +Environment=MIRA_DASHBOARD_PROJECT_ROOT=%h/projects/mira-dashboard +ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- %h/projects/mira-dashboard/production/runtimes/bun/current/bun %h/projects/mira-dashboard/production/releases/current/server/web.js +StandardOutput=append:%h/projects/mira-dashboard/production/state/logs/web-stdout.log +StandardError=append:%h/projects/mira-dashboard/production/state/logs/web-stderr.log +Restart=on-failure +RestartSec=5 +KillMode=control-group +TimeoutStopSec=45 +NoNewPrivileges=true +PrivateTmp=true +CPUWeight=100 +IOWeight=100 +CPUQuota=100% +MemoryHigh=768M +MemoryMax=1G +TasksMax=96 + +[Install] +WantedBy=default.target diff --git a/greenfield/systemd/mira-dashboard-worker.service b/greenfield/systemd/mira-dashboard-worker.service new file mode 100644 index 000000000..1e64e81e3 --- /dev/null +++ b/greenfield/systemd/mira-dashboard-worker.service @@ -0,0 +1,30 @@ +[Unit] +Description=Mira Dashboard worker coordinator +After=network-online.target openclaw-gateway.service +Wants=network-online.target + +[Service] +Type=simple +UMask=0077 +WorkingDirectory=%h/projects/mira-dashboard/production/releases/current +Environment=NODE_ENV=production +Environment=MIRA_DASHBOARD_PROJECT_ROOT=%h/projects/mira-dashboard +ExecStart=/usr/local/bin/doppler run --config prd --project rajohan --preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT -- %h/projects/mira-dashboard/production/runtimes/bun/current/bun %h/projects/mira-dashboard/production/releases/current/server/worker.js +StandardOutput=append:%h/projects/mira-dashboard/production/state/logs/worker-stdout.log +StandardError=append:%h/projects/mira-dashboard/production/state/logs/worker-stderr.log +Restart=on-failure +RestartSec=5 +KillMode=control-group +TimeoutStopSec=45 +NoNewPrivileges=true +PrivateTmp=true +Nice=10 +CPUWeight=20 +IOWeight=20 +CPUQuota=150% +MemoryHigh=768M +MemoryMax=1536M +TasksMax=128 + +[Install] +WantedBy=default.target