diff --git a/bun.lock b/bun.lock index 7f8583cc4..cf47220fc 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "hack-cli", "dependencies": { + "@balena/dockerignore": "1.0.2", "@charmland/lipgloss": "2.0.0-beta.3-0e280f3", "@clack/prompts": "1.0.0-alpha.9", "@modelcontextprotocol/sdk": "1.26.0", @@ -74,6 +75,8 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="], + "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index a5f9d4d6b..a9b9fd75d 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -164,6 +164,106 @@ same-identity byte repair, separate rollback and exact owned cleanup. It requires the current compiled CLI and companion compiler; it does not qualify completed jobs or container recreation. +## Retained basic builds + +The distinct version 9 owner handles existing basic build-only services with +qualified named data volumes and the existing default bridge. Context, relative +Dockerfile and target use the closed import mapping, with current local files +additionally verified. A service must omit `pull_policy`: explicit `build` +requires builder execution and cannot be satisfied by starting an old image. +The owner never builds, pulls, creates a container or substitutes an image during +the format switch or retained execution. Explicit rebuild/recreation requests +refuse. Image-only binding APIs and versions 1–5 retain their prior contracts. +Versions for custom networks, completed jobs and retained files are separate; +their combinations with this first build proof refuse, as do profiles, readiness, +managed/generated inputs, typed locals and literal-dollar build paths. +Pure preview can map a qualified custom bridge alongside a basic build; retained +build adoption refuses that intersection before opening context files, including +inactive workloads. + +Included context files, the Dockerfile, optional root and Dockerfile-specific +ignore files, and their safe filesystem identities are privately pinned. The +names-only private env/local layout refusal precedes the context walk and is +rechecked afterwards; known private material added mid-walk is never opened as +an included file. A Dockerfile-specific ignore file takes precedence, while presence and bytes of +both files remain bound. The pinned `@balena/dockerignore` Moby port handles only +the qualified case-sensitive grammar: literal normalized paths, `!` negation, +bare `**`, blank lines and comments. Other globs, escapes, BOMs and ambiguous +paths refuse. Every possible adopted-owned path must be excluded by the effective +rules, including Git markers, `.hack/.internal`, `.hack/.branch` and the switched +authored files. Excluded subtrees are not read. Parent negations include +descendants: `**` followed by `!.hack` does not isolate future private outputs. +Such a context refuses unless later literal exclusions close those paths. +Root and `.hack` contexts can qualify through these exact exclusions. + +The maintained `native-compose-adoption-build-worktrees` acceptance explicitly +requires Buildx's default Docker driver, `DOCKER_BUILDKIT=1` and +`compose build --builder default` for fixture bootstrap. It never creates or +bootstraps a separate builder. Its private fixed-stage evidence retains the +complete fatal-decoded synthetic COPY reply before applying the unchanged file +and hash oracle. This separates builder qualification from source/image guards; +it does not infer which builder caused an earlier failure or prove cache ownership. + +One acquisition is limited to 16 builds, depth 32, 256 captured entries, 4096 +directory names, 16 MiB of bytes and a 48 KiB private proof; the existing stable +file owner also limits each file to 1 MiB. Included byte, identity or mode changes, +included additions/removals, ignore presence changes and unsafe paths refuse. +Same-inode exact byte repair can restore a saved proof; it does not repair a +strict prepared authored-source timestamp or make editor races atomic. + +The selected read-only Compose query supplies each original image reference. +Container IDs and birth, image IDs and birth, and current tag resolution must +match the privately saved observation, along with the existing config hashes, +mounts, network identity and volume creation identities. No image environment, +command or layer contents are read. These facts attest the current retained +image and current included source separately; they do not establish which source +historically built that image. Missing images or retargeted tags refuse before +effects. No image ownership for removal is granted by this proof. + +Preparation, dry-run and saved execution use the same closed owner. Public output +contains field provenance and counts, never context hashes, image references or +private capabilities. Version 9 requires a finite remaining mutation deadline, +the whole original selection and the existing signal/process-group owner. +Starts, stops and recovery consume original IDs; source or image drift retains +pending evidence. Rollback restores the exact original authored inputs after +verified stop and keeps their original data. Older upgraded owners that know +only versions 1–5 refuse the new proof; this is not a universal old-launcher +fence. Current Moby/BuildKit ignore parity and maintained two-worktree SQL, +image/ID/birth, recovery and rollback acceptance qualify this slice separately +from pure preview and synthetic model controls. Full NC04 remains open. + +The maintained `native-compose-adoption-build-worktrees` scenario requires +explicit selection. It bootstraps two disposable Postgres images, checks the +complete `COPY .` projection for root/Dockerfile-specific and default `.hack` +contexts, and then permits only captured metadata queries and journaled original-ID +starts/stops. Format switch and saved consumption cannot reach a builder. Its +source and candidate drift controls retain pending ownership, preserve the other +worktree's SQL row, and restore both original configurations through rollback. +The fixture issues a recovery-stop transport capability only for the exact +`down --recover --json` invocation. It can stop the original IDs while the active +generation retains its interrupted start journal; publication, generation, +complete service selection and daemon checks still precede each effect. Private +per-invocation evidence records closed operation/substage names and CLI exit/code +classifications before caller assertions, without reply values or arbitrary errors. +Exact fixture image removal requires its captured new ID/birth, sole tag, fixture +label, unchanged daemon and no remaining container references. A local final +image may expose one digest for that exact repository and captured image ID; +other digest aliases refuse. The fixture privately journals the exact new +image graph after each bootstrap build. Only fixture-labelled, untagged parents +on its complete chain to the captured original base qualify for disposal, in +child-before-parent order with nonforce `image rm --no-prune`. A builder exposing +no parent qualifies only its single final object. Unexplained new images, foreign +labels or references retain the failed fixture. Only an absent `Parent` key +defaults to empty; an explicit empty parent is preserved and malformed present +values still refuse. +The known Compose project, service and version labels must match the exact +fixture project, `db` service and selected Compose release. Other label names +remain refused. Full original image inventory +and tags must be restored; inventory bounds are not relaxed for new objects. +General builder cache is retained; cache reclamation and historical image-from-source provenance remain +unqualified. Fixture source/model checks alone do not establish live builder or +data-preservation acceptance. + The version 4 typed-local slice reads optional `.hack/hack.local.json` at the selected checkout and verified inherited primary in the same issued private source acquisition. It accepts only `schema_version: 1` and an optional `environment` @@ -227,7 +327,8 @@ shared network ID nor a partial member observation can stand in for the other bridge. Engine ID, resource inventories and source bytes are rechecked, and acquisition compares two complete private observations. The Docker routing environment is captured for later comparison. Queries never -request container environment or image configuration. This retained-resource authority +request container environment or full image configuration; version 9 additionally +reads the minimal image reference, ID and birth described above. This retained-resource authority keeps the original bridge and container IDs through stop, saved restart, recovery and rollback; it does not authorize a later newly created native generation to take over those identities. That transition needs a separate diff --git a/docs/reference/native-config-import.md b/docs/reference/native-config-import.md index 0dca9169a..803ec253c 100644 --- a/docs/reference/native-config-import.md +++ b/docs/reference/native-config-import.md @@ -169,8 +169,10 @@ workload's `pull_policy: build` is a separate supported acquisition requirement. Combined `build` and `image` refuse because the native model requires exactly one source. Invalid builds cannot fall back to an authored image or a default policy. -This expands read-only preview only. Retained-container adoption still uses its -separate image-only mapping and refuses builds. The mapper runs no Compose +This expands read-only preview only. The image-only retained baseline remains +closed. A separate [proof-bearing basic-build owner](native-compose-adoption.md#retained-basic-builds) +qualifies current included source and exact existing images before adoption; a +complete pure preview never supplies that authority. The mapper runs no Compose normalization, builder or runtime command, and does not validate Dockerfile contents, path existence or filesystem identity. The authoritative compiler still must validate the whole private candidate before a complete CLI preview; actual diff --git a/package.json b/package.json index ce4db2e8c..73053cacc 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "typescript": "^5" }, "dependencies": { + "@balena/dockerignore": "1.0.2", "@charmland/lipgloss": "2.0.0-beta.3-0e280f3", "@clack/prompts": "1.0.0-alpha.9", "elysia": "^1.4.9", diff --git a/src/commands/config-adopt.ts b/src/commands/config-adopt.ts index d806c1b63..6fe298378 100644 --- a/src/commands/config-adopt.ts +++ b/src/commands/config-adopt.ts @@ -111,7 +111,10 @@ async function adoptPrepared( if (opts.signal.aborted) { throw new Error("Legacy adoption cancelled; values omitted."); } - if (legacyComposeRetainedOrdered(input.retainedPlan)) { + if ( + legacyComposeRetainedOrdered(input.retainedPlan) || + input.retainedBuild + ) { return await runLegacyComposeRetainedOperation({ input, operation: "stop", diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index cd82c9b76..48d355bb2 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -1,5 +1,13 @@ import { resolve } from "node:path"; import { isRecord } from "./guards.ts"; +import { + acquireLegacyComposeBuildSource, + type LegacyComposeBuildSourceProof, +} from "./native-compose-adoption-build.ts"; +import { + inspectLegacyComposeRetainedBuildImages, + type LegacyComposeRetainedBuildImage, +} from "./native-compose-adoption-build-images.ts"; import { legacyComposeAdoptionCandidateSupported, legacyComposeAdoptionLayoutSupported, @@ -8,6 +16,7 @@ import { retainLegacyAdoptionLocalRefusal } from "./native-compose-adoption-loca import { type LegacyComposeStorageIntent, planLegacyComposeAdoption, + planLegacyComposeRetainedBasicBuildAdoption, } from "./native-compose-adoption-plan.ts"; import { hasLegacyComposeGeneratedSources, @@ -858,6 +867,10 @@ export type LegacyComposeAdoptionBinding = { readonly compose: NativeConfigImportSourceIdentity; }; readonly projection?: Readonly; + readonly build?: { + readonly source: LegacyComposeBuildSourceProof; + readonly images: readonly LegacyComposeRetainedBuildImage[]; + }; }>; }; function translate(error: unknown, signal?: AbortSignal): never { @@ -886,12 +899,36 @@ function translate(error: unknown, signal?: AbortSignal): never { * consume this binding explicitly and recheck it on the same engine under its * lease. Repeated checks cannot atomically freeze Docker or external editors. */ -export async function acquireLegacyComposeAdoptionBinding(input: { +type BindingSelection = { readonly projectRoot: string; readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly binary?: string; -}): Promise { +}; +export async function acquireLegacyComposeAdoptionBinding( + input: BindingSelection +): Promise { + return await acquireBinding(input, "image-only"); +} + +/** Distinct current-source/image proof; ordinary image-only admission is unchanged. */ +export async function acquireLegacyComposeRetainedBasicBuildBinding( + input: BindingSelection +): Promise { + return await acquireBinding(input, "basic-build"); +} + +/** Preparation self-selects a qualified owner; no caller-provided binding or bypass is accepted. */ +export async function acquireLegacyComposeAdoptionPreparationBinding( + input: BindingSelection +): Promise { + return await acquireBinding(input, "preparation"); +} + +async function acquireBinding( + input: BindingSelection, + purpose: "image-only" | "basic-build" | "preparation" +): Promise { let signal: AbortSignal | undefined; try { const selected = selection(input); @@ -909,22 +946,34 @@ export async function acquireLegacyComposeAdoptionBinding(input: { if (!source.ok) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } - const planned = planLegacyComposeAdoption({ + const ordinary = planLegacyComposeAdoption({ configText: source.configText, composeText: source.composeText, }); + const basic = + purpose === "basic-build" || + (purpose === "preparation" && !ordinary.intent); + const planned = basic + ? planLegacyComposeRetainedBasicBuildAdoption(source) + : ordinary; const intent = planned.intent; if (!intent) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } + const buildSource = basic + ? await acquireLegacyComposeBuildSource({ source, signal }) + : undefined; const mapped = mapLegacyNativeStorageAdoption({ configText: source.configText, composeText: source.composeText, }); - const candidate = mapped.candidate; + const candidate = buildSource?.candidate ?? mapped.candidate; const jobFamily = candidate && legacyComposeRetainedPlan(candidate).requiresV7 === true; - if (jobFamily && !legacyComposeAdoptionCandidateSupported(candidate)) { + if ( + jobFamily && + (buildSource || !legacyComposeAdoptionCandidateSupported(candidate)) + ) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } let projection: LegacyComposeAdoptionProjection | undefined; @@ -942,7 +991,7 @@ export async function acquireLegacyComposeAdoptionBinding(input: { signal, }))) ) { - if (jobFamily) { + if (buildSource || jobFamily) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); } projection = await LegacyComposeAdoptionProjection.acquire({ @@ -964,6 +1013,9 @@ export async function acquireLegacyComposeAdoptionBinding(input: { await projection.assertFresh({ signal: selectedSignal }); return; } + if (buildSource) { + await buildSource.assertFresh({ signal: selectedSignal }); + } if (await hasLegacyComposeGeneratedSources(root, selectedSignal)) { refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); } @@ -988,6 +1040,14 @@ export async function acquireLegacyComposeAdoptionBinding(input: { timeoutMs, composeFiles: projected?.composeFiles, }); + const buildImages = buildSource + ? await inspectLegacyComposeRetainedBuildImages({ + binding: baseline, + composeFile: baseline.composeFile, + signal, + timeoutMs, + }) + : undefined; freezeImportValue(baseline); const assertFresh = async (current: { readonly projectRoot: string; @@ -1019,6 +1079,19 @@ export async function acquireLegacyComposeAdoptionBinding(input: { if (JSON.stringify(observed) !== JSON.stringify(baseline)) { refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); } + if ( + buildImages && + JSON.stringify( + await inspectLegacyComposeRetainedBuildImages({ + binding: observed, + composeFile: observed.composeFile, + signal: currentSignal, + timeoutMs, + }) + ) !== JSON.stringify(buildImages) + ) { + refuse("E_LEGACY_COMPOSE_BINDING_CHANGED"); + } await source.assertFresh({ signal: currentSignal }); await layoutSupported(currentSignal); cancelled(signal); @@ -1050,6 +1123,14 @@ export async function acquireLegacyComposeAdoptionBinding(input: { binding: baseline, sourceFiles: source.sourceFiles, ...(projected ? { projection: projected } : {}), + ...(buildSource && buildImages + ? { + build: Object.freeze({ + source: buildSource.proof, + images: buildImages, + }), + } + : {}), }; for (const key of [ "configText", @@ -1057,6 +1138,7 @@ export async function acquireLegacyComposeAdoptionBinding(input: { "binding", "sourceFiles", "projection", + "build", ]) { Object.defineProperty(result, key, { enumerable: false }); } diff --git a/src/lib/native-compose-adoption-build-ignore.ts b/src/lib/native-compose-adoption-build-ignore.ts new file mode 100644 index 000000000..a0424c858 --- /dev/null +++ b/src/lib/native-compose-adoption-build-ignore.ts @@ -0,0 +1,68 @@ +import createDockerignore from "@balena/dockerignore"; + +const LITERAL = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/; +const LINES = /\r?\n/; +const MAX_RULES = 128; +const MAX_IGNORE_BYTES = 16 * 1024; + +function refuse(): never { + throw new Error( + "Legacy retained build ignore mapping is unsupported; values omitted." + ); +} + +/** + * Deliberately closed Dockerignore grammar. The pinned Moby port owns matching; + * qualification is limited to literal paths, their parent matches and bare **. + * Other globs, escapes, whitespace paths and normalization ambiguities refuse. + */ +export function legacyComposeBuildIgnore(text: string) { + if (Buffer.byteLength(text) > MAX_IGNORE_BYTES || text.includes("\uFEFF")) { + refuse(); + } + const rules: string[] = []; + const literals: string[] = []; + for (const raw of text.split(LINES)) { + if (raw.startsWith("#") || !raw.trim()) { + continue; + } + const rule = raw.trim(); + const literal = rule.startsWith("!") ? rule.slice(1) : rule; + if ( + (rule !== "**" && + (!LITERAL.test(literal) || + literal.split("/").some((part) => part === "." || part === ".."))) || + rules.length >= MAX_RULES + ) { + refuse(); + } + rules.push(rule); + if (rule !== "**") { + literals.push(literal); + } + } + const matcher = createDockerignore({ ignorecase: false }).add(rules); + // This name denotes the unmatched child region, rather than a filesystem + // object. It cannot coincide with any admitted literal rule component. + let unmatched = "__hack_build_unmatched__"; + const components = new Set(literals.flatMap((path) => path.split("/"))); + while (components.has(unmatched)) { + unmatched += "_"; + } + function excluded(path: string) { + return matcher.ignores(path); + } + function subtreeExcluded(path: string) { + const regions = new Set([path, `${path}/${unmatched}`]); + for (const literal of literals) { + if (literal.startsWith(`${path}/`)) { + regions.add(literal); + regions.add(`${literal}/${unmatched}`); + } + } + // With this grammar, matching changes only at a literal prefix boundary. + // Its own node and unmatched-child region cover every descendant decision. + return [...regions].every(excluded); + } + return Object.freeze({ excluded, subtreeExcluded }); +} diff --git a/src/lib/native-compose-adoption-build-images.ts b/src/lib/native-compose-adoption-build-images.ts new file mode 100644 index 000000000..7d102420c --- /dev/null +++ b/src/lib/native-compose-adoption-build-images.ts @@ -0,0 +1,157 @@ +import { isRecord } from "./guards.ts"; +import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import { freezeImportValue } from "./native-config-import-plan.ts"; + +const ID = /^[a-f0-9]{64}$/; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const REFERENCE = /^[A-Za-z0-9][A-Za-z0-9_.:/@-]{0,511}$/; +const TIME = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; +const CONTAINER = + '{"id":{{json .Id}},"image":{{json .Image}},"reference":{{json .Config.Image}},"createdAt":{{json .Created}}}'; +const BUILT_IMAGE = '{"id":{{json .Id}},"createdAt":{{json .Created}}}'; + +/** Private observation of the current image; it does not attest historical build inputs. */ +export type LegacyComposeRetainedBuildImage = { + readonly service: string; + readonly container: string; + readonly containerCreatedAt: string; + readonly reference: string; + readonly image: string; + readonly imageCreatedAt: string; +}; +function refuse(): never { + throw new Error( + "Legacy retained build image identity is unavailable or changed; values omitted." + ); +} +function time(value: unknown): value is string { + return ( + typeof value === "string" && + TIME.test(value) && + Number.isFinite(Date.parse(value)) + ); +} +function record(text: string, expected: string) { + const value: unknown = JSON.parse(text); + if (!isRecord(value) || Object.keys(value).sort().join() !== expected) { + refuse(); + } + return value; +} + +/** + * Uses the authoritative selected Compose image reference, not a guessed name. + * Reads only IDs, reference and creation time; never image/container environment, + * command or layer contents. Same daemon and exact original IDs are rechecked. + * No tag mutation, allocation, build, pull or image removal is authorized. + */ +export async function inspectLegacyComposeRetainedBuildImages(opts: { + readonly binding: LegacyComposeVerifiedBinding; + readonly composeFile: string; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +}): Promise { + try { + const { composeFile, signal, timeoutMs } = opts; + const { binding_version, composeProject, projectRoot, engineId } = + opts.binding; + const originals = opts.binding.containers.map(({ id, service }) => ({ + id, + service, + })); + if (binding_version !== 1 || !NAME.test(composeProject)) { + refuse(); + } + const probe = createNativeComposeProbe({ signal, timeoutMs }); + const engine = async () => + (await probe(["info", "--format", "{{json .ID}}"])).trim(); + const baselineEngine = await engine(); + if (JSON.parse(baselineEngine) !== engineId) { + refuse(); + } + const result: LegacyComposeRetainedBuildImage[] = []; + for (const container of originals) { + if (!(ID.test(container.id) && NAME.test(container.service))) { + refuse(); + } + const imageOutput = await probe([ + "compose", + "--project-name", + composeProject, + "--project-directory", + `${projectRoot}/.hack`, + "--env-file", + "/dev/null", + "--profile", + "*", + "--file", + composeFile, + "config", + "--no-env-resolution", + "--images", + container.service, + ]); + const reference = imageOutput.trim(); + if (!REFERENCE.test(reference)) { + refuse(); + } + const original = record( + await probe([ + "container", + "inspect", + "--format", + CONTAINER, + container.id, + ]), + "createdAt,id,image,reference" + ); + if ( + original.id !== container.id || + original.reference !== reference || + typeof original.image !== "string" || + !IMAGE.test(original.image) || + !time(original.createdAt) + ) { + refuse(); + } + const tagged = record( + await probe(["image", "inspect", "--format", BUILT_IMAGE, reference]), + "createdAt,id" + ); + const exact = record( + await probe([ + "image", + "inspect", + "--format", + BUILT_IMAGE, + original.image, + ]), + "createdAt,id" + ); + if ( + tagged.id !== original.image || + JSON.stringify(tagged) !== JSON.stringify(exact) || + !time(exact.createdAt) + ) { + refuse(); + } + result.push({ + service: container.service, + container: container.id, + containerCreatedAt: original.createdAt, + reference, + image: original.image, + imageCreatedAt: exact.createdAt, + }); + } + if ((await engine()) !== baselineEngine) { + refuse(); + } + freezeImportValue(result); + return result; + } catch { + refuse(); + } +} diff --git a/src/lib/native-compose-adoption-build.ts b/src/lib/native-compose-adoption-build.ts new file mode 100644 index 000000000..347d02b52 --- /dev/null +++ b/src/lib/native-compose-adoption-build.ts @@ -0,0 +1,574 @@ +import { createHash } from "node:crypto"; +import type { Stats } from "node:fs"; +import { lstat, opendir, realpath } from "node:fs/promises"; +import { join, posix, resolve } from "node:path"; +import { isRecord } from "./guards.ts"; +import { legacyComposeBuildIgnore } from "./native-compose-adoption-build-ignore.ts"; +import { legacyComposeAdoptionLayoutSupported } from "./native-compose-adoption-contract.ts"; +import { + type NativeConfigImportInputs, + privateNativeConfigImportSourceProof, + readNativeConfigImportSourceFile, +} from "./native-config-import-inputs.ts"; +import { + freezeImportValue, + mapLegacyNativeRetainedBasicBuild, +} from "./native-config-import-plan.ts"; + +const MAX_ENTRIES = 256; +const MAX_BYTES = 16 * 1024 * 1024; +const MAX_NAMES = 4096; +const MAX_DEPTH = 32; +const MAX_PROOF_BYTES = 48 * 1024; +const MANAGED_PATH = /^\.hack\/hack\.env(?:\.|$)/; +const UNSAFE_PATH_BYTES = /[\\\0\r\n]/; +const PRIVATE_PATHS = [ + { path: ".git", tree: true }, + { path: ".hack/.internal", tree: true }, + { path: ".hack/.branch", tree: true }, + { path: ".hack/hack.config.json", tree: false }, + { path: ".hack/docker-compose.yml", tree: false }, + { path: ".hack/hack.project.json", tree: false }, +] as const; +type Source = Extract; +type Identity = { + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly uid: number; +}; +type File = Identity & { + readonly path: string; + readonly hash: string; + readonly size: number; +}; +type Node = + | (Identity & { readonly path: string; readonly kind: "directory" }) + | (File & { readonly kind: "file" }); +type Build = { + readonly service: string; + readonly context: string; + readonly dockerfile: string; + readonly target: string | null; +}; +type Context = Build & { + readonly ancestors: readonly (Identity & { readonly path: string })[]; + readonly definition: File; + readonly ignores: readonly { + readonly path: string; + readonly file: File | null; + }[]; + readonly effectiveIgnore: string | null; + readonly nodes: readonly Node[]; +}; +type Budget = { entries: number; bytes: number; names: number }; + +/** Private current raw-byte provenance, never a report, label, log or builder input. */ +export type LegacyComposeBuildSourceProof = { + readonly build_source_version: 1; + readonly contexts: readonly Context[]; +}; +function refuse(): never { + throw new Error( + "Legacy retained build source is unsupported, unsafe or changed; values omitted." + ); +} +function check(signal?: AbortSignal) { + if (signal?.aborted) { + refuse(); + } +} +function refusePrivateMaterial(root: string, path: string) { + const relative = posix.relative(root, path); + if ( + [".env", ".hack/.env", ".hack/hack.local.json"].includes(relative) || + MANAGED_PATH.test(relative) + ) { + refuse(); + } +} +async function requireLayout(opts: { + readonly root: string; + readonly candidate: unknown; + readonly signal?: AbortSignal; +}) { + if ( + !(await legacyComposeAdoptionLayoutSupported({ + projectRoot: opts.root, + candidate: opts.candidate, + signal: opts.signal, + })) + ) { + refuse(); + } +} +function identity(info: Stats): Identity { + if ( + info.uid !== process.getuid?.() || + (info.mode & 0o022) !== 0 || + !(info.mode & 0o444) + ) { + refuse(); + } + return { dev: info.dev, ino: info.ino, mode: info.mode, uid: info.uid }; +} +function pathSupported(path: string) { + return ( + path.length > 0 && + path.length <= 1024 && + path.split("/").length <= MAX_DEPTH && + posix.normalize(path) === path && + !path.startsWith("/") && + path !== ".." && + !path.startsWith("../") && + !path.includes("$") && + !UNSAFE_PATH_BYTES.test(path) + ); +} +function selectedBuild(service: string, value: unknown): Build | null { + if (!isRecord(value)) { + refuse(); + } + // These other version owners cannot be implicitly combined with a new proof. + if ( + Object.hasOwn(value, "profiles") || + Object.hasOwn(value, "depends_on") || + Object.hasOwn(value, "readiness") + ) { + refuse(); + } + if (!Object.hasOwn(value, "build")) { + return null; + } + // The retained start owner never invokes a builder. Explicit build policy + // requires a new image even when one exists, so it cannot be honored here. + if (Object.hasOwn(value, "pull_policy")) { + refuse(); + } + const build = value.build; + if ( + !( + isRecord(build) && + typeof build.context === "string" && + pathSupported(build.context) + ) + ) { + refuse(); + } + const context = build.context; + if ( + PRIVATE_PATHS.some( + (owned) => context === owned.path || context.startsWith(`${owned.path}/`) + ) + ) { + refuse(); + } + const dockerfile = build.dockerfile ?? "Dockerfile"; + const target = build.target ?? null; + if ( + typeof dockerfile !== "string" || + !pathSupported(dockerfile) || + dockerfile === "." || + (target !== null && typeof target !== "string") + ) { + refuse(); + } + const definitionPath = posix.join(context, dockerfile); + if ( + PRIVATE_PATHS.some( + (owned) => + definitionPath === owned.path || + (owned.tree && definitionPath.startsWith(`${owned.path}/`)) + ) + ) { + refuse(); + } + return { service, context, dockerfile, target }; +} +function selectedBuilds(candidate: unknown): readonly Build[] { + if (!(isRecord(candidate) && isRecord(candidate.services))) { + refuse(); + } + const result: Build[] = []; + for (const [service, value] of Object.entries(candidate.services)) { + const build = selectedBuild(service, value); + if (build) { + result.push(build); + } + } + if (!result.length || result.length > 16) { + refuse(); + } + return result.sort((left, right) => + left.service.localeCompare(right.service) + ); +} +async function directory(path: string, signal?: AbortSignal) { + check(signal); + const info = await lstat(path); + if (!info.isDirectory() || (await realpath(path)) !== path) { + refuse(); + } + return identity(info); +} +async function names(path: string, budget: Budget, signal?: AbortSignal) { + const selected: string[] = []; + const reader = await opendir(path); + for await (const entry of reader) { + check(signal); + if ( + ++budget.names > MAX_NAMES || + entry.name.includes("\n") || + entry.name.includes("\r") + ) { + refuse(); + } + selected.push(entry.name); + } + return selected.sort(); +} +async function optionalFile(path: string, signal?: AbortSignal) { + try { + await lstat(path); + } catch (error) { + if (isRecord(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + return readNativeConfigImportSourceFile({ path, signal }); +} +function requireExcluded( + context: string, + ignore: ReturnType +) { + for (const owned of PRIVATE_PATHS) { + if (context === owned.path || context.startsWith(`${owned.path}/`)) { + refuse(); + } + const relative = posix.relative(context, owned.path); + if (relative === ".." || relative.startsWith("../")) { + continue; + } + if ( + !(owned.tree + ? ignore.subtreeExcluded(relative) + : ignore.excluded(relative)) + ) { + refuse(); + } + } +} +async function captureContext(opts: { + readonly root: string; + readonly build: Build; + readonly signal?: AbortSignal; + readonly budget: Budget; +}): Promise { + const { root, build, signal, budget } = opts; + const base = resolve(root, build.context); + const ancestors = new Map(); + const checks: (() => Promise)[] = []; + async function pinDirectory(path: string) { + refusePrivateMaterial(root, path); + const before = await directory(path, signal); + const relative = posix.relative(root, path) || "."; + const held = ancestors.get(relative); + if (held && JSON.stringify(held) !== JSON.stringify(before)) { + refuse(); + } + ancestors.set(relative, before); + checks.push(async () => { + if ( + JSON.stringify(await directory(path, signal)) !== JSON.stringify(before) + ) { + refuse(); + } + }); + return before; + } + let ancestor = root; + await pinDirectory(ancestor); + for (const part of build.context === "." ? [] : build.context.split("/")) { + ancestor = join(ancestor, part); + await pinDirectory(ancestor); + } + async function pinFile(relative: string, optional = false) { + let parent = base; + for (const part of relative.split("/").slice(0, -1)) { + parent = join(parent, part); + await pinDirectory(parent); + } + const path = join(base, relative); + // A known private input added after the names-only precheck must never be + // opened as context material. The final layout check still refuses drift. + refusePrivateMaterial(root, path); + const read = optional + ? await optionalFile(path, signal) + : await readNativeConfigImportSourceFile({ path, signal }); + checks.push(async () => { + if (!read) { + if (await optionalFile(path, signal)) { + refuse(); + } + return; + } + const info = await lstat(path); + if ( + !info.isFile() || + JSON.stringify(identity(info)) !== + JSON.stringify(identity(read.info)) || + info.nlink !== 1 || + info.size !== read.info.size || + info.mtimeMs !== read.info.mtimeMs || + info.ctimeMs !== read.info.ctimeMs + ) { + refuse(); + } + }); + if (!read) { + return null; + } + budget.bytes += read.bytes.byteLength; + budget.entries += 1; + if (budget.bytes > MAX_BYTES || budget.entries > MAX_ENTRIES) { + refuse(); + } + return { + file: { + path: relative, + ...identity(read.info), + hash: createHash("sha256").update(read.bytes).digest("hex"), + size: read.bytes.byteLength, + }, + bytes: read.bytes, + }; + } + const definition = await pinFile(build.dockerfile); + if (!definition) { + refuse(); + } + const rootIgnore = await pinFile(".dockerignore", true); + const specificPath = `${build.dockerfile}.dockerignore`; + const specificIgnore = await pinFile(specificPath, true); + const effective = specificIgnore ?? rootIgnore; + // Shadowing does not hide an unqualified ignore grammar from this first slice. + for (const selected of [rootIgnore, specificIgnore]) { + if (selected) { + legacyComposeBuildIgnore( + new TextDecoder("utf-8", { fatal: true }).decode(selected.bytes) + ); + } + } + const ignore = legacyComposeBuildIgnore( + effective + ? new TextDecoder("utf-8", { fatal: true }).decode(effective.bytes) + : "" + ); + requireExcluded(build.context, ignore); + const nodes: Node[] = []; + async function captureNode(path: string, nodePath: string, depth: number) { + const info = await lstat(path); + if (info.isDirectory()) { + await walk(path, nodePath, depth); + return; + } + if (!info.isFile()) { + refuse(); + } + if (ignore.excluded(nodePath)) { + return; + } + const read = await pinFile(nodePath); + if (!read) { + refuse(); + } + nodes.push({ ...read.file, kind: "file" }); + } + async function walk(path: string, relative: string, depth: number) { + check(signal); + if (depth > MAX_DEPTH || ++budget.entries > MAX_ENTRIES) { + refuse(); + } + const before = await pinDirectory(path); + nodes.push({ path: relative, kind: "directory", ...before }); + const selectedNames = async () => + (await names(path, budget, signal)).filter( + (name) => + !ignore.subtreeExcluded(relative ? `${relative}/${name}` : name) + ); + const entries = await selectedNames(); + for (const name of entries) { + const selected = join(path, name); + const nodePath = relative ? `${relative}/${name}` : name; + await captureNode(selected, nodePath, depth + 1); + } + // Excluded output creation does not change the projected names. Included + // additions/removals and ancestor replacement still refuse this acquisition. + if ( + JSON.stringify(before) !== + JSON.stringify(await directory(path, signal)) || + JSON.stringify(entries) !== JSON.stringify(await selectedNames()) + ) { + refuse(); + } + } + await walk(base, "", 0); + for (const recheck of checks) { + check(signal); + await recheck(); + } + const rootIgnorePath = rootIgnore ? ".dockerignore" : null; + return { + ...build, + ancestors: [...ancestors.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([path, held]) => ({ path, ...held })), + definition: definition.file, + ignores: [ + { path: ".dockerignore", file: rootIgnore?.file ?? null }, + { path: specificPath, file: specificIgnore?.file ?? null }, + ], + effectiveIgnore: specificIgnore ? specificPath : rootIgnorePath, + nodes, + }; +} +async function capture(opts: { + readonly root: string; + readonly candidate: unknown; + readonly signal?: AbortSignal; +}): Promise { + const contexts: Context[] = []; + const budget: Budget = { entries: 0, bytes: 0, names: 0 }; + for (const build of selectedBuilds(opts.candidate)) { + contexts.push( + await captureContext({ + root: opts.root, + build, + signal: opts.signal, + budget, + }) + ); + } + const proof: LegacyComposeBuildSourceProof = { + build_source_version: 1, + contexts, + }; + if (Buffer.byteLength(JSON.stringify(proof)) > MAX_PROOF_BYTES) { + refuse(); + } + freezeImportValue(proof); + return proof; +} + +/** + * Same-issued authored acquisition plus bounded included context provenance. + * Neither this proof nor config-hash attests the image's historical build input. + * Hidden proof/candidate/callbacks authorize no builder or engine effect. + */ +export async function acquireLegacyComposeBuildSource(opts: { + readonly source: Source; + readonly signal?: AbortSignal; +}) { + try { + const { source, signal } = opts; + if (signal !== undefined && !(signal instanceof AbortSignal)) { + refuse(); + } + privateNativeConfigImportSourceProof(source); + const candidate = mapLegacyNativeRetainedBasicBuild(source).candidate; + if (!candidate) { + refuse(); + } + selectedBuilds(candidate); + await requireLayout({ root: source.projectRoot, candidate, signal }); + await source.assertFresh({ signal }); + const proof = await capture({ + root: source.projectRoot, + candidate, + signal, + }); + const assertFresh = async (current?: { readonly signal?: AbortSignal }) => { + try { + const selected = + signal && current?.signal + ? AbortSignal.any([signal, current.signal]) + : (current?.signal ?? signal); + await requireLayout({ + root: source.projectRoot, + candidate, + signal: selected, + }); + await source.assertFresh({ signal: selected }); + if ( + JSON.stringify( + await capture({ + root: source.projectRoot, + candidate, + signal: selected, + }) + ) !== JSON.stringify(proof) + ) { + refuse(); + } + await source.assertFresh({ signal: selected }); + await requireLayout({ + root: source.projectRoot, + candidate, + signal: selected, + }); + } catch { + refuse(); + } + }; + await assertFresh(); + const result = { candidate, proof, assertFresh }; + for (const key of Object.keys(result)) { + Object.defineProperty(result, key, { enumerable: false }); + } + return Object.freeze(result); + } catch { + refuse(); + } +} + +/** Key-free saved read pins included bytes/identity and optional ignore presence. */ +export async function assertSavedLegacyComposeBuildSource(opts: { + readonly projectRoot: string; + readonly configText: string; + readonly composeText: string; + readonly proof: unknown; + readonly signal?: AbortSignal; + readonly checkOwner: () => Promise; +}) { + try { + const { projectRoot, configText, composeText, signal, checkOwner } = opts; + const saved = JSON.stringify(opts.proof); + if ( + typeof saved !== "string" || + Buffer.byteLength(saved) > MAX_PROOF_BYTES + ) { + refuse(); + } + const candidate = mapLegacyNativeRetainedBasicBuild({ + configText, + composeText, + }).candidate; + if (!candidate) { + refuse(); + } + await checkOwner(); + await requireLayout({ root: projectRoot, candidate, signal }); + if ( + JSON.stringify( + await capture({ root: projectRoot, candidate, signal }) + ) !== saved + ) { + refuse(); + } + await checkOwner(); + await requireLayout({ root: projectRoot, candidate, signal }); + } catch { + refuse(); + } +} diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index 3dd227b5f..dfc31e5c0 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -293,7 +293,10 @@ export async function tryLegacyComposeAdoptedCommand( deadline, run: async (privateInput) => { cancelled(signal); - if (legacyComposeRetainedOrdered(privateInput.retainedPlan)) { + if ( + legacyComposeRetainedOrdered(privateInput.retainedPlan) || + privateInput.retainedBuild + ) { return await runLegacyComposeRetainedOperation({ input: privateInput, operation, diff --git a/src/lib/native-compose-adoption-execution.ts b/src/lib/native-compose-adoption-execution.ts index f17436ec3..3cfa1c3a3 100644 --- a/src/lib/native-compose-adoption-execution.ts +++ b/src/lib/native-compose-adoption-execution.ts @@ -166,6 +166,7 @@ export function consumeLegacyComposeJobCompletion(opts: { */ export async function executeLegacyComposeRetainedPlan(opts: { readonly plan: LegacyComposeRetainedPlan; + readonly retainedBuild?: true; readonly binding: LegacyComposeVerifiedBinding; readonly operation: AdoptionOperation; readonly deadline: number; @@ -180,6 +181,9 @@ export async function executeLegacyComposeRetainedPlan(opts: { remainingMs: number ) => Promise; }): Promise { + if (opts.retainedBuild && (opts.plan.requiresV5 || opts.plan.requiresV7)) { + refuse(); + } if (opts.plan.requiresV7) { return await executeLegacyComposeRetainedJobs(opts); } @@ -192,12 +196,13 @@ export async function executeLegacyComposeRetainedPlan(opts: { assertFresh, observe, effect, + retainedBuild, } = opts; const ids = new Map( binding.containers.map((container) => [container.service, container.id]) ); if ( - !plan.requiresV5 || + !(plan.requiresV5 || retainedBuild) || ids.size !== binding.containers.length || plan.ordered.length !== ids.size || plan.ordered.some((service) => !ids.has(service.service)) || @@ -422,6 +427,7 @@ export async function runLegacyComposeRetainedOperation(opts: { readonly input: { readonly binding: LegacyComposeVerifiedBinding; readonly retainedPlan: LegacyComposeRetainedPlan; + readonly retainedBuild?: true; readonly assertFresh: () => Promise; }; readonly operation: AdoptionOperation; @@ -432,6 +438,7 @@ export async function runLegacyComposeRetainedOperation(opts: { const admitted: Parameters[0] = Object.freeze({ plan: input.retainedPlan, + retainedBuild: input.retainedBuild, binding: input.binding, operation, deadline, diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 9f862e6d1..b45fca7f5 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -4,10 +4,12 @@ import { link, lstat, mkdir, rename, unlink } from "node:fs/promises"; import { join, resolve } from "node:path"; import { isRecord } from "./guards.ts"; import { - acquireLegacyComposeAdoptionBinding, + acquireLegacyComposeAdoptionPreparationBinding, inspectLegacyComposeAdoptionResources, type LegacyComposeVerifiedBinding, } from "./native-compose-adoption-binding.ts"; +import { assertSavedLegacyComposeBuildSource } from "./native-compose-adoption-build.ts"; +import { inspectLegacyComposeRetainedBuildImages } from "./native-compose-adoption-build-images.ts"; import { acquireLegacyComposeAdoptionCheckout } from "./native-compose-adoption-checkout.ts"; import { admitLegacyComposeCandidate } from "./native-compose-adoption-compiler.ts"; import { @@ -26,7 +28,10 @@ import { legacyComposeFreshJobResult, legacyComposeJobStates, } from "./native-compose-adoption-jobs.ts"; -import { planLegacyComposeAdoption } from "./native-compose-adoption-plan.ts"; +import { + planLegacyComposeAdoption, + planLegacyComposeRetainedBasicBuildAdoption, +} from "./native-compose-adoption-plan.ts"; import { readSavedLegacyComposeAdoptionProjection } from "./native-compose-adoption-projection.ts"; import { type LegacyComposeRetainedPlan, @@ -75,6 +80,7 @@ import { import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, + mapLegacyNativeRetainedBasicBuild, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; import type { NativeProjectEnvMetadata } from "./project-env-config.ts"; @@ -94,13 +100,14 @@ const ROUTING = [ "HACK_EXECUTION_MODE", ] as const; type SavedManifest = { - readonly adoption_generation_version: 1 | 3 | 4 | 5 | 6 | 7 | 10 | 11; + readonly adoption_generation_version: 1 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11; readonly kind: typeof KIND; readonly projectRoot: string; readonly id: string; readonly binding: unknown; readonly runtimeConfig: unknown; readonly projectionProof?: unknown; + readonly buildProof?: { readonly source: unknown; readonly images: unknown }; readonly sourceFiles: { readonly config: NativeConfigImportSourceIdentity; readonly compose: NativeConfigImportSourceIdentity; @@ -120,6 +127,8 @@ type PrivateInputs = { readonly candidateText: string; readonly binding: LegacyComposeVerifiedBinding; readonly projectionMetadata?: NativeProjectEnvMetadata; + /** Private version9 policy; never serialized into a compiler report or receipt label. */ + readonly retainedBuild?: true; }; type MutationInputs = PrivateInputs & { /** Issued while the journal and mutation lock are held; valid only during this callback. */ @@ -231,22 +240,32 @@ function sourceFileIdentity( value.uid === process.getuid?.() ); } +function manifestFieldKeys(value: Record) { + if (value.adoption_generation_version === 9) { + return "adoption_generation_version,binding,buildProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; + } + const projected = + (value.adoption_generation_version === 5 && + Object.hasOwn(value, "projectionProof")) || + value.adoption_generation_version === 3 || + value.adoption_generation_version === 4; + return projected + ? "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,runtimeConfig,sourceFiles" + : "adoption_generation_version,binding,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; +} function manifest(value: unknown, root: string, id: string): SavedManifest { if ( !( isRecord(value) && - keys( - value, - (value.adoption_generation_version === 5 && - Object.hasOwn(value, "projectionProof")) || - value.adoption_generation_version === 3 || - value.adoption_generation_version === 4 - ? "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,runtimeConfig,sourceFiles" - : "adoption_generation_version,binding,files,id,kind,projectRoot,runtimeConfig,sourceFiles" - ) && + keys(value, manifestFieldKeys(value)) && (value.adoption_generation_version === 1 || value.adoption_generation_version === 7 || value.adoption_generation_version === 6 || + (value.adoption_generation_version === 9 && + isRecord(value.buildProof) && + keys(value.buildProof, "images,source") && + isRecord(value.buildProof.source) && + Array.isArray(value.buildProof.images)) || value.adoption_generation_version === 11 || value.adoption_generation_version === 10 || (value.adoption_generation_version === 5 && @@ -282,6 +301,14 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { id, binding: value.binding, runtimeConfig: value.runtimeConfig, + ...(value.adoption_generation_version === 9 && isRecord(value.buildProof) + ? { + buildProof: { + source: value.buildProof.source, + images: value.buildProof.images, + }, + } + : {}), ...((value.adoption_generation_version === 5 && Object.hasOwn(value, "projectionProof")) || value.adoption_generation_version === 3 || @@ -415,6 +442,27 @@ type Context = { { readonly info: Stats; readonly text: string } >; }; +async function assertRetainedBuildSource(opts: { + readonly ctx: Context; + readonly meta: SavedManifest; + readonly configText: string; + readonly composeText: string; +}) { + if (opts.meta.adoption_generation_version !== 9) { + return; + } + if (!opts.meta.buildProof) { + refuse(); + } + await assertSavedLegacyComposeBuildSource({ + projectRoot: opts.ctx.root, + configText: opts.configText, + composeText: opts.composeText, + proof: opts.meta.buildProof.source, + signal: opts.ctx.signal, + checkOwner: opts.ctx.check, + }); +} function requireRetainedGenerationVersion( meta: SavedManifest, retainedPlan: LegacyComposeRetainedPlan, @@ -468,6 +516,10 @@ async function requireSelectedTopologyOwner(opts: { (!custom && (meta.binding.binding_version === 3 || meta.binding.binding_version === 4)) || + (meta.adoption_generation_version === 9 && + (meta.binding.binding_version !== 1 || + meta.projectionProof !== undefined || + requiresV5)) || (!plural && (meta.binding.binding_version === 5 || meta.binding.binding_version === 6)) @@ -483,6 +535,8 @@ async function requireSelectedTopologyOwner(opts: { (state.adoption_receipt_version === 11) !== plural || (state.adoption_receipt_version === 5) !== healthOnly || (state.adoption_receipt_version === 10) !== bridgeAndHealth || + (state.adoption_receipt_version === 9) !== + (meta.adoption_generation_version === 9) || JSON.stringify(state.prepared) !== JSON.stringify(selected) ) { refuse(); @@ -520,8 +574,18 @@ async function readInputs( join(generationRoot, "candidate.json"), meta.files.candidate ); - const mapped = mapLegacyNativeStorageAdoption({ configText, composeText }); - const planned = planLegacyComposeAdoption({ configText, composeText }); + const basic = meta.adoption_generation_version === 9; + const mapped = ( + basic ? mapLegacyNativeRetainedBasicBuild : mapLegacyNativeStorageAdoption + )({ configText, composeText }); + const planned = ( + basic + ? planLegacyComposeRetainedBasicBuildAdoption + : planLegacyComposeAdoption + )({ configText, composeText }); + const assertBuildSource = () => + assertRetainedBuildSource({ ctx, meta, configText, composeText }); + await assertBuildSource(); const retainedPlan = legacyComposeRetainedPlan(JSON.parse(candidateText)); await requireSelectedTopologyOwner({ ctx, @@ -592,6 +656,19 @@ async function readInputs( if (JSON.stringify(meta.runtimeConfig) !== JSON.stringify(runtimeConfig)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } + if ( + basic && + JSON.stringify( + await inspectLegacyComposeRetainedBuildImages({ + binding: observed, + composeFile: join(generationRoot, "legacy-compose.yml"), + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }) + ) !== JSON.stringify(meta.buildProof?.images) + ) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } await readArtifact( join(generationRoot, "manifest.json"), selected.manifest, @@ -613,6 +690,7 @@ async function readInputs( if (projection) { await readSavedLegacyComposeAdoptionProjection(projectionOpts); } + await assertBuildSource(); await ctx.check(); freezeImportValue(observed); return { @@ -623,6 +701,7 @@ async function readInputs( candidateText, binding: observed, ...(projection ? { projectionMetadata: projection.metadata } : {}), + ...(basic ? { retainedBuild: true as const } : {}), }), }; } finally { @@ -723,11 +802,31 @@ function manifestVersion( } return projection.projectionProof.projection_version === 2 ? 4 : 3; } +function preparedManifestVersion(opts: { + readonly build: boolean; + readonly retainedPlan: LegacyComposeRetainedPlan; + readonly binding: LegacyComposeVerifiedBinding; + readonly projection?: { + readonly projectionProof: { readonly projection_version: number }; + }; +}): Manifest["adoption_generation_version"] { + if (opts.build) { + return 9; + } + if (opts.retainedPlan.requiresV7) { + return 7; + } + return manifestVersion( + opts.binding, + opts.retainedPlan.requiresV5, + opts.projection + ); +} async function prepare( ctx: Context, binary: string | undefined ): Promise { - const binding = await acquireLegacyComposeAdoptionBinding({ + const binding = await acquireLegacyComposeAdoptionPreparationBinding({ projectRoot: ctx.root, signal: ctx.signal, timeoutMs: ctx.timeoutMs, @@ -748,7 +847,11 @@ async function prepare( ) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } - const mapped = mapLegacyNativeStorageAdoption(acquired); + const mapped = ( + acquired.build + ? mapLegacyNativeRetainedBasicBuild + : mapLegacyNativeStorageAdoption + )(acquired); if (!mapped.candidate) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } @@ -757,10 +860,15 @@ async function prepare( ); const retainedPlan = legacyComposeRetainedPlan(JSON.parse(candidateText)); if ( - retainedPlan.requiresV7 && - (acquired.projection || - acquired.binding.binding_version !== 1 || - !legacyComposeAdoptionCandidateSupported(JSON.parse(candidateText))) + (acquired.build && + (acquired.binding.binding_version !== 1 || + acquired.projection !== undefined || + retainedPlan.requiresV5 || + retainedPlan.requiresV7)) || + (retainedPlan.requiresV7 && + (acquired.projection || + acquired.binding.binding_version !== 1 || + !legacyComposeAdoptionCandidateSupported(JSON.parse(candidateText)))) ) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } @@ -801,17 +909,17 @@ async function prepare( await originals.file.sync(); await originals.file.close(); const meta: Manifest = { - adoption_generation_version: retainedPlan.requiresV7 - ? 7 - : manifestVersion( - acquired.binding, - retainedPlan.requiresV5, - acquired.projection - ), + adoption_generation_version: preparedManifestVersion({ + build: Boolean(acquired.build), + retainedPlan, + binding: acquired.binding, + projection: acquired.projection, + }), kind: KIND, projectRoot: ctx.root, id, binding: acquired.binding, + ...(acquired.build ? { buildProof: acquired.build } : {}), ...(acquired.projection ? { projectionProof: acquired.projection.projectionProof } : {}), @@ -1421,7 +1529,8 @@ function requireMutationDeadline( plan: LegacyComposeRetainedPlan ) { if ( - legacyComposeRetainedOrdered(plan) && + (legacyComposeRetainedOrdered(plan) || + captured.generation.report.adoption_generation_version === 9) && (captured.deadline === undefined || !Number.isFinite(captured.deadline) || captured.deadline <= Date.now()) @@ -1430,7 +1539,7 @@ function requireMutationDeadline( } } -/** V5 reacquisitions share the same remaining clock; old receipt owners keep their prior budgets. */ +/** New proof owners share one remaining clock; old receipt owners keep their prior budgets. */ function boundedMutationContext(ctx: Context, deadline: number) { function remaining() { cancelled(ctx.signal); @@ -1474,11 +1583,11 @@ function preparedReceiptVersion( if (version !== 1) { return version; } - // A rolled-back dependency or topology owner must not label a later plain - // generation with its superseded contract. + // A rolled-back proof owner must not label a later plain generation with its version. if ( prior.adoption_receipt_version === 5 || prior.adoption_receipt_version === 6 || + prior.adoption_receipt_version === 9 || prior.adoption_receipt_version === 7 || prior.adoption_receipt_version === 10 || prior.adoption_receipt_version === 11 @@ -1555,7 +1664,9 @@ async function mutateRetainedContainers( refuse(); } if ( - ![5, 7, 10].includes(captured.generation.report.adoption_generation_version) + ![5, 7, 9, 10].includes( + captured.generation.report.adoption_generation_version + ) ) { return await mutateRetainedContainersWithinBudget( original, @@ -1640,7 +1751,8 @@ async function mutateRetainedContainersWithinBudget( JSON.parse(loaded.inputs.candidateText) ); if ( - legacyComposeRetainedOrdered(retainedPlan) && + (legacyComposeRetainedOrdered(retainedPlan) || + loaded.inputs.retainedBuild) && JSON.stringify([...selectedServices].sort()) !== JSON.stringify([...services].sort()) ) { @@ -1693,6 +1805,7 @@ async function mutateRetainedContainersWithinBudget( ? { projectionMetadata: loaded.inputs.projectionMetadata } : {}), retainedPlan, + ...(loaded.inputs.retainedBuild ? { retainedBuild: true as const } : {}), assertFresh: async () => { if (!callbackOpen) { refuse(); diff --git a/src/lib/native-compose-adoption-plan.ts b/src/lib/native-compose-adoption-plan.ts index 328962cd0..4ddf0caaa 100644 --- a/src/lib/native-compose-adoption-plan.ts +++ b/src/lib/native-compose-adoption-plan.ts @@ -8,6 +8,7 @@ import { freezeImportValue, mapLegacyNativeAdoptionBaseline, mapLegacyNativeCompletedJobAdoptionBaseline, + mapLegacyNativeRetainedBasicBuild, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; import { @@ -78,6 +79,21 @@ export function planLegacyComposeAdoption(opts: { const baseline = jobs ? mapLegacyNativeCompletedJobAdoptionBaseline(opts) : mapLegacyNativeAdoptionBaseline(opts); + return plan(opts, baseline); +} + +/** Pure closed build/storage intent only; the distinct source/image owner must still admit it. */ +export function planLegacyComposeRetainedBasicBuildAdoption(opts: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeAdoptionPlan { + return plan(opts, mapLegacyNativeRetainedBasicBuild(opts)); +} + +function plan( + opts: { readonly configText: string; readonly composeText: string }, + baseline: ReturnType +): LegacyComposeAdoptionPlan { const config = parseImportDocument({ text: opts.configText, document: "config", diff --git a/src/lib/native-compose-adoption-preview.ts b/src/lib/native-compose-adoption-preview.ts index b9641dd98..d9d05aae8 100644 --- a/src/lib/native-compose-adoption-preview.ts +++ b/src/lib/native-compose-adoption-preview.ts @@ -1,4 +1,4 @@ -import { acquireLegacyComposeAdoptionBinding } from "./native-compose-adoption-binding.ts"; +import { acquireLegacyComposeAdoptionPreparationBinding } from "./native-compose-adoption-binding.ts"; import { admitLegacyComposeCandidate } from "./native-compose-adoption-compiler.ts"; import { legacyComposeAdoptionCandidateSupported, @@ -12,6 +12,7 @@ import { import type { ImportField } from "./native-config-import-parser.ts"; import { freezeImportValue, + mapLegacyNativeRetainedBasicBuild, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; @@ -39,9 +40,13 @@ export async function previewLegacyComposeAdoption(input: { const opts = { ...input }; let fields: readonly ImportField[] = []; try { - const owner = await acquireLegacyComposeAdoptionBinding(opts), + const owner = await acquireLegacyComposeAdoptionPreparationBinding(opts), acquired = await owner.resolvePreparationInputs(opts); - const mapped = mapLegacyNativeStorageAdoption(acquired); + const mapped = ( + acquired.build + ? mapLegacyNativeRetainedBasicBuild + : mapLegacyNativeStorageAdoption + )(acquired); fields = [ ...mapped.report.fields, ...(acquired.projection?.localFields ?? []), diff --git a/src/lib/native-compose-adoption-receipt.ts b/src/lib/native-compose-adoption-receipt.ts index f9e5aa8fd..8a8b75e1b 100644 --- a/src/lib/native-compose-adoption-receipt.ts +++ b/src/lib/native-compose-adoption-receipt.ts @@ -17,7 +17,7 @@ export type Checkout = { }; export type Anchor = { readonly id: string; readonly manifest: Artifact }; export type Receipt = { - readonly adoption_receipt_version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11; + readonly adoption_receipt_version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11; readonly kind: typeof KIND; readonly checkout: Checkout; readonly prepared: Anchor | null; @@ -79,6 +79,7 @@ export function parseLegacyComposeAdoptionReceipt( ) && (value.adoption_receipt_version === 11 || value.adoption_receipt_version === 10 || + value.adoption_receipt_version === 9 || value.adoption_receipt_version === 7 || value.adoption_receipt_version === 6 || value.adoption_receipt_version === 5 || @@ -122,6 +123,7 @@ export function parseLegacyComposeAdoptionReceipt( adoption_receipt_version: version === 11 || version === 10 || + version === 9 || version === 7 || version === 6 || version === 5 || diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index fe5324014..69eefd6aa 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -141,6 +141,7 @@ type NativeImportPurpose = | "preview" | "adoption-baseline" | "completed-job-adoption" + | "retained-basic-build" | "storage-adoption"; /** @@ -195,8 +196,11 @@ function mapLegacyNativeInput(opts: { candidate, mark, refuse, - buildPreview: opts.purpose === "preview", - jobPreview: opts.purpose !== "adoption-baseline", + buildPreview: + opts.purpose === "preview" || opts.purpose === "retained-basic-build", + jobPreview: + opts.purpose !== "adoption-baseline" && + opts.purpose !== "retained-basic-build", }); mapOwnedNetwork({ project: name, @@ -209,7 +213,10 @@ function mapLegacyNativeInput(opts: { if (opts.purpose === "preview") { mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); } - if (opts.purpose === "storage-adoption") { + if ( + opts.purpose === "storage-adoption" || + opts.purpose === "retained-basic-build" + ) { mapStorageCandidate({ config: config.value, compose: compose.value, @@ -242,6 +249,14 @@ function mapOwnedNetwork( opts.refuse("compose", mapping.pointer, mapping.code); return; } + if (opts.purpose === "retained-basic-build") { + opts.refuse( + "compose", + "/networks", + "retained_build_network_adoption_unqualified" + ); + return; + } if ( opts.purpose !== "preview" && isRecord(opts.candidate.jobs) && @@ -359,6 +374,22 @@ export function mapLegacyNativeStorageAdoption(opts: { }); } +/** + * Private closed build-and-storage intent for the retained original-image owner. + * This does not grant adoption or build authority. The image-only baseline and + * ordinary storage mapper deliberately retain their previous refusals. + */ +export function mapLegacyNativeRetainedBasicBuild(opts: { + readonly configText: string; + readonly composeText: string; +}): NativeImportPlan { + return mapLegacyNativeInput({ + configText: opts.configText, + composeText: opts.composeText, + purpose: "retained-basic-build", + }); +} + type FileMappingContext = Pick; function mapFileDeclarations( diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 49a97c02d..8d72de307 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -16,6 +16,7 @@ import { lifecycleHostProcessScenario } from "./scenarios/lifecycle-host-process import { lifecycleSessionRecoveryScenario } from "./scenarios/lifecycle-session-recovery.ts"; import { nativeComposeAdoptionJobWorktreesScenario } from "./scenarios/native-compose-adoption-job-worktrees.ts"; import { + nativeComposeAdoptionBuildWorktreesScenario, nativeComposeAdoptionDependencyWorktreesScenario, nativeComposeAdoptionLocalWorktreesScenario, nativeComposeAdoptionManagedWorktreesScenario, @@ -94,6 +95,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ nativeComposeAdoptionManagedWorktreesScenario, nativeComposeAdoptionLocalWorktreesScenario, nativeComposeAdoptionDependencyWorktreesScenario, + nativeComposeAdoptionBuildWorktreesScenario, nativeComposeAdoptionJobWorktreesScenario, lifecycleHostProcessScenario, worktreeParallelUpScenario, diff --git a/tests/e2e/scenarios/native-compose-adoption-build-diagnostics.ts b/tests/e2e/scenarios/native-compose-adoption-build-diagnostics.ts new file mode 100644 index 000000000..cacdd9232 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-build-diagnostics.ts @@ -0,0 +1,178 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import type { CliResult } from "../harness.ts"; + +const CODES = [ + "E_CONFIG_INVALID", + "E_LIFECYCLE_FAILED", + "E_STATE", + "E_NATIVE_COMPOSE_ADOPTION", + "E_NATIVE_COMPOSE_OWNERSHIP", + "E_NATIVE_COMPOSE_PROBE", + "E_NATIVE_COMPOSE_PROBE_TIMEOUT", +] as const; +type Context = { + readonly tempRoot: string; + readonly log?: (message: string) => void; +}; +type Outcome = Pick; +const owners = new WeakMap>(); + +function operation(args: readonly string[], drift: boolean) { + const joined = JSON.stringify(args); + const operations = [ + [["config", "adopt", "--dry-run", "--stop", "--json"], "preview"], + [["config", "adopt", "--stop", "--json"], "adopt-stop"], + [ + ["config", "adopt", "--recover", "--stop", "--json"], + "adopt-recover-stop", + ], + [["config", "adopt", "--rollback", "--json"], "rollback"], + [["up", "db", "--detach", "--json"], "partial-selection"], + [ + ["up", "--detach", "--json"], + drift ? "start-with-context-drift" : "start", + ], + [["down", "--recover", "--json"], "recover-stop"], + [["down", "--json"], "stop"], + [["run", "db", "--", "true"], "run-refusal"], + ] as const; + return ( + operations.find(([expected]) => JSON.stringify(expected) === joined)?.[1] ?? + "unavailable" + ); +} + +function code(stdout: string) { + if (Buffer.byteLength(stdout) > 65_536) { + return "unavailable"; + } + try { + const value: unknown = JSON.parse(stdout); + if (!isRecord(value)) { + return "unavailable"; + } + if (value.ok === true) { + return "none"; + } + if (value.ok === false && isRecord(value.error)) { + const error = value.error; + return CODES.find((known) => known === error.code) ?? "unavailable"; + } + return "unavailable"; + } catch { + return "unavailable"; + } +} +function outcome(result: Outcome) { + try { + const exitCode = + Number.isSafeInteger(result.exitCode) && + result.exitCode >= 0 && + result.exitCode <= 255 + ? result.exitCode + : "unavailable"; + return { + exitCode, + timedOut: result.timedOut === true, + code: code(result.stdout), + readGuardRefused: result.stderr.includes( + "retained-build-refused stage=read-admission code=93" + ), + mutationGuardRefused: result.stderr.includes( + "retained-build-refused stage=mutation-admission code=94" + ), + }; + } catch { + return { + exitCode: "unavailable", + timedOut: "unavailable", + code: "unavailable", + readGuardRefused: "unavailable", + mutationGuardRefused: "unavailable", + }; + } +} +function recorder(context: Context) { + const root = join(context.tempRoot, "retained-build-cli"); + const started = performance.now(); + let count = 0; + let invocation = 0; + return async (opts: { + readonly mode: unknown; + readonly args: readonly string[]; + readonly driftAfterStart: boolean; + readonly run: () => Promise; + }): Promise => { + const ordinal = ++invocation; + const mode = + opts.mode === "root-specific" || opts.mode === "hack-default" + ? opts.mode + : "unavailable"; + const selected = operation(opts.args, opts.driftAfterStart); + const emit = async ( + stage: "begin" | "result" | "settled" | "thrown", + detail?: ReturnType + ) => { + const row = { + evidence_version: 1, + invocation: ordinal, + mode, + operation: selected, + stage, + elapsedMs: Math.round(performance.now() - started), + ...detail, + }; + try { + const index = ++count; + if (index > 512) { + return; + } + await mkdir(root, { mode: 0o700, recursive: true }); + await writeFile( + join(root, `${String(index).padStart(4, "0")}.json`), + `${JSON.stringify(row)}\n`, + { mode: 0o600, flag: "wx" } + ); + } catch { + // Evidence failure cannot replace the original result or error. + } + try { + context.log?.(`retained-build-cli ${JSON.stringify(row)}`); + } catch { + // Logging cannot skip cleanup or change admission. + } + }; + await emit("begin"); + try { + const result = await opts.run(); + await emit("result", outcome(result)); + await emit("settled"); + return result; + } catch (error) { + await emit("thrown"); + throw error; + } + }; +} + +/** + * Fixture-only fixed CLI substages and bounded exit/error classifications. No + * argv, paths, response values or arbitrary errors are retained. Diagnostic + * failure preserves the original result/error and never changes an effect gate. + */ +export async function observeRetainedBuildFixtureCli(opts: { + readonly context: Context; + readonly mode: unknown; + readonly args: readonly string[]; + readonly driftAfterStart: boolean; + readonly run: () => Promise; +}): Promise { + let owner = owners.get(opts.context); + if (!owner) { + owner = recorder(opts.context); + owners.set(opts.context, owner); + } + return await owner(opts); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-build-evidence.ts b/tests/e2e/scenarios/native-compose-adoption-build-evidence.ts new file mode 100644 index 000000000..7d1a7cd69 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-build-evidence.ts @@ -0,0 +1,157 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { + assertRetainedBuildFixtureCopy, + type RetainedBuildFixtureMode, +} from "./native-compose-adoption-build-inputs.ts"; + +export const RETAINED_BUILD_BOOTSTRAP_ENV = Object.freeze({ + DOCKER_BUILDKIT: "1", + BUILDX_BUILDER: "default", +}); +export const RETAINED_BUILD_BUILDER_FORMAT = + '{{if eq .DriverEndpoint "docker"}}{"name":{{json .Builder.Name}},"driver":{{json .DriverEndpoint}},"nodes":[{{range $i,$n := .Builder.Nodes}}{{if $i}},{{end}}{"name":{{json $n.Name}},"endpoint":{{json $n.Endpoint}}}{{end}}]}{{end}}'; +type Stage = + | "builder-begin" + | "builder-qualified" + | "build-begin" + | "build-settled" + | "image-admission-begin" + | "image-admitted" + | "graph-admitted" + | "original-start-begin" + | "original-start-settled" + | "original-ids-captured" + | "readiness-begin" + | "readiness-qualified" + | "sql-begin" + | "sql-qualified" + | "full-check-begin" + | "full-check-qualified" + | "source-recheck-begin" + | "source-rechecked" + | "runtime-image-begin" + | "runtime-image-rechecked" + | "copy-begin" + | "copy-captured" + | "copy-qualified"; +export type RetainedBuildFixtureEvidence = ( + mode: RetainedBuildFixtureMode, + stage: Stage, + bytes?: Uint8Array +) => Promise; + +function refuse(): never { + throw new Error("Retained build fixture evidence refused; values omitted."); +} + +/** Fixed stages and complete synthetic COPY bytes only; no subprocess argv, arbitrary errors or host environment. */ +export function createRetainedBuildFixtureEvidence(opts: { + readonly tempRoot: string; +}): RetainedBuildFixtureEvidence { + const root = join(opts.tempRoot, "retained-build-stages"); + let count = 0; + const started = performance.now(); + return async (mode, stage, bytes) => { + if ( + ++count > 512 || + (bytes !== undefined && + (stage !== "copy-captured" || bytes.byteLength > 16_384)) + ) { + refuse(); + } + await mkdir(root, { mode: 0o700, recursive: true }); + await writeFile( + join(root, `${String(count).padStart(4, "0")}.json`), + JSON.stringify({ + evidence_version: 1, + mode, + stage, + elapsedMs: Math.round(performance.now() - started), + ...(bytes === undefined + ? {} + : { + byteLength: bytes.byteLength, + copyBase64: Buffer.from(bytes).toString("base64"), + }), + }), + { mode: 0o600, flag: "wx" } + ); + }; +} + +/** Select only the engine's default Docker driver; no builder creation, bootstrap, remote driver or fallback. */ +export async function qualifyRetainedBuildFixtureBuilder(opts: { + readonly mode: RetainedBuildFixtureMode; + readonly read: (args: readonly string[]) => Promise; + readonly record: RetainedBuildFixtureEvidence; + readonly env: Readonly>; +}): Promise { + await opts.record(opts.mode, "builder-begin"); + if ( + opts.env.DOCKER_BUILDKIT !== "1" || + opts.env.BUILDX_BUILDER !== "default" || + opts.env.DOCKER_CONTEXT !== undefined || + !opts.env.DOCKER_HOST?.startsWith("unix:///") + ) { + refuse(); + } + const version = await opts.read(["buildx", "version"]); + if ( + !/^github\.com\/docker\/buildx v\d+\.\d+\.\d+(?:[-+][\w.-]+)? [a-f0-9]+\s*$/.test( + version + ) + ) { + refuse(); + } + const text = await opts.read([ + "buildx", + "ls", + "--timeout", + "10s", + "--format", + RETAINED_BUILD_BUILDER_FORMAT, + ]); + const rows = text.split("\n").filter((line) => line.trim()); + if (rows.length !== 1) { + refuse(); + } + let value: unknown; + try { + value = JSON.parse(rows[0] ?? ""); + } catch { + refuse(); + } + if ( + !isRecord(value) || + Object.keys(value).sort().join() !== "driver,name,nodes" || + value.name !== "default" || + value.driver !== "docker" || + !Array.isArray(value.nodes) || + value.nodes.length !== 1 || + !isRecord(value.nodes[0]) || + Object.keys(value.nodes[0]).sort().join() !== "endpoint,name" || + value.nodes[0].name !== "default" || + !( + value.nodes[0].endpoint === "default" || + value.nodes[0].endpoint === opts.env.DOCKER_HOST + ) + ) { + refuse(); + } + await opts.record(opts.mode, "builder-qualified"); +} + +/** Persist the untrimmed, fatal-decoded reply before the unchanged complete COPY oracle can refuse. */ +export async function qualifyRetainedBuildFixtureCopy(opts: { + readonly mode: RetainedBuildFixtureMode; + readonly read: () => Promise; + readonly record: RetainedBuildFixtureEvidence; +}): Promise { + await opts.record(opts.mode, "copy-begin"); + const text = await opts.read(); + await opts.record(opts.mode, "copy-captured", new TextEncoder().encode(text)); + assertRetainedBuildFixtureCopy({ mode: opts.mode, text }); + await opts.record(opts.mode, "copy-qualified"); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts new file mode 100644 index 000000000..d7440b26b --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-build-inputs.ts @@ -0,0 +1,606 @@ +import { lstat, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { adoptionDependencyReadAllowed } from "./native-compose-adoption-dependency-inputs.ts"; +import { + type AdoptionDependencyFirstPrepare, + adoptionDependencyStagedReadAllowed, +} from "./native-compose-adoption-dependency-staged-read.ts"; + +export type RetainedBuildFixtureMode = "root-specific" | "hack-default"; +export const RETAINED_BUILD_BASE_TAG = "postgres:17.6-alpine"; +export const RETAINED_BUILD_IMAGE_OWNER = "hack.e2e.retained-build.owner"; +export const RETAINED_BUILD_IMAGE_FORMAT = `{"id":{{json .Id}},"created":{{json .Created}},"owner":{{json (index .Config.Labels "${RETAINED_BUILD_IMAGE_OWNER}")}},"stage":{{json (index .Config.Labels "hack.e2e.retained-build.stage")}},"tags":{{json .RepoTags}},"digests":{{json .RepoDigests}}}`; +export const RETAINED_BUILD_OBJECT_FORMAT = `{"id":{{json .Id}},"parent":{{$parent := ""}}{{range $key, $value := .}}{{if eq $key "Parent"}}{{$parent = $value}}{{end}}{{end}}{{json $parent}},"created":{{json .Created}},"size":{{json .Size}},"owner":{{json (index .Config.Labels "${RETAINED_BUILD_IMAGE_OWNER}")}},"stage":{{json (index .Config.Labels "hack.e2e.retained-build.stage")}},"tags":{{json .RepoTags}},"digests":{{json .RepoDigests}},"composeProject":{{json (index .Config.Labels "com.docker.compose.project")}},"composeService":{{json (index .Config.Labels "com.docker.compose.service")}},"composeVersion":{{json (index .Config.Labels "com.docker.compose.version")}},"labelNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}]}`; +const ID = /^[a-f0-9]{64}$/; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const BIRTH = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const TOKEN = /^[a-f0-9]{32}$/; +const CONTAINER_IMAGE_FORMAT = + '{"id":{{json .Id}},"image":{{json .Image}},"reference":{{json .Config.Image}},"createdAt":{{json .Created}}}'; +const IMAGE_BIRTH_FORMAT = '{"id":{{json .Id}},"createdAt":{{json .Created}}}'; + +function refuse(): never { + throw new Error( + "Retained build fixture source or ownership refused; values omitted." + ); +} +function hash(bytes: string | Uint8Array) { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); +} +function marker(mode: RetainedBuildFixtureMode) { + return `retained-build-${mode}\n`; +} +/** Closed authored definition: root/specific/target and default .hack are independently exercised. */ +export function retainedBuildFixtureDefinition(mode: RetainedBuildFixtureMode) { + return mode === "root-specific" + ? { + context: "..", + dockerfile: ".hack/toolchain/Dockerfile", + target: "retained", + } + : {}; +} +export function retainedBuildFixtureMarker( + root: string, + mode: RetainedBuildFixtureMode +) { + return join( + root, + mode === "root-specific" ? ".hack/toolchain/run.sh" : ".hack/data-marker" + ); +} +export function retainedBuildFixtureCopiedFiles( + mode: RetainedBuildFixtureMode +) { + return mode === "root-specific" + ? [ + { path: ".hack/toolchain/run.sh", hash: hash(marker(mode)) }, + { path: "mise.toml", hash: hash("# synthetic retained build input\n") }, + ] + : [{ path: "data-marker", hash: hash(marker(mode)) }]; +} +/** The real builder's complete copied file list and hashes must equal this explicit fixture oracle. */ +export function assertRetainedBuildFixtureCopy(opts: { + readonly mode: RetainedBuildFixtureMode; + readonly text: string; +}) { + const expected = retainedBuildFixtureCopiedFiles(opts.mode) + .map(({ path, hash: digest }) => `${path} ${digest}`) + .sort() + .join("\n"); + if (opts.text.trim() !== expected) { + refuse(); + } +} +export const RETAINED_BUILD_COPY_ORACLE = + 'set -eu; cd /nc04-context; find . -type f | LC_ALL=C sort | while IFS= read -r p; do h=$(sha256sum "$p"); printf \'%s %s\\n\' "${p#./}" "${h%% *}"; done'; + +/** Fixture authoring only. These files live outside all managed-output directories. */ +export async function prepareRetainedBuildFixtureSources(opts: { + readonly root: string; + readonly name: string; + readonly mode: RetainedBuildFixtureMode; +}) { + if (!NAME.test(opts.name)) { + refuse(); + } + const rootMode = opts.mode === "root-specific"; + const context = rootMode ? opts.root : join(opts.root, ".hack"); + const definition = rootMode ? ".hack/toolchain/Dockerfile" : "Dockerfile"; + await mkdir(join(opts.root, ".hack/toolchain"), { recursive: true }); + await Bun.write( + retainedBuildFixtureMarker(opts.root, opts.mode), + marker(opts.mode) + ); + const dockerfile = [ + `FROM ${RETAINED_BUILD_BASE_TAG} AS retained`, + `LABEL ${RETAINED_BUILD_IMAGE_OWNER}="${opts.name}" hack.e2e.retained-build.stage="retained"`, + "COPY . /nc04-context", + ...(rootMode + ? [ + `FROM ${RETAINED_BUILD_BASE_TAG} AS wrong-final-stage`, + "LABEL hack.e2e.retained-build.stage=wrong", + ] + : []), + "", + ].join("\n"); + await Bun.write(join(context, definition), dockerfile); + if (rootMode) { + await Bun.write( + join(opts.root, "mise.toml"), + "# synthetic retained build input\n" + ); + // The shadowed root ignore deliberately selects a different file. Both raw + // inputs remain privately pinned, while only the specific rules feed COPY. + await Bun.write(join(opts.root, "wrong-marker"), "shadowed-root-ignore\n"); + await Bun.write(join(opts.root, ".dockerignore"), "**\n!wrong-marker\n"); + await Bun.write( + join(context, `${definition}.dockerignore`), + [ + "**", + "!mise.toml", + "!.hack", + "!.hack/toolchain", + "!.hack/toolchain/Dockerfile", + "!.hack/toolchain/run.sh", + // Parent negation also admits future private output; close every owned + // frontier rather than pretending the raw toolchain whitelist is safe. + ".hack/.internal", + ".hack/.branch", + ".hack/hack.config.json", + ".hack/docker-compose.yml", + ".hack/hack.project.json", + ".hack/toolchain/Dockerfile", + ".hack/toolchain/Dockerfile.dockerignore", + "", + ].join("\n") + ); + } else { + await Bun.write(join(context, ".dockerignore"), "**\n!data-marker\n"); + } +} + +/** Explicit fixture paths, not an alternative general build-context acquisition owner. */ +export async function retainedBuildFixtureSourceSnapshot(opts: { + readonly root: string; + readonly mode: RetainedBuildFixtureMode; +}) { + const paths = + opts.mode === "root-specific" + ? [ + "mise.toml", + ".dockerignore", + "wrong-marker", + ".hack/toolchain/Dockerfile", + ".hack/toolchain/Dockerfile.dockerignore", + ".hack/toolchain/run.sh", + ] + : [".hack/Dockerfile", ".hack/.dockerignore", ".hack/data-marker"]; + const result: { + readonly path: string; + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly hash: string; + }[] = []; + for (const path of paths) { + const selected = join(opts.root, path); + const info = await lstat(selected); + if ( + !info.isFile() || + info.nlink !== 1 || + info.uid !== process.getuid?.() || + (info.mode & 0o022) !== 0 + ) { + refuse(); + } + result.push({ + path, + dev: info.dev, + ino: info.ino, + mode: info.mode, + hash: hash(await readFile(selected)), + }); + } + return JSON.stringify(result); +} + +export type RetainedFixtureImage = { + readonly id: string; + readonly created: string; + readonly reference: string; + readonly tag: string; + readonly owner: string; + readonly digests: readonly string[] | null; +}; +function selectedDigests( + value: unknown, + reference: string, + id: string +): value is readonly string[] | null { + return ( + value === null || + (Array.isArray(value) && + (value.length === 0 || + (value.length === 1 && value[0] === `${reference}@${id}`))) + ); +} +/** Capture no old/foreign image removal authority, even when its tag happens to match. */ +export function retainedBuildFixtureImage(opts: { + readonly value: unknown; + readonly reference: string; + readonly owner: string; + readonly originalImageIds: readonly string[]; +}): RetainedFixtureImage { + const { value, reference, owner, originalImageIds } = opts; + if ( + !( + isRecord(value) && + Object.keys(value).sort().join() === + "created,digests,id,owner,stage,tags" && + typeof value.id === "string" && + IMAGE.test(value.id) && + !originalImageIds.includes(value.id) && + originalImageIds.every((id) => IMAGE.test(id)) && + typeof value.created === "string" && + BIRTH.test(value.created) && + Number.isFinite(Date.parse(value.created)) && + NAME.test(owner) && + value.owner === owner && + value.stage === "retained" && + Array.isArray(value.tags) && + value.tags.length === 1 && + (value.tags[0] === reference || + value.tags[0] === `${reference}:latest`) && + selectedDigests(value.digests, reference, value.id) + ) + ) { + refuse(); + } + return Object.freeze({ + id: value.id, + created: value.created, + reference, + tag: value.tags[0], + owner, + digests: value.digests === null ? null : Object.freeze([...value.digests]), + }); +} +export function assertRetainedFixtureImageUnchanged(opts: { + readonly current: RetainedFixtureImage; + readonly captured: RetainedFixtureImage; +}) { + if (JSON.stringify(opts.current) !== JSON.stringify(opts.captured)) { + refuse(); + } +} + +export type RetainedFixtureBuildObject = { + readonly id: string; + readonly parent: string; + readonly created: string; + readonly size: number; + readonly owner: string; + readonly stage: "retained"; + readonly tags: readonly string[] | null; + readonly digests: readonly string[] | null; + readonly labelNames: readonly string[]; + readonly composeProject: string | null; + readonly composeService: "db" | null; + readonly composeVersion: string | null; +}; + +const COMPOSE_IMAGE_LABELS = [ + "com.docker.compose.project", + "com.docker.compose.service", + "com.docker.compose.version", +] as const; +const COMPOSE_RELEASE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; +/** + * Release-only short version matches Compose's public core-version image label. + * https://github.com/docker/compose/blob/v2.40.3/pkg/api/labels.go + * https://github.com/docker/compose/blob/v2.40.3/pkg/compose/build.go + */ +export function retainedBuildFixtureComposeVersion(text: string) { + const version = text.trim(); + if (!COMPOSE_RELEASE.test(version)) { + refuse(); + } + return version; +} +function capturedComposeImageLabels(opts: { + readonly value: Record; + readonly selected: RetainedFixtureImage; + readonly composeVersion?: string; +}) { + const { value, selected, composeVersion } = opts; + const labels = value.labelNames; + if (!Array.isArray(labels)) { + refuse(); + } + const present = COMPOSE_IMAGE_LABELS.filter((name) => labels.includes(name)); + if (present.length === 0) { + if ( + value.composeProject !== null || + value.composeService !== null || + value.composeVersion !== null || + (value.id === selected.id && composeVersion !== undefined) + ) { + refuse(); + } + return { composeProject: null, composeService: null, composeVersion: null }; + } + if ( + present.length !== 3 || + composeVersion === undefined || + !COMPOSE_RELEASE.test(composeVersion) || + value.composeProject !== selected.owner || + value.composeService !== "db" || + value.composeVersion !== composeVersion + ) { + refuse(); + } + return { + composeProject: selected.owner, + composeService: "db" as const, + composeVersion, + }; +} + +/** Only exact fixture-labelled objects, never arbitrary dangling images or cache entries. */ +export function retainedBuildFixtureObject(opts: { + readonly value: unknown; + readonly selected: RetainedFixtureImage; + readonly originalImageIds: readonly string[]; + readonly composeVersion?: string; +}): RetainedFixtureBuildObject { + const { value, selected, originalImageIds } = opts; + if ( + !( + isRecord(value) && + Object.keys(value).sort().join() === + "composeProject,composeService,composeVersion,created,digests,id,labelNames,owner,parent,size,stage,tags" && + typeof value.id === "string" && + IMAGE.test(value.id) && + !originalImageIds.includes(value.id) && + originalImageIds.every((id) => IMAGE.test(id)) && + typeof value.parent === "string" && + (value.parent === "" || IMAGE.test(value.parent)) && + typeof value.created === "string" && + BIRTH.test(value.created) && + Number.isFinite(Date.parse(value.created)) && + typeof value.size === "number" && + Number.isSafeInteger(value.size) && + value.size >= 0 && + value.owner === selected.owner && + value.stage === "retained" && + Array.isArray(value.labelNames) && + value.labelNames.every((name) => typeof name === "string") && + new Set(value.labelNames).size === value.labelNames.length && + value.labelNames.includes(RETAINED_BUILD_IMAGE_OWNER) && + value.labelNames.includes("hack.e2e.retained-build.stage") && + value.labelNames.every((name) => + [ + RETAINED_BUILD_IMAGE_OWNER, + "hack.e2e.retained-build.stage", + "com.docker.compose.image.builder", + ...COMPOSE_IMAGE_LABELS, + ].includes(name) + ) + ) + ) { + refuse(); + } + const composeLabels = capturedComposeImageLabels({ ...opts, value }); + if (value.id === selected.id) { + const current = retainedBuildFixtureImage({ + value: { + id: value.id, + created: value.created, + owner: value.owner, + stage: value.stage, + tags: value.tags, + digests: value.digests, + }, + reference: selected.reference, + owner: selected.owner, + originalImageIds, + }); + assertRetainedFixtureImageUnchanged({ current, captured: selected }); + } else if ( + !( + (value.tags === null || + (Array.isArray(value.tags) && value.tags.length === 0)) && + (value.digests === null || + (Array.isArray(value.digests) && value.digests.length === 0)) + ) + ) { + refuse(); + } + if ( + !( + (value.tags === null || Array.isArray(value.tags)) && + (value.digests === null || Array.isArray(value.digests)) + ) + ) { + refuse(); + } + return Object.freeze({ + id: value.id, + parent: value.parent, + created: value.created, + size: value.size, + owner: selected.owner, + stage: "retained", + tags: value.tags === null ? null : Object.freeze([...value.tags]), + digests: value.digests === null ? null : Object.freeze([...value.digests]), + labelNames: Object.freeze([...value.labelNames].sort()), + ...composeLabels, + }); +} + +/** + * Capture a closed child-to-parent image graph from one explicit bootstrap build. + * A builder with no exposed Parent may qualify one final object. Exposed parents + * must form the exact owned chain to the captured base; unrelated new IDs refuse. + * This proves only disposal authority for these objects, never general cache ownership. + */ +export function retainedBuildFixtureObjectGraph(opts: { + readonly values: readonly unknown[]; + readonly selected: RetainedFixtureImage; + readonly originalImageIds: readonly string[]; + readonly baseImage: string; + readonly composeVersion?: string; +}): readonly RetainedFixtureBuildObject[] { + if ( + opts.values.length < 1 || + opts.values.length > 16 || + !IMAGE.test(opts.baseImage) || + !opts.originalImageIds.includes(opts.baseImage) + ) { + refuse(); + } + const nodes = new Map(); + for (const value of opts.values) { + const node = retainedBuildFixtureObject({ ...opts, value }); + if (nodes.has(node.id)) { + refuse(); + } + nodes.set(node.id, node); + } + const result: RetainedFixtureBuildObject[] = []; + let id = opts.selected.id; + while (id !== opts.baseImage) { + const node = nodes.get(id); + if (!node || result.some((row) => row.id === id)) { + refuse(); + } + result.push(node); + if (node.parent === "") { + if (result.length !== 1 || nodes.size !== 1) { + refuse(); + } + break; + } + id = node.parent; + } + if (result.length !== nodes.size) { + refuse(); + } + return Object.freeze(result); +} + +type BuildReadScope = { + readonly args: readonly string[]; + readonly projectRoot: string; + readonly project: string; + readonly containerIds: readonly string[]; + readonly networkId: string; + readonly volumeName: string; + readonly generationId?: unknown; + readonly images: readonly { + readonly id: string; + readonly reference: string; + }[]; +}; +function imageQueryAsHash(opts: BuildReadScope): readonly string[] | null { + const prefix = [ + "compose", + "--project-name", + opts.project, + "--project-directory", + join(opts.projectRoot, ".hack"), + "--env-file", + "/dev/null", + "--profile", + "*", + "--file", + ]; + if ( + opts.args.length !== prefix.length + 5 || + JSON.stringify(opts.args.slice(0, prefix.length)) !== + JSON.stringify(prefix) || + JSON.stringify(opts.args.slice(-3, -1)) !== + JSON.stringify(["--no-env-resolution", "--images"]) || + opts.args.at(-4) !== "config" || + !["db", "worker"].includes(opts.args.at(-1) ?? "") + ) { + return null; + } + return [...opts.args.slice(0, -2), "--hash", "*"]; +} +/** Closed read forwarding extends existing public metadata formats only with exact build-image facts. */ +export function retainedBuildFixtureReadAllowed(opts: BuildReadScope): boolean { + if (adoptionDependencyReadAllowed(opts)) { + return true; + } + const imageQuery = imageQueryAsHash(opts); + if (imageQuery) { + return adoptionDependencyReadAllowed({ ...opts, args: imageQuery }); + } + const { args } = opts; + if (args.length !== 5 || args[1] !== "inspect" || args[2] !== "--format") { + return false; + } + if (args[0] === "container") { + return ( + args[3] === CONTAINER_IMAGE_FORMAT && + opts.containerIds.includes(args[4] ?? "") && + opts.containerIds.every((id) => ID.test(id)) + ); + } + return ( + args[0] === "image" && + args[3] === IMAGE_BIRTH_FORMAT && + opts.images.some( + ({ id, reference }) => + IMAGE.test(id) && [id, reference].includes(args[4] ?? "") + ) + ); +} +/** First staged images query uses the same strict file/store/receipt check as the canonical staged hash query. */ +export async function retainedBuildFixtureStagedReadAllowed(opts: { + readonly scope: BuildReadScope; + readonly first: AdoptionDependencyFirstPrepare; +}) { + const args = imageQueryAsHash(opts.scope) ?? opts.scope.args; + return await adoptionDependencyStagedReadAllowed({ + args, + project: opts.scope.project, + first: opts.first, + }); +} +/** No generic engine passthrough: the saved journal, complete IDs and current daemon precede each effect. */ +export function retainedBuildFixtureMutationAllowed(opts: { + readonly args: readonly string[]; + readonly receipt: unknown; + readonly ids: readonly string[]; + readonly services: readonly string[]; + readonly recoverPendingStartStop?: true; +}) { + const { args, receipt, ids, services } = opts; + if ( + !( + args.length === 3 && + args[0] === "container" && + ["start", "stop"].includes(args[1] ?? "") && + ids.length === 2 && + new Set(ids).size === 2 && + ids.every((id) => ID.test(id)) && + ids.includes(args[2] ?? "") && + services.length === 2 && + JSON.stringify([...services].sort()) === + JSON.stringify(["db", "worker"]) && + isRecord(receipt) && + receipt.adoption_receipt_version === 9 && + isRecord(receipt.pendingOperation) && + (receipt.pendingOperation.operation === args[1] || + (opts.recoverPendingStartStop === true && + args[1] === "stop" && + receipt.pendingOperation.operation === "start" && + isRecord(receipt.publication) && + receipt.publication.phase === "active")) && + Array.isArray(receipt.pendingOperation.services) && + JSON.stringify([...receipt.pendingOperation.services].sort()) === + JSON.stringify([...services].sort()) && + isRecord(receipt.pendingOperation.generation) && + typeof receipt.pendingOperation.generation.id === "string" && + TOKEN.test(receipt.pendingOperation.generation.id) + ) + ) { + return false; + } + const selection = + args[1] === "stop" && receipt.publication === null + ? receipt.prepared + : isRecord(receipt.publication) && receipt.publication.phase === "active" + ? receipt.publication.generation + : null; + return ( + isRecord(selection) && + JSON.stringify(selection) === + JSON.stringify(receipt.pendingOperation.generation) + ); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index 759e9bda1..bdc73146d 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -25,6 +25,32 @@ import { type Scenario, type ScenarioContext, } from "../harness.ts"; +import { observeRetainedBuildFixtureCli } from "./native-compose-adoption-build-diagnostics.ts"; +import { + createRetainedBuildFixtureEvidence, + qualifyRetainedBuildFixtureBuilder, + qualifyRetainedBuildFixtureCopy, + RETAINED_BUILD_BOOTSTRAP_ENV, +} from "./native-compose-adoption-build-evidence.ts"; +import { + assertRetainedFixtureImageUnchanged, + prepareRetainedBuildFixtureSources, + RETAINED_BUILD_BASE_TAG, + RETAINED_BUILD_COPY_ORACLE, + RETAINED_BUILD_IMAGE_FORMAT, + RETAINED_BUILD_IMAGE_OWNER, + RETAINED_BUILD_OBJECT_FORMAT, + type RetainedBuildFixtureMode, + type RetainedFixtureBuildObject, + type RetainedFixtureImage, + retainedBuildFixtureComposeVersion, + retainedBuildFixtureDefinition, + retainedBuildFixtureImage, + retainedBuildFixtureMarker, + retainedBuildFixtureObject, + retainedBuildFixtureObjectGraph, + retainedBuildFixtureSourceSnapshot, +} from "./native-compose-adoption-build-inputs.ts"; import { adoptionDependencyHealthcheck, assertAdoptionDependencyControl, @@ -98,6 +124,7 @@ type Instance = { readonly ownedNetwork?: true; readonly ownedNetworks?: true; readonly dependency?: "service_started" | "service_healthy"; + readonly basicBuild?: RetainedBuildFixtureMode; }; type Observation = { readonly id: string; @@ -542,8 +569,9 @@ async function writeLegacy(instance: Instance, image: string) { name: instance.name, services: { db: { - image, - pull_policy: "never", + ...(instance.basicBuild + ? { build: retainedBuildFixtureDefinition(instance.basicBuild) } + : { image, pull_policy: "never" }), environment: { POSTGRES_DB: "fixture", POSTGRES_HOST_AUTH_METHOD: "trust", @@ -643,6 +671,26 @@ function linkedFixtureFeatures(opts: { }; } +function authoredFixtureFeatures(opts: { + readonly generated: boolean; + readonly stringArgv: boolean; + readonly typedLocal: boolean; + readonly role: "primary" | "first" | "second"; +}): Partial> { + return { + ...(opts.generated ? { sourceMode: "canonical-generated" as const } : {}), + ...(opts.stringArgv + ? { + argvMode: + opts.role === "second" + ? ("string-cleared" as const) + : ("string-entrypoint" as const), + } + : {}), + ...(opts.typedLocal ? { typedLocal: true as const } : {}), + }; +} + async function prepareFixtureInputs( ctx: ScenarioContext, options: { @@ -652,6 +700,7 @@ async function prepareFixtureInputs( readonly ownedNetwork?: boolean; readonly ownedNetworks?: boolean; readonly dependencies?: boolean; + readonly basicBuild?: boolean; } = {} ) { const { @@ -661,7 +710,19 @@ async function prepareFixtureInputs( ownedNetwork = false, ownedNetworks = false, dependencies = false, + basicBuild = false, } = options; + if ( + basicBuild && + (generated || + typedLocal || + stringArgv || + ownedNetwork || + ownedNetworks || + dependencies) + ) { + refused(); + } const firstFeatures = linkedFixtureFeatures({ ownedNetwork, ownedNetworks, @@ -696,10 +757,14 @@ async function prepareFixtureInputs( root: fixture.root, name: `${fixture.name}-main`, marker: "unused-primary", - ...(generated ? { sourceMode: "canonical-generated" as const } : {}), - ...(stringArgv ? { argvMode: "string-entrypoint" as const } : {}), - ...(typedLocal ? { typedLocal: true as const } : {}), + ...authoredFixtureFeatures({ + generated, + stringArgv, + typedLocal, + role: "primary", + }), ...(ownedNetwork ? { ownedNetwork: true as const } : {}), + ...(basicBuild ? { basicBuild: "root-specific" as const } : {}), ...(ownedNetworks ? { ownedNetworks: true as const } : {}), }; if ((await probe(["info", "--format", "{{.OSType}}"])) !== "linux") { @@ -719,6 +784,12 @@ async function prepareFixtureInputs( refused(); } await writeLegacy(primary, image); + if (primary.basicBuild) { + await prepareRetainedBuildFixtureSources({ + ...primary, + mode: primary.basicBuild, + }); + } await commitAll({ root: primary.root, message: "fixture: canonical legacy source", @@ -727,22 +798,36 @@ async function prepareFixtureInputs( root: await addLinkedWorktree({ fixture, branch: "adoption-alpha" }), name: `${fixture.name}-alpha`, marker: "alpha-existing-sql-row", - ...(generated ? { sourceMode: "canonical-generated" as const } : {}), - ...(stringArgv ? { argvMode: "string-entrypoint" as const } : {}), - ...(typedLocal ? { typedLocal: true as const } : {}), + ...authoredFixtureFeatures({ + generated, + stringArgv, + typedLocal, + role: "first", + }), ...firstFeatures, + ...(basicBuild ? { basicBuild: "root-specific" as const } : {}), }; const second: Instance = { root: await addLinkedWorktree({ fixture, branch: "adoption-beta" }), name: `${fixture.name}-beta`, marker: "beta-existing-sql-row", - ...(generated ? { sourceMode: "canonical-generated" as const } : {}), - ...(stringArgv ? { argvMode: "string-cleared" as const } : {}), - ...(typedLocal ? { typedLocal: true as const } : {}), + ...authoredFixtureFeatures({ + generated, + stringArgv, + typedLocal, + role: "second", + }), ...secondFeatures, + ...(basicBuild ? { basicBuild: "hack-default" as const } : {}), }; for (const instance of [first, second]) { await writeLegacy(instance, image); + if (instance.basicBuild) { + await prepareRetainedBuildFixtureSources({ + ...instance, + mode: instance.basicBuild, + }); + } await commitAll({ root: instance.root, message: "fixture: distinct original identity", @@ -773,14 +858,26 @@ async function prepareFixtureInputs( first, second, probe, + baseImage: image, + originalImageIds: basicBuild ? await fixtureImageInventory(probe) : [], }; } function createFixtureRuntime( opts: Awaited> ) { - const { ctx, engine, engineId, fixtureRoot, primary, first, second, probe } = - opts; + const { + ctx, + engine, + engineId, + fixtureRoot, + primary, + first, + second, + probe, + baseImage, + originalImageIds, + } = opts; const env = { HACK_RUNTIME_BACKEND: "compose", CI: "", @@ -840,6 +937,21 @@ function createFixtureRuntime( const anchors = new Map(); const managedAnchors = new Map(); const localAnchors = new Map(); + const buildSourceAnchors = new Map(); + const buildEvidence = createRetainedBuildFixtureEvidence({ + tempRoot: ctx.tempRoot, + }); + const buildEnv = Object.freeze({ + ...RETAINED_BUILD_BOOTSTRAP_ENV, + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_CONTEXT: process.env.DOCKER_CONTEXT, + }); + const builtImages = new Map(); + const builtImageObjects = new Map< + Instance, + readonly RetainedFixtureBuildObject[] + >(); + const buildImageAnchors = new Map(); const effect = async (args: readonly string[]) => { await requirePreparedEngine({ engineId, probe }); return successful( @@ -1076,7 +1188,61 @@ function createFixtureRuntime( }); } }; - const check = async (instance: Instance, checkSource = true) => { + const checkBuild = async (instance: Instance, baseline: Snapshot) => { + if (!instance.basicBuild) { + refused(); + } + await buildEvidence(instance.basicBuild, "image-admission-begin"); + await assertFixtureBuildImages({ + probe, + builtImages, + builtImageObjects, + originalImageIds, + instance, + }); + await buildEvidence(instance.basicBuild, "image-admitted"); + await buildEvidence(instance.basicBuild, "source-recheck-begin"); + if ( + (await retainedBuildFixtureSourceSnapshot({ + root: instance.root, + mode: instance.basicBuild, + })) !== buildSourceAnchors.get(instance) + ) { + refused(); + } + await buildEvidence(instance.basicBuild, "source-rechecked"); + await buildEvidence(instance.basicBuild, "runtime-image-begin"); + if ( + (await fixtureRuntimeImages({ + probe, + instance, + containers: baseline.resources.container, + builtImages, + baseImage, + })) !== buildImageAnchors.get(instance) + ) { + refused(); + } + await buildEvidence(instance.basicBuild, "runtime-image-rechecked"); + await qualifyRetainedBuildFixtureCopy({ + mode: instance.basicBuild, + record: buildEvidence, + read: () => + createNativeComposeProbe({ timeoutMs: 30_000 })([ + "container", + "exec", + container(instance, "db"), + "/bin/sh", + "-c", + RETAINED_BUILD_COPY_ORACLE, + ]), + }); + await owned(instance, "container", container(instance, "db")); + }; + const checkedRunningBaseline = async ( + instance: Instance, + checkSource: boolean + ) => { const baseline = anchors.get(instance); if ( !baseline || @@ -1088,10 +1254,17 @@ function createFixtureRuntime( ) { refused(); } + return baseline; + }; + const check = async (instance: Instance, checkSource = true) => { + const baseline = await checkedRunningBaseline(instance, checkSource); await assertTopology(instance, true); await assertAliasSql(instance); await checkWorkerArgv(instance); await checkHealthcheck(instance); + if (instance.basicBuild) { + await checkBuild(instance, baseline); + } if (instance.sourceMode === "canonical-generated") { if ( (await managedAdoptionFixtureSourceSnapshot({ primary, instance })) !== @@ -1159,6 +1332,24 @@ function createFixtureRuntime( refused(); } } + if (instance.basicBuild) { + await assertFixtureBuildImages({ + ...opts, + builtImages, + builtImageObjects, + instance, + }); + if ( + (await fixtureRuntimeImages({ + ...opts, + builtImages, + instance, + containers: baseline.resources.container, + })) !== buildImageAnchors.get(instance) + ) { + refused(); + } + } }; return { ctx, @@ -1177,6 +1368,14 @@ function createFixtureRuntime( anchors, managedAnchors, localAnchors, + buildSourceAnchors, + buildEvidence, + buildEnv, + buildImageAnchors, + builtImages, + builtImageObjects, + baseImage, + originalImageIds, effect, container, sql, @@ -1197,23 +1396,389 @@ function refusedPreview(result: CliResult) { }); return result; } +async function fixtureImageInventory( + probe: ReturnType +) { + const ids = [ + ...new Set( + ( + await probe([ + "image", + "ls", + "--all", + "--no-trunc", + "--format", + "{{.ID}}", + ]) + ) + .split(/\s+/) + .filter(Boolean) + ), + ].sort(); + if (!ids.every((id) => IMAGE.test(id))) { + refused(); + } + return ids; +} +function fixtureComposePrefix( + instance: Instance, + composeFile = join(instance.root, ".hack/docker-compose.yml") +) { + return [ + "compose", + "--project-name", + instance.name, + "--project-directory", + join(instance.root, ".hack"), + "--env-file", + "/dev/null", + "--profile", + "*", + "--file", + composeFile, + ]; +} +type BuildImageContext = { + readonly probe: ReturnType; + readonly builtImages: ReadonlyMap; + readonly builtImageObjects: ReadonlyMap< + Instance, + readonly RetainedFixtureBuildObject[] + >; + readonly originalImageIds: readonly string[]; + readonly baseImage: string; +}; +async function assertFixtureBuildImages( + opts: Pick< + BuildImageContext, + "probe" | "builtImages" | "builtImageObjects" | "originalImageIds" + > & { readonly instance: Instance } +) { + const captured = opts.builtImages.get(opts.instance); + if (!captured) { + refused(); + } + for (const reference of [captured.reference, captured.id]) { + assertRetainedFixtureImageUnchanged({ + captured, + current: retainedBuildFixtureImage({ + value: object( + await opts.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_IMAGE_FORMAT, + reference, + ]) + ), + reference: captured.reference, + owner: opts.instance.name, + originalImageIds: opts.originalImageIds, + }), + }); + } + const objects = opts.builtImageObjects.get(opts.instance); + if (!objects || objects.length < 1) { + refused(); + } + for (const expected of objects) { + const current = retainedBuildFixtureObject({ + value: object( + await opts.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_OBJECT_FORMAT, + expected.id, + ]) + ), + selected: captured, + originalImageIds: opts.originalImageIds, + ...(expected.composeVersion !== null + ? { composeVersion: expected.composeVersion } + : {}), + }); + if (JSON.stringify(current) !== JSON.stringify(expected)) { + refused(); + } + } +} +async function fixtureRuntimeImages( + opts: Pick & { + readonly instance: Instance; + readonly containers: readonly Observation[]; + } +) { + const selected = opts.builtImages.get(opts.instance); + if (!selected || opts.containers.length !== 2) { + refused(); + } + const result: Record[] = []; + for (const container of opts.containers) { + const row = object( + await opts.probe([ + "container", + "inspect", + "--format", + '{"id":{{json .Id}},"image":{{json .Image}},"reference":{{json .Config.Image}},"createdAt":{{json .Created}}}', + container.id, + ]) + ); + const reference = + container.service === "db" ? selected.reference : opts.baseImage; + const id = container.service === "db" ? selected.id : opts.baseImage; + if ( + Object.keys(row).sort().join() !== "createdAt,id,image,reference" || + row.id !== container.id || + row.image !== id || + row.reference !== reference || + typeof row.createdAt !== "string" || + !CREATED.test(row.createdAt) + ) { + refused(); + } + const image = object( + await opts.probe([ + "image", + "inspect", + "--format", + '{"id":{{json .Id}},"createdAt":{{json .Created}}}', + id, + ]) + ); + if ( + Object.keys(image).sort().join() !== "createdAt,id" || + image.id !== id || + typeof image.createdAt !== "string" || + !CREATED.test(image.createdAt) + ) { + refused(); + } + result.push({ + service: container.service, + ...row, + imageCreatedAt: image.createdAt, + }); + } + return JSON.stringify( + result.sort((a, b) => String(a.service).localeCompare(String(b.service))) + ); +} +async function saveFixtureBuildRecovery(h: FixtureRuntime, pending?: unknown) { + const path = join(h.ctx.tempRoot, "retained-build-original-images.json"); + await Bun.write( + path, + JSON.stringify({ + engineId: h.engineId, + originalImageIds: h.originalImageIds, + baseImage: h.baseImage, + images: [...h.builtImages.entries()].map(([instance, image]) => ({ + project: instance.name, + root: instance.root, + image, + })), + objects: [...h.builtImageObjects.entries()].map( + ([instance, objects]) => ({ + project: instance.name, + objects, + }) + ), + ...(pending !== undefined ? { pending } : {}), + }) + ); + await chmod(path, 0o600); +} +async function bootstrapFixtureBuildImage( + h: FixtureRuntime, + instance: Instance +) { + if (!instance.basicBuild || h.builtImages.has(instance)) { + refused(); + } + await requirePreparedEngine(h); + const previousIds = [ + ...h.originalImageIds, + ...[...h.builtImageObjects.values()].flatMap((rows) => + rows.map((row) => row.id) + ), + ].sort(); + if ( + JSON.stringify(await fixtureImageInventory(h.probe)) !== + JSON.stringify(previousIds) + ) { + refused(); + } + if ( + ( + await h.probe([ + "image", + "inspect", + "--format", + "{{.Id}}", + RETAINED_BUILD_BASE_TAG, + ]) + ).trim() !== h.baseImage + ) { + refused(); + } + const reference = ( + await h.probe([ + ...fixtureComposePrefix(instance), + "config", + "--no-env-resolution", + "--images", + "db", + ]) + ).trim(); + if ( + !reference || + reference.length > 512 || + /\s/.test(reference) || + ( + await h.probe([ + "image", + "ls", + "--all", + "--no-trunc", + "--filter", + `reference=${reference}`, + "--format", + "{{.ID}}", + ]) + ).trim() || + ( + await h.probe([ + "image", + "ls", + "--all", + "--no-trunc", + "--filter", + `label=${RETAINED_BUILD_IMAGE_OWNER}=${instance.name}`, + "--format", + "{{.ID}}", + ]) + ).trim() + ) { + refused(); + } + const composeVersion = retainedBuildFixtureComposeVersion( + await h.probe(["compose", "version", "--short"]) + ); + await saveFixtureBuildRecovery(h, { + project: instance.name, + reference, + stage: "before-build", + }); + await qualifyRetainedBuildFixtureBuilder({ + mode: instance.basicBuild, + env: h.buildEnv, + read: h.probe, + record: h.buildEvidence, + }); + await h.buildEvidence(instance.basicBuild, "build-begin"); + await requirePreparedEngine(h); + const built = await runCommand({ + argv: [ + h.engine, + ...fixtureComposePrefix(instance), + "build", + "--builder", + "default", + "db", + ], + cwd: h.fixtureRoot, + env: RETAINED_BUILD_BOOTSTRAP_ENV, + timeoutMs: TIMEOUT, + }); + await h.buildEvidence(instance.basicBuild, "build-settled"); + await h.buildEvidence(instance.basicBuild, "image-admission-begin"); + const observation = object( + await h.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_IMAGE_FORMAT, + reference, + ]) + ); + await saveFixtureBuildRecovery(h, { + project: instance.name, + reference, + stage: "image-observed", + observation, + }); + const image = retainedBuildFixtureImage({ + value: observation, + reference, + owner: instance.name, + originalImageIds: h.originalImageIds, + }); + await h.buildEvidence(instance.basicBuild, "image-admitted"); + const observedIds = await fixtureImageInventory(h.probe); + if (!previousIds.every((id) => observedIds.includes(id))) { + refused(); + } + const newIds = observedIds.filter((id) => !previousIds.includes(id)); + if (newIds.length < 1 || newIds.length > 16) { + refused(); + } + const values: unknown[] = []; + for (const id of newIds) { + values.push( + object( + await h.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_OBJECT_FORMAT, + id, + ]) + ) + ); + await saveFixtureBuildRecovery(h, { + project: instance.name, + reference, + stage: "objects-observed", + observations: values, + }); + } + const objects = retainedBuildFixtureObjectGraph({ + values, + selected: image, + originalImageIds: h.originalImageIds, + baseImage: h.baseImage, + composeVersion, + }); + h.builtImages.set(instance, image); + h.builtImageObjects.set(instance, objects); + await saveFixtureBuildRecovery(h); + await h.buildEvidence(instance.basicBuild, "graph-admitted"); + successful(built); + await assertFixtureBuildImages({ ...h, instance }); + if ( + ( + await h.probe([ + "image", + "inspect", + "--format", + "{{.Id}}", + RETAINED_BUILD_BASE_TAG, + ]) + ).trim() !== h.baseImage || + (await retainedBuildFixtureSourceSnapshot({ + root: instance.root, + mode: instance.basicBuild, + })) !== h.buildSourceAnchors.get(instance) + ) { + refused(); + } +} -async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { - const { - engine, - fixtureRoot, - probe, - list, - resources, - anchors, - sql, - waitReady, - check, - managedAnchors, - localAnchors, - primary, - } = h; - +async function requireFixtureNamesAbsent( + h: FixtureRuntime, + instance: Instance +) { + const { list, probe } = h; for (const kind of ["container", "network", "volume"] as const) { if ((await list(instance, kind)).length) { refused(); @@ -1242,7 +1807,34 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { refused(); } } +} +async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { + const { + engine, + fixtureRoot, + resources, + anchors, + sql, + waitReady, + check, + managedAnchors, + localAnchors, + primary, + buildSourceAnchors, + } = h; + + await requireFixtureNamesAbsent(h, instance); const originalSource = await source(instance); + if (instance.basicBuild) { + buildSourceAnchors.set( + instance, + await retainedBuildFixtureSourceSnapshot({ + root: instance.root, + mode: instance.basicBuild, + }) + ); + await bootstrapFixtureBuildImage(h, instance); + } if (instance.sourceMode === "canonical-generated") { managedAnchors.set( instance, @@ -1255,6 +1847,9 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { await typedLocalAdoptionFixtureSourceSnapshot({ primary, instance }) ); } + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "original-start-begin"); + } await requirePreparedEngine(h); const started = await runCommand({ argv: [ @@ -1271,10 +1866,14 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { "--detach", "--pull", "never", + ...(instance.basicBuild ? ["--no-build"] : []), ], cwd: fixtureRoot, timeoutMs: TIMEOUT, }); + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "original-start-settled"); + } const captured = await resources(instance); anchors.set(instance, { resources: captured, @@ -1288,7 +1887,23 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { ) { refused(); } + if (instance.basicBuild) { + h.buildImageAnchors.set( + instance, + await fixtureRuntimeImages({ + ...h, + instance, + containers: captured.container, + }) + ); + await h.buildEvidence(instance.basicBuild, "original-ids-captured"); + await h.buildEvidence(instance.basicBuild, "readiness-begin"); + } await waitReady(instance); + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "readiness-qualified"); + await h.buildEvidence(instance.basicBuild, "sql-begin"); + } await waitForAdoptionFixtureSql({ read: async () => { await sql( @@ -1299,7 +1914,14 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { }, expected: instance.marker, }); + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "sql-qualified"); + await h.buildEvidence(instance.basicBuild, "full-check-begin"); + } await check(instance); + if (instance.basicBuild) { + await h.buildEvidence(instance.basicBuild, "full-check-qualified"); + } } async function checkInheritedRefusal(h: FixtureRuntime) { const { ctx, primary, first, second, env, cli, check, assertNoState } = h; @@ -1790,11 +2412,58 @@ async function foreignCanaryRefusal( } await h.assertNoState(first); } +function partialStopReceiptVersion(first: Instance): number { + const generatedVersion = first.typedLocal ? 4 : 3; + return first.basicBuild + ? 9 + : first.ownedNetworks + ? 11 + : first.ownedNetwork + ? first.dependency + ? 10 + : 6 + : first.dependency + ? 5 + : first.sourceMode + ? generatedVersion + : 2; +} +function partialStopDockerScript(opts: { + readonly h: FixtureRuntime; + readonly engine: string; + readonly first: Instance; + readonly receipt: string; + readonly db: string; + readonly worker: string; + readonly control: string; + readonly firstPrepare?: AdoptionDependencyFirstPrepare; +}): string { + const { h, engine, first, receipt, db, worker, control, firstPrepare } = opts; + const receiptVersion = partialStopReceiptVersion(first); + return `#!${process.execPath} +const args = process.argv.slice(2); +const engine = ${JSON.stringify(engine)}; +if(args[0]==="container" && args[1]==="stop") { + if(${first.dependency || first.basicBuild ? `args.length!==3 || args[2]!==${JSON.stringify(worker)}` : `args.length!==4 || !args.includes(${JSON.stringify(db)}) || !args.includes(${JSON.stringify(worker)})`}) process.exit(99); + const state=JSON.parse(await Bun.file(${JSON.stringify(receipt)}).text()); + if(state.adoption_receipt_version!==${receiptVersion} || state.pendingOperation?.operation!=="stop") process.exit(98); + ${first.dependency || first.basicBuild ? dependencyEngineCheck(h) : ""} + ${first.basicBuild ? buildMutationGuard(h, first, receipt) : ""} + const child=Bun.spawn([engine,"container","stop",${JSON.stringify(first.dependency || first.basicBuild ? worker : db)}],{stdin:"ignore",stdout:"ignore",stderr:"ignore"}); + if(await child.exited!==0) process.exit(97); + await Bun.write(${JSON.stringify(control)},"journal-before-partial-stop");process.exit(71); +} +${first.dependency ? dependencyReadGuard(h, first, receipt, firstPrepare) : ""} +${first.basicBuild ? buildReadGuard(h, first, receipt, firstPrepare) : ""} +const child=Bun.spawn([engine,...args],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});process.exit(await child.exited); +`; +} async function interruptFirstStop(h: FixtureRuntime) { const { ctx, engine, first, container, cli } = h; - const firstPrepare = first.dependency - ? await captureAdoptionDependencyFirstPrepare({ projectRoot: first.root }) - : undefined; + const firstPrepare = + first.dependency || first.basicBuild + ? await captureAdoptionDependencyFirstPrepare({ projectRoot: first.root }) + : undefined; const shimRoot = join(ctx.tempRoot, "partial-stop-shim"); await mkdir(shimRoot, { mode: 0o700 }); const receipt = join( @@ -1804,42 +2473,25 @@ async function interruptFirstStop(h: FixtureRuntime) { const db = container(first, "db"), worker = container(first, "worker"); const control = join(shimRoot, "control-hit"); - const generatedVersion = first.typedLocal ? 4 : 3; - const receiptVersion = first.ownedNetworks - ? 11 - : first.ownedNetwork - ? first.dependency - ? 10 - : 6 - : first.dependency - ? 5 - : first.sourceMode - ? generatedVersion - : 2; const shim = join(shimRoot, "docker"); await Bun.write( shim, - `#!${process.execPath} -const args = process.argv.slice(2); -const engine = ${JSON.stringify(engine)}; -if(args[0]==="container" && args[1]==="stop") { - if(${first.dependency ? `args.length!==3 || args[2]!==${JSON.stringify(worker)}` : `args.length!==4 || !args.includes(${JSON.stringify(db)}) || !args.includes(${JSON.stringify(worker)})`}) process.exit(99); - const state=JSON.parse(await Bun.file(${JSON.stringify(receipt)}).text()); - if(state.adoption_receipt_version!==${receiptVersion} || state.pendingOperation?.operation!=="stop") process.exit(98); - ${first.dependency ? dependencyEngineCheck(h) : ""} - const child=Bun.spawn([engine,"container","stop",${JSON.stringify(first.dependency ? worker : db)}],{stdin:"ignore",stdout:"ignore",stderr:"ignore"}); - if(await child.exited!==0) process.exit(97); - await Bun.write(${JSON.stringify(control)},"journal-before-partial-stop");process.exit(71); -} -${first.dependency ? dependencyReadGuard(h, first, receipt, firstPrepare) : ""} -const child=Bun.spawn([engine,...args],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});process.exit(await child.exited); -` + partialStopDockerScript({ + h, + engine, + first, + receipt, + db, + worker, + control, + firstPrepare, + }) ); await chmod(shim, 0o700); const partial = await cli(first, ["config", "adopt", "--stop", "--json"], { PATH: `${shimRoot}:${process.env.PATH ?? "/usr/bin:/bin"}`, }); - if (first.dependency) { + if (first.dependency || first.basicBuild) { assertAdoptionDependencyControl({ stage: "prepared-stop", exitCode: partial.exitCode, @@ -1880,7 +2532,7 @@ async function dependencyControlMarker(path: string, expected: string) { } } -function dependencyEngineCheck(h: FixtureRuntime): string { +function dependencyEngineCheck(h: Pick): string { return `const engineCheck=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'}); const engineId=(await new Response(engineCheck.stdout).text()).trim();if(await engineCheck.exited!==0 || engineId!==${JSON.stringify(h.engineId)})process.exit(95);`; } @@ -1923,6 +2575,143 @@ try { if(!allowed){console.error('dependency-read-refused stage=read-admission code=93');process.exit(93);} }catch{console.error('dependency-read-refused stage=read-admission code=93');process.exit(93);}`; } +type BuildFixtureTransport = Pick< + FixtureRuntime, + "engine" | "engineId" | "baseImage" | "anchors" | "builtImages" | "cli" +> & { + readonly ctx: Pick & + Partial>; +}; +function fixtureBuildScope(h: BuildFixtureTransport, instance: Instance) { + const anchor = h.anchors.get(instance); + const built = h.builtImages.get(instance); + if ( + !(anchor && built) || + anchor.resources.container.length !== 2 || + anchor.resources.network.length !== 1 || + anchor.resources.volume.length !== 1 + ) { + refused(); + } + return { + projectRoot: instance.root, + project: instance.name, + containerIds: anchor.resources.container.map((row) => row.id), + networkId: anchor.resources.network[0]?.id, + volumeName: anchor.resources.volume[0]?.id, + images: [ + { id: built.id, reference: built.reference }, + { id: h.baseImage, reference: h.baseImage }, + ], + }; +} +function buildMutationGuard( + h: BuildFixtureTransport, + instance: Instance, + receipt: string, + recoverPendingStartStop = false +) { + const helper = fileURLToPath( + new URL("./native-compose-adoption-build-inputs.ts", import.meta.url) + ); + return `const {retainedBuildFixtureMutationAllowed}=await import(${JSON.stringify(helper)}); +const mutationReceipt=JSON.parse(await Bun.file(${JSON.stringify(receipt)}).text()); +if(!retainedBuildFixtureMutationAllowed({args,receipt:mutationReceipt,ids:${JSON.stringify(fixtureBuildScope(h, instance).containerIds)},services:['db','worker']${recoverPendingStartStop ? ",recoverPendingStartStop:true" : ""}})) {console.error('retained-build-refused stage=mutation-admission code=94');process.exit(94);}`; +} +function buildReadGuard( + h: BuildFixtureTransport, + instance: Instance, + receipt: string, + firstPrepare?: AdoptionDependencyFirstPrepare +) { + const helper = fileURLToPath( + new URL("./native-compose-adoption-build-inputs.ts", import.meta.url) + ); + return `import {retainedBuildFixtureReadAllowed,retainedBuildFixtureStagedReadAllowed} from ${JSON.stringify(helper)}; +try { + const savedFile=Bun.file(${JSON.stringify(receipt)}); + const saved=await savedFile.exists()?JSON.parse(await savedFile.text()):null; + const generationId=saved?.prepared?.id ?? saved?.publication?.generation?.id; + const scope={...${JSON.stringify(fixtureBuildScope(h, instance))},args,generationId}; + const allowed=retainedBuildFixtureReadAllowed(scope)${firstPrepare ? ` || await retainedBuildFixtureStagedReadAllowed({scope,first:${JSON.stringify(firstPrepare)}})` : ""}; + if(!allowed){console.error('retained-build-refused stage=read-admission code=93');process.exit(93);} +}catch{console.error('retained-build-refused stage=read-admission code=93');process.exit(93);}`; +} +/** Actual closed transport used by every post-bootstrap build9 CLI invocation, including previews. */ +export async function buildFixtureCli( + h: BuildFixtureTransport, + instance: Instance, + args: readonly string[], + driftAfterStart = false +) { + const invocation = Object.freeze([...args]); + const recoverPendingStartStop = + invocation.length === 3 && + invocation[0] === "down" && + invocation[1] === "--recover" && + invocation[2] === "--json"; + if (!instance.basicBuild) { + refused(); + } + const receipt = join( + instance.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const firstPrepare = + invocation[0] === "config" && + invocation[1] === "adopt" && + !(await Bun.file(receipt).exists()) + ? await captureAdoptionDependencyFirstPrepare({ + projectRoot: instance.root, + }) + : undefined; + const shimRoot = join( + h.ctx.tempRoot, + `retained-build-${instance.name}-${crypto.randomUUID()}` + ); + await mkdir(shimRoot, { mode: 0o700 }); + const control = join(shimRoot, "control-hit"); + const shim = join(shimRoot, "docker"); + await Bun.write( + shim, + `#!${process.execPath} +const args=process.argv.slice(2); const engine=${JSON.stringify(h.engine)}; +if(args[0]==='container' && ['start','stop'].includes(args[1])) { + ${buildMutationGuard(h, instance, receipt, recoverPendingStartStop)} + ${dependencyEngineCheck(h)} + const child=Bun.spawn([engine,...args],{stdin:'ignore',stdout:'ignore',stderr:'ignore'}); + const code=await child.exited; + ${driftAfterStart ? `if(code===0 && args[1]==='start') {await Bun.write(${JSON.stringify(retainedBuildFixtureMarker(instance.root, instance.basicBuild))},${JSON.stringify("synthetic-controlled-context-drift\n")});await Bun.write(${JSON.stringify(control)},'original-start-before-context-drift');}` : ""} + process.exit(code); +} +${buildReadGuard(h, instance, receipt, firstPrepare)} +const child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited); +` + ); + await chmod(shim, 0o700); + const result = await observeRetainedBuildFixtureCli({ + context: h.ctx, + mode: instance.basicBuild, + args: invocation, + driftAfterStart, + run: () => + h.cli(instance, invocation, { + PATH: `${shimRoot}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }), + }); + if (driftAfterStart) { + assertAdoptionDependencyControl({ + stage: "pending-start", + exitCode: result.exitCode, + timedOut: result.timedOut, + control: await dependencyControlMarker( + control, + "original-start-before-context-drift" + ), + }); + } + return result; +} async function recoverFirstAndRollback(h: FixtureRuntime) { const { first, second, cli, container, waitReady, check, anchors, effect } = h; @@ -2118,6 +2907,365 @@ async function rollbackDependencyInstance( await h.waitReady(instance); await h.check(instance); } +async function fixtureRunningIds(h: FixtureRuntime, instance: Instance) { + const anchor = h.anchors.get(instance); + if (!anchor) { + refused(); + } + return JSON.stringify( + await Promise.all( + anchor.resources.container.map(async (row) => ({ + id: row.id, + running: await h.probe([ + "container", + "inspect", + "--format", + "{{.State.Running}}", + row.id, + ]), + })) + ) + ); +} +async function retainedBuildContextRecovery(h: FixtureRuntime) { + const { first, second } = h; + if (!first.basicBuild) { + refused(); + } + const path = retainedBuildFixtureMarker(first.root, first.basicBuild); + const bytes = await readFile(path); + try { + refusedPreview( + await buildFixtureCli(h, first, ["up", "--detach", "--json"], true) + ); + const receiptPath = join( + first.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const receiptBytes = await Bun.file(receiptPath).text(); + const receipt = object(receiptBytes); + if ( + !isRecord(receipt.pendingOperation) || + receipt.pendingOperation.operation !== "start" + ) { + refused(); + } + const state = await fixtureRunningIds(h, first); + refusedPreview( + await buildFixtureCli(h, first, ["down", "--recover", "--json"]) + ); + if ( + (await fixtureRunningIds(h, first)) !== state || + (await Bun.file(receiptPath).text()) !== receiptBytes + ) { + refused(); + } + await assertFixtureBuildImages({ ...h, instance: first }); + await h.check(second); + } finally { + // The active included-context proof permits only exact bytes on the same + // inode. Prepared authored-source timestamp repair is never exercised here. + await writeFile(path, bytes); + } + successful(await buildFixtureCli(h, first, ["down", "--recover", "--json"])); + await h.assertStopped(first); + await h.check(second); + successful(await buildFixtureCli(h, first, ["up", "--detach", "--json"])); + await h.waitReady(first); + await h.check(first, false); + await h.check(second); +} +async function retainedBuildCandidateRefusal(h: FixtureRuntime) { + const path = join(h.first.root, ".hack/hack.project.json"); + const original = await readFile(path); + const candidate = object( + new TextDecoder("utf-8", { fatal: true }).decode(original) + ); + if ( + !( + isRecord(candidate.services) && + isRecord(candidate.services.db) && + isRecord(candidate.services.db.build) + ) + ) { + refused(); + } + candidate.services.db.build.context = "unsupported-context-edit"; + const running = await fixtureRunningIds(h, h.first); + await writeFile(path, JSON.stringify(candidate)); + try { + refusedPreview( + await buildFixtureCli(h, h.first, ["up", "--detach", "--json"]) + ); + if ((await fixtureRunningIds(h, h.first)) !== running) { + refused(); + } + await assertFixtureBuildImages({ ...h, instance: h.first }); + await h.check(h.second); + } finally { + await writeFile(path, original); + } + await h.check(h.first, false); +} +async function rollbackRetainedBuildFixture( + h: FixtureRuntime, + instance: Instance +) { + successful(await buildFixtureCli(h, instance, ["down", "--json"])); + await h.assertStopped(instance); + successful( + await buildFixtureCli(h, instance, [ + "config", + "adopt", + "--rollback", + "--json", + ]) + ); + if ((await source(instance)) !== h.anchors.get(instance)?.source) { + refused(); + } + await h.effect([ + "container", + "start", + h.container(instance, "db"), + h.container(instance, "worker"), + ]); + await h.waitReady(instance); + await h.check(instance); +} +type BuildCleanupInputs = Pick< + FixtureRuntime, + | "engineId" + | "probe" + | "builtImages" + | "builtImageObjects" + | "originalImageIds" + | "baseImage" +> & { + readonly effect: (args: readonly string[]) => Promise; + readonly journal: (value: unknown) => Promise; +}; +async function requireRemainingBuildObjects( + h: BuildCleanupInputs, + pending: ReadonlySet +) { + await requirePreparedEngine(h); + if ( + JSON.stringify(await fixtureImageInventory(h.probe)) !== + JSON.stringify([...h.originalImageIds, ...pending].sort()) + ) { + refused(); + } + for (const [instance, rows] of h.builtImageObjects) { + const selected = h.builtImages.get(instance); + if (!selected) { + refused(); + } + for (const captured of rows) { + if (!pending.has(captured.id)) { + continue; + } + const current = retainedBuildFixtureObject({ + value: object( + await h.probe([ + "image", + "inspect", + "--format", + RETAINED_BUILD_OBJECT_FORMAT, + captured.id, + ]) + ), + selected, + originalImageIds: h.originalImageIds, + ...(captured.composeVersion !== null + ? { composeVersion: captured.composeVersion } + : {}), + }); + if ( + JSON.stringify(current) !== JSON.stringify(captured) || + ( + await h.probe([ + "container", + "ls", + "--all", + "--no-trunc", + "--filter", + `ancestor=${captured.id}`, + "--format", + "{{.ID}}", + ]) + ).trim() + ) { + refused(); + } + } + } + if ( + ( + await h.probe([ + "image", + "inspect", + "--format", + "{{.Id}}", + RETAINED_BUILD_BASE_TAG, + ]) + ).trim() !== h.baseImage + ) { + refused(); + } + await requirePreparedEngine(h); +} +/** Exact captured child-before-parent disposal, never prune or general cache removal. */ +export async function cleanupRetainedBuildFixtureImages(h: BuildCleanupInputs) { + if (h.builtImages.size !== h.builtImageObjects.size) { + refused(); + } + const rows = [...h.builtImageObjects.values()].flat(); + const pending = new Set(rows.map((row) => row.id)); + if ( + pending.size !== rows.length || + rows.some((row) => h.originalImageIds.includes(row.id)) + ) { + refused(); + } + for (const [instance, objects] of h.builtImageObjects) { + const selected = h.builtImages.get(instance); + const composeVersion = selected + ? objects.find((row) => row.id === selected.id)?.composeVersion + : undefined; + if ( + !selected || + JSON.stringify( + retainedBuildFixtureObjectGraph({ + values: objects, + selected, + originalImageIds: h.originalImageIds, + baseImage: h.baseImage, + ...(typeof composeVersion === "string" ? { composeVersion } : {}), + }) + ) !== JSON.stringify(objects) + ) { + refused(); + } + for (const row of objects) { + await requireRemainingBuildObjects(h, pending); + await h.journal({ + stage: "before-image-remove", + id: row.id, + remaining: [...pending].sort(), + }); + await requireRemainingBuildObjects(h, pending); + await h.effect(["image", "rm", "--no-prune", row.id]); + pending.delete(row.id); + await requireRemainingBuildObjects(h, pending); + await h.journal({ + stage: "after-image-remove", + id: row.id, + remaining: [...pending].sort(), + }); + } + } + await requireRemainingBuildObjects(h, pending); +} +/** Basic builds qualify separately from preview; no builder is reachable after bootstrap. */ +export const nativeComposeAdoptionBuildWorktreesScenario: Scenario = { + name: "native-compose-adoption-build-worktrees", + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + summary: + "root/specific and default .hack COPY projections retain two existing SQL volumes, images and original IDs through recovery and rollback", + run: async (ctx) => { + const h = createFixtureRuntime( + await prepareFixtureInputs(ctx, { basicBuild: true }) + ); + await runWithFixtureCleanup({ + run: async () => { + for (const instance of [h.first, h.second]) { + await bootstrapOriginal(h, instance); + const preview = successful( + await buildFixtureCli(h, instance, [ + "config", + "adopt", + "--dry-run", + "--stop", + "--json", + ]) + ); + if (object(preview.stdout).complete !== true) { + refused(); + } + await h.assertNoState(instance); + await h.check(instance); + } + await interruptFirstStop(h); + successful( + await buildFixtureCli(h, h.first, [ + "config", + "adopt", + "--recover", + "--stop", + "--json", + ]) + ); + await h.assertStopped(h.first); + await h.check(h.second); + refusedPreview( + await buildFixtureCli(h, h.first, ["up", "db", "--detach", "--json"]) + ); + await h.assertStopped(h.first); + await retainedBuildContextRecovery(h); + await retainedBuildCandidateRefusal(h); + refusedPreview( + await buildFixtureCli(h, h.first, ["run", "db", "--", "true"]) + ); + await h.check(h.first, false); + await rollbackRetainedBuildFixture(h, h.first); + await h.check(h.second); + successful( + await buildFixtureCli(h, h.second, [ + "config", + "adopt", + "--stop", + "--json", + ]) + ); + await h.assertStopped(h.second); + await h.check(h.first); + successful( + await buildFixtureCli(h, h.second, ["up", "--detach", "--json"]) + ); + await h.waitReady(h.second); + await h.check(h.second, false); + await h.check(h.first); + await rollbackRetainedBuildFixture(h, h.second); + await h.check(h.first); + ctx.log( + "basic build COPY/ignore/target parity, original image/container/volume births and SQL, partial-stop/source repair and isolated rollback verified; historical image provenance and cache reclamation unqualified" + ); + }, + cleanup: async () => { + await cleanupOwnedAdoptionFixture({ + ...h, + instances: [h.first, h.second], + }); + await cleanupRetainedBuildFixtureImages({ + ...h, + effect: async (args) => { + await h.effect(args); + }, + journal: async (pending) => { + await saveFixtureBuildRecovery(h, pending); + }, + }); + }, + secondaryFailure: () => + ctx.retainFixtures( + "Retained build exact-owned cleanup failed; original resource and image evidence retained" + ), + }); + }, +}; async function runDependencyWorktrees( ctx: ScenarioContext, diff --git a/tests/helpers/docker-image-format.ts b/tests/helpers/docker-image-format.ts new file mode 100644 index 000000000..a34e65947 --- /dev/null +++ b/tests/helpers/docker-image-format.ts @@ -0,0 +1,201 @@ +import { constants } from "node:fs"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { createNativeComposeProbe } from "../../src/lib/native-compose-ownership.ts"; + +export const DOCKER_FORMAT_IMAGE_ID = `sha256:${"c".repeat(64)}`; +const HASH = /^[a-f0-9]{64}$/; +const INSPECT_PATH = `/v1.41/images/${DOCKER_FORMAT_IMAGE_ID}/json`; +function refuse(): never { + throw new Error("Synthetic Docker formatting fixture is unsafe or changed."); +} +function sameIdentity( + a: Awaited>, + b: Awaited> +) { + return ( + a.dev === b.dev && + a.ino === b.ino && + a.mode === b.mode && + a.uid === b.uid && + a.gid === b.gid && + a.nlink === b.nlink && + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs + ); +} +async function binaryIdentity(path: string, hash: string) { + if ( + !(isAbsolute(path) && HASH.test(hash)) || + (await realpath(path)) !== path + ) { + refuse(); + } + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const stat = await file.stat(); + if ( + !stat.isFile() || + stat.nlink !== 1 || + (stat.mode & 0o111) === 0 || + (stat.mode & 0o022) !== 0 || + stat.size <= 0 || + stat.size > 128 * 1024 * 1024 + ) { + refuse(); + } + const bytes = await file.readFile(), + current = await file.stat(), + named = await lstat(path); + if ( + bytes.byteLength !== stat.size || + new Bun.CryptoHasher("sha256").update(bytes).digest("hex") !== hash || + !named.isFile() || + named.isSymbolicLink() || + !sameIdentity(stat, current) || + !sameIdentity(stat, named) || + (await realpath(path)) !== path + ) { + refuse(); + } + return stat; + } finally { + await file.close(); + } +} + +/** + * The pinned real Docker client formats only synthetic image inspect JSON from this + * owned Unix socket. No request is forwarded and no caller config is inherited. + * The existing bounded probe settles each client and both output pipes. + */ +export async function withDockerImageFormatFixture(opts: { + readonly binary: string; + readonly sha256: string; + readonly image: Readonly>; + readonly observe: (probe: (format: string) => Promise) => Promise; +}): Promise { + const binary = opts.binary, + sha256 = opts.sha256, + response = JSON.stringify(opts.image), + observe = opts.observe; + if (response.length > 64 * 1024) { + refuse(); + } + const anchor = await binaryIdentity(binary, sha256); + const root = await realpath(await mkdtemp(join(tmpdir(), "docker-fmt-"))); + await chmod(root, 0o700); + const rootIdentity = await lstat(root), + config = join(root, "config"), + socket = join(root, "engine.sock"), + alias = join(root, "docker"); + const assertRoot = async () => { + const current = await lstat(root); + if ( + !current.isDirectory() || + current.isSymbolicLink() || + current.dev !== rootIdentity.dev || + current.ino !== rootIdentity.ino || + current.uid !== rootIdentity.uid || + current.mode !== rootIdentity.mode || + (await realpath(root)) !== root + ) { + refuse(); + } + }; + let server: ReturnType | undefined; + let unexpected = false; + try { + await mkdir(config, { mode: 0o700 }); + await writeFile(join(config, "config.json"), "{}", { + mode: 0o600, + flag: "wx", + }); + await symlink(binary, alias); + server = Bun.serve({ + unix: socket, + fetch(request) { + const url = new URL(request.url); + if ( + url.pathname === "/_ping" && + ["HEAD", "GET"].includes(request.method) + ) { + return new Response("OK", { + headers: { "Api-Version": "1.41", Ostype: "linux" }, + }); + } + if (request.method === "GET" && url.pathname === INSPECT_PATH) { + return new Response(response, { + headers: { "Content-Type": "application/json" }, + }); + } + unexpected = true; + return new Response("Synthetic fixture refuses this request", { + status: 403, + }); + }, + }); + const socketIdentity = await lstat(socket), + environment = process.env; + let owner: ReturnType; + try { + // The owner captures this selection synchronously before returning. + process.env = { + PATH: root, + HOME: root, + DOCKER_CONFIG: config, + DOCKER_HOST: `unix://${socket}`, + DOCKER_API_VERSION: "1.41", + }; + owner = createNativeComposeProbe({ timeoutMs: 15_000 }); + } finally { + process.env = environment; + } + const result = await observe(async (format) => { + await assertRoot(); + const selected = await lstat(socket); + if ( + !selected.isSocket() || + selected.dev !== socketIdentity.dev || + selected.ino !== socketIdentity.ino || + (await realpath(alias)) !== binary + ) { + refuse(); + } + return await owner([ + "image", + "inspect", + "--format", + format, + DOCKER_FORMAT_IMAGE_ID, + ]); + }); + await assertRoot(); + if ( + unexpected || + !sameIdentity(anchor, await binaryIdentity(binary, sha256)) + ) { + refuse(); + } + return result; + } finally { + await server?.stop(true); + await assertRoot(); + await rm(root, { recursive: true }); + } +} diff --git a/tests/helpers/retained-build-adoption.ts b/tests/helpers/retained-build-adoption.ts new file mode 100644 index 000000000..2fcbd7283 --- /dev/null +++ b/tests/helpers/retained-build-adoption.ts @@ -0,0 +1,228 @@ +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; +import { runLegacyComposeRetainedOperation } from "../../src/lib/native-compose-adoption-execution.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "../../src/lib/native-compose-adoption-generation.ts"; +import { parseLegacyComposeAdoptionReceipt } from "../../src/lib/native-compose-adoption-receipt.ts"; +import { restoreEnv } from "./env.ts"; +import { managedEnvCompilerFixture } from "./managed-env-compiler.ts"; + +export const BUILD_CANARY = "synthetic-private-retained-build"; +const CREATED = "2026-01-01T01:02:03Z"; +const CONTAINER = "a".repeat(64), + NETWORK = "b".repeat(64), + IMAGE = `sha256:${"c".repeat(64)}`, + HASH = "d".repeat(64); +function identity(value: unknown) { + if ( + !isRecord(value) || + typeof value.dev !== "number" || + typeof value.ino !== "number" || + !Number.isSafeInteger(value.dev) || + !Number.isSafeInteger(value.ino) || + value.dev < 0 || + value.ino <= 0 + ) { + throw new Error("Synthetic identity is invalid; values omitted."); + } + return { dev: value.dev, ino: value.ino }; +} +/** Source-to-model transport control only. No real daemon, image build or SQL proof. */ +export async function retainedBuildFixture() { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-owner-")) + ); + const root = join(outer, "checkout"); + await mkdir(join(root, ".hack"), { recursive: true }); + await mkdir(join(root, ".git")); + await mkdir(join(root, "src")); + await writeFile(join(root, "src/marker"), BUILD_CANARY); + await writeFile(join(root, "Dockerfile"), "FROM scratch\nCOPY src /source\n"); + await writeFile(join(root, ".dockerignore"), "**\n!Dockerfile\n!src\n"); + const config = '{"name":"fixture"}'; + const compose = JSON.stringify({ + name: "fixture", + services: { db: { build: "..", volumes: ["data:/data"] } }, + volumes: { data: { name: "fixture_original_data" } }, + }); + await writeFile(join(root, ".hack/hack.config.json"), config); + await writeFile(join(root, ".hack/docker-compose.yml"), compose); + await writeFile(join(outer, "accepted-compose"), compose); + const model = { + running: false, + image: IMAGE, + tag: IMAGE, + imageBirth: CREATED, + containerBirth: CREATED, + volumeBirth: CREATED, + hash: HASH, + reference: "fixture-db", + engine: "synthetic-retained-build-engine", + partial: false, + sourceRace: false, + hold: false, + probeHold: false, + imageExists: true, + row: BUILD_CANARY, + }; + const modelPath = join(outer, "model.json"), + commands = join(outer, "commands"), + binary = join(outer, "docker"); + await writeFile(modelPath, JSON.stringify(model)); + await writeFile( + binary, + `#!${process.execPath} +import {appendFileSync,readFileSync,writeFileSync} from 'node:fs'; +const outer=${JSON.stringify(outer)}, root=${JSON.stringify(root)}, id=${JSON.stringify(CONTAINER)}, network=${JSON.stringify(NETWORK)}; +const args=process.argv.slice(2), [kind,action]=args, m=JSON.parse(readFileSync(outer+'/model.json','utf8')); +appendFileSync(outer+'/commands',JSON.stringify(args)+'\\n'); +function red(){writeFileSync(outer+'/unexpected','unowned action');process.exit(99);} +const volume='fixture_original_data', born=${JSON.stringify(CREATED)}; +if(kind==='info' && args.length===3 && action==='--format') {console.log(args[2]==='{{json .ID}}' ? JSON.stringify(m.engine) : JSON.stringify({id:m.engine,os:'linux'}));process.exit(0);} +if(kind==='compose') { + const path=args[10], service=args.at(-1); + if(args[1]!=='--project-name' || args[2]!=='fixture' || args[3]!=='--project-directory' || args[4]!==root+'/.hack' || args[5]!=='--env-file' || args[6]!=='/dev/null' || args[7]!=='--profile' || args[8]!=='*' || args[9]!=='--file' || args[11]!=='config' || args[12]!=='--no-env-resolution') red(); + const prefix=root+'/.hack/.internal/legacy-compose-adoption-v1/generations/', tail=path.slice(prefix.length).split('/'); + if(path!==root+'/.hack/docker-compose.yml' && !(path.startsWith(prefix) && tail.length===2 && /^[a-f0-9]{32}$/.test(tail[0]) && tail[1]==='legacy-compose.yml')) red(); + if(readFileSync(path,'utf8')!==readFileSync(outer+'/accepted-compose','utf8')) red(); + if(args.length===15 && args[13]==='--images' && service==='db') console.log(m.reference); + else if(args.length===15 && args[13]==='--hash' && service==='*') console.log('db '+m.hash); + else red();process.exit(0); +} +if(kind==='image' && action==='inspect' && args.length===5 && args[2]==='--format') { + if(!m.imageExists) process.exit(1); + if(!args[3].includes('createdAt') || args[3].includes('Env')) red(); + const selected=args[4];if(selected!==m.reference && selected!==m.image) process.exit(1); + console.log(JSON.stringify({id:selected===m.reference ? m.tag : m.image,createdAt:m.imageBirth}));process.exit(0); +} +if(kind==='container' && ['start','stop'].includes(action)) { + if(args.length!==3 || args[2]!==id) red(); + if(m.hold) {writeFileSync(outer+'/effect-started',String(process.pid));await Bun.sleep(60000);} + m.running=action==='start';if(m.sourceRace) {writeFileSync(root+'/src/marker','changed');} + writeFileSync(outer+'/model.json',JSON.stringify(m));process.exit(m.partial ? 7 : 0); +} +if(!['container','volume','network'].includes(kind) || !['ls','inspect'].includes(action) || !args.includes('--format')) red(); +if(m.probeHold) {writeFileSync(outer+'/probe-started',String(process.pid));await Bun.sleep(60000);} +if(action==='ls') { + const row=kind==='container' ? {id,name:'fixture-db-1',project:'fixture'} : kind==='network' ? {id:network,name:'fixture_default',project:'fixture'} : {id:volume,name:volume,project:'fixture'}; + console.log(JSON.stringify(row));process.exit(0); +} +const selected=args.at(-1), format=args[3]; +if(kind==='container') { + if(selected!==id) process.exit(1); + if(format.includes('.Config.Image')) console.log(JSON.stringify({id,image:m.image,reference:m.reference,createdAt:m.containerBirth})); + else if(format.includes('config-hash')) console.log(JSON.stringify({id,hash:m.hash})); + else if(format.includes('.Mounts')) console.log(JSON.stringify({id,name:'/fixture-db-1',project:'fixture',native:'',service:'db',number:'1',oneoff:'False',running:m.running,workingDir:root+'/.hack',configFiles:root+'/.hack/docker-compose.yml',mounts:[{type:'volume',name:volume,source:'/var/lib/docker/volumes/original/_data',target:'/data',rw:true}],networks:[{name:'fixture_default',id:network}]})); + else if(format.includes('.State.Running')) console.log(JSON.stringify({id,running:m.running,paused:false,status:m.running ? 'running' : 'exited',...(format.includes('Health') ? {health:''} : {})})); + else red(); +} else if(kind==='volume' && selected===volume) console.log(JSON.stringify({id:volume,name:volume,project:'fixture',native:'',storage:'data',createdAt:m.volumeBirth,driver:'local',scope:'local',mountpoint:'/var/lib/docker/volumes/original/_data',options:null})); +else if(kind==='network' && selected===network) console.log(JSON.stringify({id:network,name:'fixture_default',project:'fixture',native:'',logical:'default',createdAt:born,driver:'bridge',scope:'local',internal:false,containers:m.running ? [id] : []})); +else red(); +` + ); + await chmod(binary, 0o700); + const previousPath = process.env.PATH; + process.env.PATH = outer; + const compiler = await managedEnvCompilerFixture(join(outer, "compiler")); + function isModel(value: unknown): value is typeof model { + return ( + isRecord(value) && + Object.keys(value).sort().join() === Object.keys(model).sort().join() && + Object.entries(model).every( + ([key, original]) => typeof value[key] === typeof original + ) + ); + } + return { + root, + outer, + config, + compose, + model, + compiler, + acceptCompose: (text: string) => + writeFile(join(outer, "accepted-compose"), text), + persist: () => writeFile(modelPath, JSON.stringify(model)), + readModel: async () => { + const value: unknown = JSON.parse(await readFile(modelPath, "utf8")); + if (!isModel(value)) { + throw new Error("Synthetic model is invalid; values omitted."); + } + return value; + }, + commands: async (): Promise => + (await readFile(commands, "utf8")) + .trim() + .split("\n") + .map((line) => { + const value: unknown = JSON.parse(line); + if ( + !( + Array.isArray(value) && + value.every((part): part is string => typeof part === "string") + ) + ) { + throw new Error( + "Synthetic command ledger is invalid; values omitted." + ); + } + return value; + }), + receipt: async () => { + const value: unknown = JSON.parse( + await readFile( + join(root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json"), + "utf8" + ) + ); + if (!(isRecord(value) && isRecord(value.checkout))) { + throw new Error("Synthetic receipt is invalid; values omitted."); + } + return parseLegacyComposeAdoptionReceipt(value, { + root: identity(value.checkout.root), + project: identity(value.checkout.project), + git: identity(value.checkout.git), + }); + }, + store: (signal?: AbortSignal) => + openLegacyComposeAdoptedGenerationStore({ projectRoot: root, signal }), + operation: async ( + store: Awaited< + ReturnType + >, + generation: Parameters[0]["generation"], + operation: "start" | "stop", + recover = false + ) => { + const deadline = Date.now() + 15_000; + return await store.withMutation({ + generation, + operation, + recover, + services: [], + binary: compiler, + deadline, + run: (input) => + runLegacyComposeRetainedOperation({ + input, + operation, + deadline, + signal: new AbortController().signal, + }), + }); + }, + cleanup: async () => { + restoreEnv("PATH", previousPath); + await rm(outer, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/native-compose-adoption-build-diagnostics.test.ts b/tests/native-compose-adoption-build-diagnostics.test.ts new file mode 100644 index 000000000..3490f8382 --- /dev/null +++ b/tests/native-compose-adoption-build-diagnostics.test.ts @@ -0,0 +1,243 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { observeRetainedBuildFixtureCli } from "./e2e/scenarios/native-compose-adoption-build-diagnostics.ts"; + +const CANARY = "private-build-cli-diagnostic-canary"; +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-cli-diagnostic-")) + ); + await chmod(root, 0o700); + const messages: string[] = []; + const context = { + tempRoot: root, + log: (message: string) => messages.push(message), + }; + return { + root, + messages, + context, + close: () => rm(root, { recursive: true, force: true }), + }; +} +function observation< + T extends { + exitCode: number; + timedOut: boolean; + stdout: string; + stderr: string; + }, +>(context: { tempRoot: string; log?: (message: string) => void }, value: T) { + return observeRetainedBuildFixtureCli({ + context, + mode: "root-specific", + args: ["down", "--recover", "--json"], + driftAfterStart: false, + run: async () => value, + }); +} +const refused = { + exitCode: 94, + timedOut: false, + stdout: JSON.stringify({ + ok: false, + error: { code: "E_STATE", message: CANARY }, + private: CANARY, + }), + stderr: "retained-build-refused stage=mutation-admission code=94\n", +}; + +test("retained build CLI captures a nonzero result before caller assertions and preserves result identity", async () => { + const h = await fixture(); + try { + expect(await observation(h.context, refused)).toBe(refused); + const directory = join(h.root, "retained-build-cli"); + const files = (await readdir(directory)).sort(); + expect(files).toEqual(["0001.json", "0002.json", "0003.json"]); + const rows = await Promise.all( + files.map(async (file) => { + const path = join(directory, file); + expect((await stat(path)).mode & 0o777).toBe(0o600); + return JSON.parse(await readFile(path, "utf8")); + }) + ); + expect(rows.map((row) => row.stage)).toEqual([ + "begin", + "result", + "settled", + ]); + expect(rows[1]).toMatchObject({ + invocation: 1, + mode: "root-specific", + operation: "recover-stop", + exitCode: 94, + timedOut: false, + code: "E_STATE", + readGuardRefused: false, + mutationGuardRefused: true, + }); + expect( + rows.every( + (row) => Number.isSafeInteger(row.elapsedMs) && row.elapsedMs >= 0 + ) + ).toBe(true); + expect(rows[2].elapsedMs).toBeGreaterThanOrEqual(rows[0].elapsedMs); + expect(JSON.stringify(rows)).not.toContain(CANARY); + expect(h.messages.join("\n")).not.toContain(CANARY); + expect(h.messages.join("\n")).not.toContain(h.root); + expect(h.messages.join("\n")).not.toContain("--recover"); + } finally { + await h.close(); + } +}); + +test("retained build CLI preserves the original thrown error and records its failing invocation without its contents", async () => { + const h = await fixture(); + try { + await observation(h.context, { + ...refused, + exitCode: 0, + stdout: '{"ok":true}', + stderr: "", + }); + const original = new Error(CANARY); + let observed: unknown; + try { + await observeRetainedBuildFixtureCli({ + context: h.context, + mode: "hack-default", + args: ["up", "--detach", "--json"], + driftAfterStart: true, + run: async () => { + throw original; + }, + }); + } catch (error) { + observed = error; + } + expect(observed).toBe(original); + const row = JSON.parse( + await readFile(join(h.root, "retained-build-cli/0005.json"), "utf8") + ); + expect(row).toMatchObject({ + invocation: 2, + operation: "start-with-context-drift", + mode: "hack-default", + stage: "thrown", + }); + expect(h.messages.join("\n")).toContain('"code":"none"'); + expect(h.messages.join("\n")).not.toContain(CANARY); + expect(h.messages.join("\n")).not.toContain(original.stack ?? CANARY); + } finally { + await h.close(); + } +}); + +test("retained build CLI omits unknown labels, codes, malformed replies and oversized private replies", async () => { + const h = await fixture(); + try { + for (const stdout of [ + CANARY, + JSON.stringify({ ok: false, error: { code: CANARY } }), + CANARY.repeat(5000), + ]) { + const result = { ...refused, stdout, stderr: CANARY }; + expect( + await observeRetainedBuildFixtureCli({ + context: h.context, + mode: CANARY, + args: [CANARY], + driftAfterStart: false, + run: async () => result, + }) + ).toBe(result); + } + const results = h.messages + .map((message) => JSON.parse(message.slice("retained-build-cli ".length))) + .filter((row) => row.stage === "result"); + expect(results).toHaveLength(3); + for (const row of results) { + expect(row).toMatchObject({ + mode: "unavailable", + operation: "unavailable", + code: "unavailable", + readGuardRefused: false, + mutationGuardRefused: false, + }); + } + expect(h.messages.join("\n")).not.toContain(CANARY); + } finally { + await h.close(); + } +}); + +test("retained build CLI treats unreadable result fields as unavailable without replacing the result", async () => { + const h = await fixture(); + try { + const result = { ...refused }; + Object.defineProperty(result, "stdout", { + get: () => { + throw new Error(CANARY); + }, + }); + expect(await observation(h.context, result)).toBe(result); + const row = JSON.parse( + await readFile(join(h.root, "retained-build-cli/0002.json"), "utf8") + ); + expect(row).toMatchObject({ + exitCode: "unavailable", + timedOut: "unavailable", + code: "unavailable", + readGuardRefused: "unavailable", + mutationGuardRefused: "unavailable", + }); + expect(h.messages.join("\n")).not.toContain(CANARY); + } finally { + await h.close(); + } +}); + +test("retained build CLI capture and logger failures preserve success and the original thrown error", async () => { + const h = await fixture(); + try { + const blocked = join(h.root, "not-a-directory"); + await writeFile(blocked, CANARY, { mode: 0o600 }); + const context = { + tempRoot: blocked, + log: () => { + throw new Error(CANARY); + }, + }; + expect(await observation(context, refused)).toBe(refused); + const original = new Error(CANARY); + let observed: unknown; + try { + await observeRetainedBuildFixtureCli({ + context, + mode: "root-specific", + args: ["down", "--json"], + driftAfterStart: false, + run: async () => { + throw original; + }, + }); + } catch (error) { + observed = error; + } + expect(observed).toBe(original); + expect(await readFile(blocked, "utf8")).toBe(CANARY); + } finally { + await h.close(); + } +}); diff --git a/tests/native-compose-adoption-build-evidence.test.ts b/tests/native-compose-adoption-build-evidence.test.ts new file mode 100644 index 000000000..050db0b83 --- /dev/null +++ b/tests/native-compose-adoption-build-evidence.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from "bun:test"; +import { lstat, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createRetainedBuildFixtureEvidence, + qualifyRetainedBuildFixtureBuilder, + qualifyRetainedBuildFixtureCopy, + RETAINED_BUILD_BOOTSTRAP_ENV, + RETAINED_BUILD_BUILDER_FORMAT, +} from "./e2e/scenarios/native-compose-adoption-build-evidence.ts"; +import { retainedBuildFixtureCopiedFiles } from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; + +const host = "unix:///synthetic-owned/docker.sock"; +const env = { ...RETAINED_BUILD_BOOTSTRAP_ENV, DOCKER_HOST: host }; +const version = `github.com/docker/buildx v0.28.0 ${"a".repeat(40)}\n`; +const builder = { + name: "default", + driver: "docker", + nodes: [{ name: "default", endpoint: "default" }], +}; + +test("retained builder qualification uses fixed read-only Buildx queries and the default Docker driver", async () => { + const calls: (readonly string[])[] = []; + const stages: string[] = []; + await qualifyRetainedBuildFixtureBuilder({ + mode: "root-specific", + env, + record: async (_mode, stage) => { + stages.push(stage); + }, + read: async (args) => { + calls.push(args); + return args[1] === "version" ? version : `\n${JSON.stringify(builder)}\n`; + }, + }); + expect(calls).toEqual([ + ["buildx", "version"], + [ + "buildx", + "ls", + "--timeout", + "10s", + "--format", + RETAINED_BUILD_BUILDER_FORMAT, + ], + ]); + expect(stages).toEqual(["builder-begin", "builder-qualified"]); + expect( + calls + .flat() + .some((arg) => ["create", "use", "build", "--bootstrap"].includes(arg)) + ).toBe(false); +}); + +for (const [name, changed] of [ + ["disabled BuildKit", { ...env, DOCKER_BUILDKIT: "0" }], + ["foreign builder", { ...env, BUILDX_BUILDER: "foreign" }], + ["foreign context", { ...env, DOCKER_CONTEXT: "foreign" }], + ["remote socket", { ...env, DOCKER_HOST: "tcp://foreign:2375" }], +] as const) { + test(`retained builder refuses unqualified transport ${name}`, async () => { + let reads = 0; + await expect( + qualifyRetainedBuildFixtureBuilder({ + mode: "hack-default", + env: changed, + record: async () => {}, + read: async () => { + reads += 1; + return version; + }, + }) + ).rejects.toThrow("values omitted"); + expect(reads).toBe(0); + }); +} + +test.each([ + { ...builder, driver: "docker-container" }, + { ...builder, name: "foreign" }, + { ...builder, nodes: [{ name: "default", endpoint: "ssh://foreign" }] }, + { ...builder, nodes: [...builder.nodes, ...builder.nodes] }, + { ...builder, nodes: [] }, +])("retained builder refuses remote/custom/multiple-node facts before qualification", async (row) => { + const stages: string[] = []; + await expect( + qualifyRetainedBuildFixtureBuilder({ + mode: "root-specific", + env, + record: async (_mode, stage) => { + stages.push(stage); + }, + read: async (args) => + args[1] === "version" ? version : JSON.stringify(row), + }) + ).rejects.toThrow("values omitted"); + expect(stages).toEqual(["builder-begin"]); +}); + +for (const mode of ["root-specific", "hack-default"] as const) { + test(`complete ${mode} COPY bytes are retained before an unchanged oracle refusal`, async () => { + const root = await mkdtemp(join(tmpdir(), "retained-copy-evidence-")); + try { + const record = createRetainedBuildFixtureEvidence({ tempRoot: root }); + const text = `wrong-marker ${"a".repeat(64)}\n\n`; + await expect( + qualifyRetainedBuildFixtureCopy({ + mode, + record, + read: async () => text, + }) + ).rejects.toThrow("values omitted"); + const directory = join(root, "retained-build-stages"); + const paths = (await readdir(directory)).sort(); + expect(paths).toHaveLength(2); + const row = JSON.parse( + await readFile(join(directory, paths[1] ?? ""), "utf8") + ); + expect(row.stage).toBe("copy-captured"); + expect(row.byteLength).toBe(Buffer.byteLength(text)); + expect(Buffer.from(row.copyBase64, "base64").toString()).toBe(text); + expect((await lstat(join(directory, paths[1] ?? ""))).mode & 0o777).toBe( + 0o600 + ); + const valid = `${retainedBuildFixtureCopiedFiles(mode) + .map((item) => `${item.path} ${item.hash}`) + .join("\n")}\n`; + await qualifyRetainedBuildFixtureCopy({ + mode, + record, + read: async () => valid, + }); + const final = JSON.parse( + await readFile(join(directory, "0005.json"), "utf8") + ); + expect(final.stage).toBe("copy-qualified"); + const captured = JSON.parse( + await readFile(join(directory, "0004.json"), "utf8") + ); + expect(Buffer.from(captured.copyBase64, "base64").toString()).toBe(valid); + expect(JSON.stringify(final)).not.toContain(valid); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/tests/native-compose-adoption-build-fixture.test.ts b/tests/native-compose-adoption-build-fixture.test.ts new file mode 100644 index 000000000..fb3882609 --- /dev/null +++ b/tests/native-compose-adoption-build-fixture.test.ts @@ -0,0 +1,967 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { acquireLegacyComposeBuildSource } from "../src/lib/native-compose-adoption-build.ts"; +import { acquireLegacyAdoptionSourceInputs } from "../src/lib/native-config-import-inputs.ts"; +import { runCommand } from "./e2e/harness.ts"; +import { + assertRetainedBuildFixtureCopy, + assertRetainedFixtureImageUnchanged, + prepareRetainedBuildFixtureSources, + RETAINED_BUILD_OBJECT_FORMAT, + retainedBuildFixtureComposeVersion, + retainedBuildFixtureCopiedFiles, + retainedBuildFixtureDefinition, + retainedBuildFixtureImage, + retainedBuildFixtureMutationAllowed, + retainedBuildFixtureObjectGraph, + retainedBuildFixtureReadAllowed, + retainedBuildFixtureSourceSnapshot, +} from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; +import { captureAdoptionDependencyFirstPrepare } from "./e2e/scenarios/native-compose-adoption-dependency-staged-read.ts"; +import { + buildFixtureCli, + cleanupRetainedBuildFixtureImages, + nativeComposeAdoptionBuildWorktreesScenario, +} from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; +import { retainedBuildFixture } from "./helpers/retained-build-adoption.ts"; + +const CANARY = "synthetic-private-builder-canary"; +const id = "a".repeat(64), + other = "b".repeat(64), + image = `sha256:${"c".repeat(64)}`; +const birth = "2026-10-08T20:00:00.123456789Z"; +const scope = { + projectRoot: "/owned/root", + project: "fixture", + containerIds: [id, other], + networkId: "d".repeat(64), + volumeName: "fixture_data", + images: [{ id: image, reference: "fixture-db" }], +}; +const prefix = [ + "compose", + "--project-name", + "fixture", + "--project-directory", + "/owned/root/.hack", + "--env-file", + "/dev/null", + "--profile", + "*", + "--file", +]; +const anchor = { + id: "e".repeat(32), + manifest: { dev: 1, ino: 2, hash: "f".repeat(64) }, +}; +const receipt = { + adoption_receipt_version: 9, + prepared: anchor, + publication: null, + pendingOperation: { + generation: anchor, + operation: "stop", + services: ["db", "worker"], + }, +}; +const imageRow = { + id: image, + created: birth, + owner: "fixture", + stage: "retained", + tags: ["fixture-db:latest"], + digests: null, +}; + +test("retained build acceptance is explicit and failure-preserving", () => { + expect(nativeComposeAdoptionBuildWorktreesScenario).toMatchObject({ + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + }); +}); +for (const mode of ["root-specific", "hack-default"] as const) { + test(`real ${mode} fixture authoring issues a qualified current source with explicit COPY oracle`, async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-fixture-source-")) + ); + try { + await mkdir(join(root, ".git")); + await mkdir(join(root, ".hack")); + await prepareRetainedBuildFixtureSources({ root, name: "fixture", mode }); + const configText = '{"name":"fixture"}'; + const composeText = JSON.stringify({ + name: "fixture", + services: { + db: { + build: retainedBuildFixtureDefinition(mode), + volumes: ["data:/data"], + }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/hack.config.json"), configText); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const inputs = await acquireLegacyAdoptionSourceInputs({ + projectRoot: root, + }); + if (!inputs.ok) { + throw new Error( + "Synthetic fixture source acquisition refused; values omitted." + ); + } + const captured = await acquireLegacyComposeBuildSource({ + source: inputs, + }); + expect(captured.candidate).toMatchObject({ + services: { + db: { + build: + mode === "root-specific" + ? { + context: ".", + dockerfile: ".hack/toolchain/Dockerfile", + target: "retained", + } + : { context: ".hack" }, + }, + }, + }); + const context = captured.proof.contexts[0]; + expect(context).toBeDefined(); + const files = context?.nodes + .filter((node) => node.kind === "file") + .map((node) => ({ + path: node.path, + hash: node.kind === "file" ? node.hash : "", + })); + expect(files).toEqual(retainedBuildFixtureCopiedFiles(mode)); + const copy = retainedBuildFixtureCopiedFiles(mode) + .map((row) => `${row.path} ${row.hash}`) + .join("\n"); + expect(() => + assertRetainedBuildFixtureCopy({ mode, text: copy }) + ).not.toThrow(); + expect(() => + assertRetainedBuildFixtureCopy({ + mode, + text: `${copy}\n.hack/.internal/private ${"a".repeat(64)}`, + }) + ).toThrow("values omitted"); + expect(() => assertRetainedBuildFixtureCopy({ mode, text: "" })).toThrow( + "values omitted" + ); + const source = await retainedBuildFixtureSourceSnapshot({ root, mode }); + await captured.assertFresh(); + expect(await retainedBuildFixtureSourceSnapshot({ root, mode })).toBe( + source + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} +for (const mode of ["root-specific", "hack-default"] as const) { + test(`actual ${mode} preview transport refuses an injected build before forwarding`, async () => { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-preview-transport-")) + ); + const root = join(outer, "checkout"); + const engine = join(outer, "synthetic-engine"); + const forwarded = join(outer, "engine-forwarded"); + const previewArgs = ["config", "adopt", "--dry-run", "--stop", "--json"]; + const instance = { + root, + name: "fixture", + marker: "synthetic-sql-marker", + basicBuild: mode, + }; + try { + await chmod(outer, 0o700); + await mkdir(root, { mode: 0o700 }); + await mkdir(join(root, ".hack"), { mode: 0o700 }); + await writeFile( + join(root, ".hack/docker-compose.yml"), + JSON.stringify({ + services: { db: { build: retainedBuildFixtureDefinition(mode) } }, + }), + { mode: 0o600 } + ); + await writeFile( + engine, + `#!${process.execPath}\nawait Bun.write(${JSON.stringify(forwarded)},'unexpected-forwarding');process.exit(0);\n`, + { mode: 0o700 } + ); + let invocations = 0; + const cli: Parameters[0]["cli"] = async ( + selected, + args, + extra + ) => { + invocations += 1; + expect(selected).toBe(instance); + expect(args).toEqual(previewArgs); + return await runCommand({ + argv: ["docker", "compose", "build", "db"], + cwd: root, + env: extra, + timeoutMs: 5000, + }); + }; + const result = await buildFixtureCli( + { + ctx: { tempRoot: outer }, + engine, + engineId: "synthetic-daemon", + baseImage: `sha256:${"1".repeat(64)}`, + anchors: new Map([ + [ + instance, + { + source: "synthetic-original-source", + resources: { + container: [ + { id, service: "db" }, + { id: other, service: "worker" }, + ], + network: [{ id: scope.networkId }], + volume: [{ id: scope.volumeName }], + }, + }, + ], + ]), + builtImages: new Map([ + [ + instance, + retainedBuildFixtureImage({ + value: imageRow, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }), + ], + ]), + cli, + }, + instance, + previewArgs + ); + expect(invocations).toBe(1); + expect(result.timedOut).toBe(false); + expect(result.exitCode).toBe(93); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "retained-build-refused stage=read-admission code=93\n" + ); + expect(await Bun.file(forwarded).exists()).toBe(false); + expect( + await Bun.file( + join(root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json") + ).exists() + ).toBe(false); + } finally { + await rm(outer, { recursive: true, force: true }); + } + }); +} +test("image fixture capture requires a new sole-tag selected-stage image and pins birth", () => { + const captured = retainedBuildFixtureImage({ + value: imageRow, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }); + expect(captured).toMatchObject({ + id: image, + created: birth, + tag: "fixture-db:latest", + }); + expect(() => + assertRetainedFixtureImageUnchanged({ + captured, + current: { ...captured, created: "2026-10-08T21:00:00Z" }, + }) + ).toThrow("values omitted"); + for (const row of [ + { ...imageRow, owner: CANARY }, + { ...imageRow, stage: "wrong" }, + { ...imageRow, tags: ["fixture-db:latest", "foreign:latest"] }, + { ...imageRow, digests: [CANARY] }, + { ...imageRow, created: CANARY }, + ]) { + expect(() => + retainedBuildFixtureImage({ + value: row, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }) + ).toThrow("values omitted"); + } + expect(() => + retainedBuildFixtureImage({ + value: imageRow, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [image], + }) + ).toThrow("values omitted"); +}); +test("fixture image capture pins the exact observed same-ID repository digest", () => { + const digest = `fixture-db@${image}`; + const captured = retainedBuildFixtureImage({ + value: { ...imageRow, digests: [digest] }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }); + expect(captured.digests).toEqual([digest]); + expect(Object.isFrozen(captured.digests)).toBe(true); + expect(() => + assertRetainedFixtureImageUnchanged({ + captured, + current: { ...captured, digests: null }, + }) + ).toThrow("values omitted"); + for (const digests of [ + [CANARY], + [`other-db@${image}`], + [`fixture-db@sha256:${"d".repeat(64)}`], + [digest, digest], + [digest, CANARY], + ]) { + expect(() => + retainedBuildFixtureImage({ + value: { ...imageRow, digests }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }) + ).toThrow("values omitted"); + } +}); + +const baseBuildImage = `sha256:${"1".repeat(64)}`; +const firstBuildParent = `sha256:${"2".repeat(64)}`; +const secondBuildParent = `sha256:${"3".repeat(64)}`; +const noComposeImageLabels = { + composeProject: null, + composeService: null, + composeVersion: null, +}; +function capturedBuildGraph() { + const selected = retainedBuildFixtureImage({ + value: { ...imageRow, digests: [`fixture-db@${image}`] }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [baseBuildImage], + }); + const labels = [ + "hack.e2e.retained-build.owner", + "hack.e2e.retained-build.stage", + ]; + const values = [ + { + ...imageRow, + ...noComposeImageLabels, + parent: secondBuildParent, + size: 1000, + digests: [`fixture-db@${image}`], + labelNames: [...labels, "com.docker.compose.image.builder"], + }, + { + ...imageRow, + ...noComposeImageLabels, + id: firstBuildParent, + parent: baseBuildImage, + size: 800, + tags: [], + digests: [], + labelNames: labels, + }, + { + ...imageRow, + ...noComposeImageLabels, + id: secondBuildParent, + parent: firstBuildParent, + size: 900, + tags: [], + digests: [], + labelNames: labels, + }, + ] as const; + const capture = ( + rows: readonly unknown[] = values, + originals = [baseBuildImage], + composeVersion?: string + ) => + retainedBuildFixtureObjectGraph({ + values: rows, + selected, + originalImageIds: originals, + baseImage: baseBuildImage, + ...(composeVersion !== undefined ? { composeVersion } : {}), + }); + return { selected, values, capture }; +} +test("explicit builder graph captures only the owned child-to-base chain", () => { + const { capture, values } = capturedBuildGraph(); + const graph = capture(); + expect(graph.map((row) => row.id)).toEqual([ + image, + secondBuildParent, + firstBuildParent, + ]); + expect(Object.isFrozen(graph)).toBe(true); + expect(Object.isFrozen(graph[0]?.labelNames)).toBe(true); + expect(capture([{ ...values[0], parent: "" }]).map((row) => row.id)).toEqual([ + image, + ]); + for (const rows of [ + [values[0], values[1]], + [values[0], values[1], { ...values[2], owner: CANARY }], + [values[0], values[1], { ...values[2], parent: image }], + [values[0], { ...values[1], tags: ["foreign:latest"] }, values[2]], + [...values, { ...values[1], id: `sha256:${"4".repeat(64)}` }], + [ + { ...values[0], labelNames: [...values[0].labelNames, CANARY] }, + values[1], + values[2], + ], + [{ ...values[0], parent: "" }, values[1], values[2]], + ]) { + expect(() => capture(rows)).toThrow("values omitted"); + } + expect(() => capture(values, [baseBuildImage, firstBuildParent])).toThrow( + "values omitted" + ); +}); + +test("captured Compose image labels require the exact project, service and selected release", () => { + const { selected, values } = capturedBuildGraph(); + const composeVersion = retainedBuildFixtureComposeVersion("2.40.3\n"); + const publicLabels = [ + "com.docker.compose.project", + "com.docker.compose.service", + "com.docker.compose.version", + ]; + const row = { + ...values[0], + parent: "", + composeProject: "fixture", + composeService: "db", + composeVersion, + labelNames: [...values[0].labelNames, ...publicLabels], + }; + const capture = ( + value: unknown, + version: string | undefined = composeVersion + ) => + retainedBuildFixtureObjectGraph({ + values: [value], + selected, + originalImageIds: [baseBuildImage], + baseImage: baseBuildImage, + ...(version !== undefined ? { composeVersion: version } : {}), + }); + const captured = capture(row); + expect(captured).toHaveLength(1); + expect(captured[0]).toMatchObject({ + id: selected.id, + parent: "", + created: selected.created, + owner: selected.owner, + composeProject: "fixture", + composeService: "db", + composeVersion, + tags: row.tags, + digests: row.digests, + }); + for (const malformed of [ + { ...row, composeProject: CANARY }, + { ...row, composeService: "worker" }, + { ...row, composeVersion: "2.40.4" }, + { ...row, composeVersion: null }, + { + ...row, + labelNames: row.labelNames.filter((name) => name !== publicLabels[2]), + }, + { + ...row, + labelNames: [...row.labelNames, "com.docker.compose.private-canary"], + }, + { ...row, labelNames: [...row.labelNames, CANARY] }, + { ...row, ...noComposeImageLabels, labelNames: values[0].labelNames }, + ]) { + let error: unknown; + try { + capture(malformed); + } catch (caught: unknown) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain("values omitted"); + expect(String(error)).not.toContain(CANARY); + } + expect(() => capture(row, "2.40.4")).toThrow("values omitted"); + expect(() => + retainedBuildFixtureObjectGraph({ + values: [row], + selected, + originalImageIds: [baseBuildImage], + baseImage: baseBuildImage, + }) + ).toThrow("values omitted"); + for (const version of [ + "v2.40.3", + "2.40.3-rc.1", + "2.40.3+local", + "02.40.3", + CANARY, + ]) { + expect(() => retainedBuildFixtureComposeVersion(version)).toThrow( + "values omitted" + ); + } +}); + +function buildCleanupModel(opts?: { + readonly withoutExposedParent?: true; + readonly composeVersion?: string; +}) { + const { selected, values, capture } = capturedBuildGraph(); + const objects = opts?.withoutExposedParent + ? capture( + [ + { + ...values[0], + parent: "", + ...(opts.composeVersion + ? { + composeProject: "fixture", + composeService: "db", + composeVersion: opts.composeVersion, + labelNames: [ + ...values[0].labelNames, + "com.docker.compose.project", + "com.docker.compose.service", + "com.docker.compose.version", + ], + } + : {}), + }, + ], + [baseBuildImage], + opts.composeVersion + ) + : capture(); + const instance = { + root: "/owned/fixture", + name: "fixture", + marker: "sql-marker", + }; + const remaining = new Set(objects.map((row) => row.id)); + const refs = new Set(); + const events: { stage: string; id?: string }[] = []; + let daemon = '"fixture-engine"'; + let afterJournal: (() => void) | undefined; + let afterEffect: (() => void) | undefined; + const current = new Map(objects.map((row) => [row.id, row])); + const controls = { + engineId: daemon, + originalImageIds: [baseBuildImage], + baseImage: baseBuildImage, + builtImages: new Map([[instance, selected]]), + builtImageObjects: new Map([[instance, objects]]), + probe: async (args: readonly string[]) => { + if (args.join() === "info,--format,{{json .ID}}") { + return daemon; + } + if (args.join() === "image,ls,--all,--no-trunc,--format,{{.ID}}") { + return [baseBuildImage, ...remaining].sort().join("\n"); + } + if ( + args[0] === "image" && + args[1] === "inspect" && + args[3] === "{{.Id}}" && + args[4] === "postgres:17.6-alpine" + ) { + return baseBuildImage; + } + if ( + args[0] === "image" && + args[1] === "inspect" && + args[2] === "--format" && + args[3] === RETAINED_BUILD_OBJECT_FORMAT && + args.length === 5 && + args[4] && + remaining.has(args[4]) + ) { + return JSON.stringify(current.get(args[4])); + } + if ( + args[0] === "container" && + args[1] === "ls" && + args[2] === "--all" && + args[3] === "--no-trunc" && + args[4] === "--filter" && + args[5]?.startsWith("ancestor=") && + args[6] === "--format" && + args[7] === "{{.ID}}" && + args.length === 8 + ) { + return refs.has(args[5].slice(9)) ? id : ""; + } + throw new Error("Unexpected fixed fixture read"); + }, + journal: async (value: unknown) => { + if ( + !( + value && + typeof value === "object" && + "stage" in value && + typeof value.stage === "string" + ) + ) { + throw new Error("Missing fixed journal stage"); + } + events.push({ stage: value.stage }); + if (value.stage === "before-image-remove") { + afterJournal?.(); + } + }, + effect: async (args: readonly string[]) => { + expect(args.slice(0, 3)).toEqual(["image", "rm", "--no-prune"]); + const selectedId = args[3]; + if (!(selectedId && remaining.delete(selectedId))) { + throw new Error("Unexpected exact image effect"); + } + events.push({ stage: "remove", id: selectedId }); + afterEffect?.(); + }, + }; + return { + opts: controls, + remaining, + refs, + events, + current, + changeDaemon: () => { + daemon = '"foreign-engine"'; + }, + setAfterJournal: (action: () => void) => { + afterJournal = action; + }, + setAfterEffect: (action: () => void) => { + afterEffect = action; + }, + }; +} +test("owned image cleanup journals and removes exact children before parents without prune", async () => { + const model = buildCleanupModel(); + await cleanupRetainedBuildFixtureImages(model.opts); + expect([...model.remaining]).toEqual([]); + expect(model.events).toEqual( + [image, secondBuildParent, firstBuildParent].flatMap((id) => [ + { stage: "before-image-remove" }, + { stage: "remove", id }, + { stage: "after-image-remove" }, + ]) + ); +}); +test("one final image without exposed Parent retains exact facts and only its ID removal authority", async () => { + const model = buildCleanupModel({ withoutExposedParent: true }); + expect([...model.remaining]).toEqual([image]); + await cleanupRetainedBuildFixtureImages(model.opts); + expect([...model.remaining]).toEqual([]); + expect(model.events).toEqual([ + { stage: "before-image-remove" }, + { stage: "remove", id: image }, + { stage: "after-image-remove" }, + ]); + expect(model.current.get(image)?.parent).toBe(""); + expect(model.current.has(baseBuildImage)).toBe(false); +}); +test("full captured Compose image triplet revalidates through exact nonforce cleanup", async () => { + const model = buildCleanupModel({ + withoutExposedParent: true, + composeVersion: "2.40.3", + }); + await cleanupRetainedBuildFixtureImages(model.opts); + expect([...model.remaining]).toEqual([]); + expect(model.events).toEqual([ + { stage: "before-image-remove" }, + { stage: "remove", id: image }, + { stage: "after-image-remove" }, + ]); + expect(model.current.get(image)).toMatchObject({ + composeProject: "fixture", + composeService: "db", + composeVersion: "2.40.3", + id: image, + parent: "", + }); + expect(model.current.has(baseBuildImage)).toBe(false); +}); +test("full captured Compose image triplet drift after journal permits no cleanup effect", async () => { + const model = buildCleanupModel({ + withoutExposedParent: true, + composeVersion: "2.40.3", + }); + model.setAfterJournal(() => { + const row = model.current.get(image); + if (!row) { + throw new Error("Missing fixed image row"); + } + model.current.set(image, { ...row, composeVersion: "2.40.4" }); + }); + await expect(cleanupRetainedBuildFixtureImages(model.opts)).rejects.toThrow( + "values omitted" + ); + expect(model.events).toEqual([{ stage: "before-image-remove" }]); + expect([...model.remaining]).toEqual([image]); +}); +for (const mode of [ + "reference-after-journal", + "daemon-after-journal", + "changed-birth", + "foreign-after-effect", +] as const) { + test(`owned image cleanup refuses ${mode} without another effect`, async () => { + const model = buildCleanupModel(); + if (mode === "reference-after-journal") { + model.setAfterJournal(() => model.refs.add(image)); + } + if (mode === "daemon-after-journal") { + model.setAfterJournal(model.changeDaemon); + } + if (mode === "changed-birth") { + const row = model.current.get(secondBuildParent); + if (!row) { + throw new Error("Missing fixed image row"); + } + model.current.set(secondBuildParent, { + ...row, + created: "2026-10-08T21:00:00Z", + }); + } + if (mode === "foreign-after-effect") { + model.setAfterEffect(() => + model.remaining.add(`sha256:${"f".repeat(64)}`) + ); + } + await expect(cleanupRetainedBuildFixtureImages(model.opts)).rejects.toThrow( + "values omitted" + ); + expect(model.events.filter((row) => row.stage === "remove").length).toBe( + mode === "foreign-after-effect" ? 1 : 0 + ); + expect(model.remaining.has(firstBuildParent)).toBe(true); + }); +} +test("build read shim admits exact original/published images queries and captured image facts only", () => { + for (const file of [ + "/owned/root/.hack/docker-compose.yml", + `/owned/root/.hack/.internal/legacy-compose-adoption-v1/generations/${anchor.id}/legacy-compose.yml`, + ]) { + for (const service of ["db", "worker"]) { + expect( + retainedBuildFixtureReadAllowed({ + ...scope, + generationId: anchor.id, + args: [ + ...prefix, + file, + "config", + "--no-env-resolution", + "--images", + service, + ], + }) + ).toBe(true); + } + } + expect( + retainedBuildFixtureReadAllowed({ + ...scope, + args: [ + "image", + "inspect", + "--format", + '{"id":{{json .Id}},"createdAt":{{json .Created}}}', + image, + ], + }) + ).toBe(true); + for (const args of [ + [ + ...prefix, + "/elsewhere/source.yml", + "config", + "--no-env-resolution", + "--images", + "db", + ], + [ + ...prefix, + "/owned/root/.hack/docker-compose.yml", + "config", + "--images", + "db", + ], + [...prefix, "/owned/root/.hack/docker-compose.yml", "up", "--detach"], + ["compose", "build", "db"], + ["image", "rm", image], + ["pull", "fixture-db"], + ["container", "create", "fixture-db"], + ["image", "inspect", "--format", "{{json .Config.Env}}", image], + [ + "image", + "inspect", + "--format", + '{"id":{{json .Id}},"createdAt":{{json .Created}}}', + "foreign", + ], + ]) { + expect(retainedBuildFixtureReadAllowed({ ...scope, args })).toBe(false); + } +}); +test("build effects require whole journaled original IDs and exact prepared or active generation", () => { + expect( + retainedBuildFixtureMutationAllowed({ + args: ["container", "stop", other], + receipt, + ids: [id, other], + services: ["db", "worker"], + }) + ).toBe(true); + const active = { + ...receipt, + publication: { phase: "active", generation: anchor }, + pendingOperation: { ...receipt.pendingOperation, operation: "start" }, + }; + expect( + retainedBuildFixtureMutationAllowed({ + args: ["container", "start", id], + receipt: active, + ids: [id, other], + services: ["db", "worker"], + }) + ).toBe(true); + for (const value of [ + null, + { ...receipt, adoption_receipt_version: 6 }, + { ...receipt, pendingOperation: null }, + { + ...receipt, + pendingOperation: { ...receipt.pendingOperation, services: ["db"] }, + }, + { ...receipt, prepared: { ...anchor, id: "1".repeat(32) } }, + ]) { + expect( + retainedBuildFixtureMutationAllowed({ + args: ["container", "stop", id], + receipt: value, + ids: [id, other], + services: ["db", "worker"], + }) + ).toBe(false); + } + for (const args of [ + ["container", "stop", "f".repeat(64)], + ["container", "stop", id, other], + ["container", "rm", id], + ["compose", "up", "--detach"], + ]) { + expect( + retainedBuildFixtureMutationAllowed({ + args, + receipt, + ids: [id, other], + services: ["db", "worker"], + }) + ).toBe(false); + } +}); +test("first preparation image/hash queries pass at actual empty-receipt boundaries through the closed staged owner", async () => { + const h = await retainedBuildFixture(); + try { + const first = await captureAdoptionDependencyFirstPrepare({ + projectRoot: h.root, + }); + const entry = join(h.outer, "docker"), + target = join(h.outer, "docker-model"); + await rename(entry, target); + const helper = fileURLToPath( + new URL( + "./e2e/scenarios/native-compose-adoption-build-inputs.ts", + import.meta.url + ) + ); + const trace = join(h.outer, "staged-fixture-read-control"); + const receiptPath = join( + h.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const fixedScope = { + projectRoot: h.root, + project: "fixture", + containerIds: [id], + networkId: "b".repeat(64), + volumeName: "fixture_original_data", + images: [{ id: h.model.image, reference: "fixture-db" }], + }; + const script = [ + `#!${process.execPath}`, + `import {retainedBuildFixtureReadAllowed,retainedBuildFixtureStagedReadAllowed} from ${JSON.stringify(helper)};`, + `const args=process.argv.slice(2), file=Bun.file(${JSON.stringify(receiptPath)});`, + "const saved=await file.exists()?await file.json():null;", + `const scope={...${JSON.stringify(fixedScope)},args,generationId:saved?.prepared?.id};`, + `const ordinary=retainedBuildFixtureReadAllowed(scope), staged=!ordinary && await retainedBuildFixtureStagedReadAllowed({scope,first:${JSON.stringify(first)}});`, + "if(!ordinary&&!staged)process.exit(93);", + `if(staged){const {appendFile}=await import('node:fs/promises');await appendFile(${JSON.stringify(trace)},JSON.stringify({imageQuery:args.includes('--images'),receiptPrepared:saved?.prepared!==null})+'\\n');}`, + `const child=Bun.spawn([${JSON.stringify(target)},...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited);`, + ].join("\n"); + await writeFile(entry, script); + await chmod(entry, 0o700); + const store = await h.store(); + try { + const prepared = await store.prepare({ binary: h.compiler }); + expect(prepared.report.adoption_generation_version).toBe(9); + const rows: unknown[] = (await readFile(trace, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(rows).toContainEqual({ imageQuery: true, receiptPrepared: false }); + expect(rows).toContainEqual({ + imageQuery: false, + receiptPrepared: false, + }); + expect( + rows.every( + (row) => + typeof row === "object" && + row !== null && + "receiptPrepared" in row && + row.receiptPrepared === false + ) + ).toBe(true); + } finally { + await store.close(); + } + } finally { + await h.cleanup(); + } +}, 30_000); diff --git a/tests/native-compose-adoption-build-format.test.ts b/tests/native-compose-adoption-build-format.test.ts new file mode 100644 index 000000000..69ad05acc --- /dev/null +++ b/tests/native-compose-adoption-build-format.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from "bun:test"; +import { isRecord } from "../src/lib/guards.ts"; +import { nativeComposeProbeFailure } from "../src/lib/native-compose-ownership.ts"; +import { + RETAINED_BUILD_OBJECT_FORMAT, + retainedBuildFixtureImage, + retainedBuildFixtureObjectGraph, +} from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; +import { + DOCKER_FORMAT_IMAGE_ID, + withDockerImageFormatFixture, +} from "./helpers/docker-image-format.ts"; + +const binary = process.env.HACK_TEST_DOCKER_FORMAT_BINARY, + sha256 = process.env.HACK_TEST_DOCKER_FORMAT_SHA256; +const baseImage = `sha256:${"a".repeat(64)}`; +const composeVersion = "2.40.3"; +const image = { + Id: DOCKER_FORMAT_IMAGE_ID, + Created: "2026-10-08T20:00:00.123456789Z", + Size: 1000, + RepoTags: ["fixture-db:latest"], + RepoDigests: [`fixture-db@${DOCKER_FORMAT_IMAGE_ID}`], + Config: { + Labels: { + "hack.e2e.retained-build.owner": "fixture", + "hack.e2e.retained-build.stage": "retained", + "com.docker.compose.project": "fixture", + "com.docker.compose.service": "db", + "com.docker.compose.version": composeVersion, + }, + }, + RootFS: { Type: "layers", Layers: [`sha256:${"d".repeat(64)}`] }, +}; +const selected = retainedBuildFixtureImage({ + value: { + id: image.Id, + created: image.Created, + owner: "fixture", + stage: "retained", + tags: image.RepoTags, + digests: image.RepoDigests, + }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [baseImage], +}); +function capture(value: unknown) { + return retainedBuildFixtureObjectGraph({ + values: [value], + selected, + originalImageIds: [baseImage], + baseImage, + composeVersion, + }); +} + +test.skipIf(binary === undefined && sha256 === undefined)( + "real pinned Docker client accepts absent image Parent with exact Compose labels", + async () => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + await withDockerImageFormatFixture({ + binary, + sha256, + image, + observe: async (probe) => { + const old = RETAINED_BUILD_OBJECT_FORMAT.replace( + '{{$parent := ""}}{{range $key, $value := .}}{{if eq $key "Parent"}}{{$parent = $value}}{{end}}{{end}}{{json $parent}}', + "{{json .Parent}}" + ); + expect(old).not.toBe(RETAINED_BUILD_OBJECT_FORMAT); + let failure: unknown; + try { + await probe(old); + } catch (error: unknown) { + failure = error; + } + expect(nativeComposeProbeFailure(failure)).toBe("child"); + const value: unknown = JSON.parse( + await probe(RETAINED_BUILD_OBJECT_FORMAT) + ); + expect(capture(value)).toEqual([ + { + id: image.Id, + parent: "", + created: image.Created, + size: image.Size, + owner: "fixture", + stage: "retained", + tags: image.RepoTags, + digests: image.RepoDigests, + labelNames: Object.keys(image.Config.Labels).sort(), + composeProject: "fixture", + composeService: "db", + composeVersion, + }, + ]); + }, + }); + }, + 20_000 +); + +test + .skipIf(binary === undefined && sha256 === undefined) + .each([ + { parent: null }, + { parent: false }, + { parent: 0 }, + { parent: [] }, + { parent: {} }, + ])( + "real client preserves or refuses malformed present Parent without empty normalization: %j", + async ({ parent }) => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + await withDockerImageFormatFixture({ + binary, + sha256, + image: { ...image, Parent: parent }, + observe: async (probe) => { + let text: string; + try { + text = await probe(RETAINED_BUILD_OBJECT_FORMAT); + } catch (error: unknown) { + if (parent === null) { + throw error; + } + expect(nativeComposeProbeFailure(error)).toBe("child"); + return; + } + const value: unknown = JSON.parse(text); + if (!isRecord(value)) { + throw new Error("Synthetic image projection is malformed"); + } + expect(value.parent).toEqual(parent); + expect(value.parent).not.toBe(""); + expect(() => capture(value)).toThrow("values omitted"); + }, + }); + }, + 20_000 +); + +test.skipIf(binary === undefined && sha256 === undefined)( + "real client keeps an explicit empty image Parent and exact birth, tag and digest", + async () => { + if (!(binary && sha256)) { + throw new Error("Explicit pinned Docker format client is required"); + } + await withDockerImageFormatFixture({ + binary, + sha256, + image: { ...image, Parent: "" }, + observe: async (probe) => { + expect( + capture(JSON.parse(await probe(RETAINED_BUILD_OBJECT_FORMAT)))[0] + ).toMatchObject({ + id: selected.id, + parent: "", + created: selected.created, + owner: selected.owner, + tags: image.RepoTags, + digests: image.RepoDigests, + }); + }, + }); + }, + 20_000 +); diff --git a/tests/native-compose-adoption-build-generation.test.ts b/tests/native-compose-adoption-build-generation.test.ts new file mode 100644 index 000000000..fc9a7b939 --- /dev/null +++ b/tests/native-compose-adoption-build-generation.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, test as boundedTest, expect } from "bun:test"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { previewLegacyComposeAdoption } from "../src/lib/native-compose-adoption-preview.ts"; +import { parseLegacyComposeAdoptionReceipt } from "../src/lib/native-compose-adoption-receipt.ts"; +import { + BUILD_CANARY, + retainedBuildFixture, +} from "./helpers/retained-build-adoption.ts"; + +const test = (name: string, run: () => Promise) => + boundedTest(name, run, 30_000); +let h: Awaited>; +beforeEach(async () => { + h = await retainedBuildFixture(); +}); +afterEach(async () => { + await h.cleanup(); +}); +async function red(pending: Promise) { + try { + await pending; + throw new Error("unexpected retained build success"); + } catch (error) { + expect(String(error)).toMatch(/values omitted/i); + expect(String(error)).not.toContain(BUILD_CANARY); + expect(String(error)).not.toContain(h.root); + } +} +async function prepared() { + const store = await h.store(); + try { + const generation = await store.prepare({ binary: h.compiler }); + return { store, generation }; + } catch (error: unknown) { + await store.close(); + throw error; + } +} +async function assertNoAllocation() { + const commands = await h.commands(); + expect( + commands.some( + (args) => + args.includes("build") || + args.includes("pull") || + args.includes("create") || + args.includes("up") || + args.includes("rm") + ) + ).toBe(false); + expect( + commands + .filter( + (args) => + args[0] === "container" && ["start", "stop"].includes(args[1] ?? "") + ) + .every((args) => args.length === 3 && args[2] === "a".repeat(64)) + ).toBe(true); +} +async function writeReceipt(value: unknown) { + await writeFile( + join(h.root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json"), + JSON.stringify(value) + ); +} + +test("version9 prepares/switches/starts/stops/rolls back the original model identities and data", async () => { + const { store, generation } = await prepared(); + try { + expect(generation.report.adoption_generation_version).toBe(9); + expect((await h.receipt()).adoption_receipt_version).toBe(9); + expect(JSON.stringify(generation)).not.toContain(BUILD_CANARY); + expect(JSON.stringify(generation)).not.toContain("sha256:"); + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("missing synthetic active generation"); + } + expect(active.report.adoption_generation_version).toBe(9); + expect(await h.operation(store, active, "start")).toBe(0); + expect(await h.operation(store, active, "stop")).toBe(0); + await store.rollback(); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect(await h.readModel()).toMatchObject({ + row: BUILD_CANARY, + image: h.model.image, + imageBirth: h.model.imageBirth, + volumeBirth: h.model.volumeBirth, + containerBirth: h.model.containerBirth, + running: false, + }); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("dry-run reports only field provenance/counts and does not create a generation", async () => { + const report = await previewLegacyComposeAdoption({ + projectRoot: h.root, + binary: h.compiler, + }); + expect(report.complete).toBe(true); + expect(JSON.stringify(report)).not.toContain(BUILD_CANARY); + expect(JSON.stringify(report)).not.toContain("sha256:"); + expect( + await Bun.file( + join(h.root, ".hack/.internal/legacy-compose-adoption-v1/receipt.json") + ).exists() + ).toBe(false); + await assertNoAllocation(); +}); +boundedTest.each([ + "context", + "tag", + "missing image", + "image birth", + "container birth", + "volume birth", + "config hash", +])( + "prepared %s drift cannot switch the authored format", + async (kind) => { + const { store, generation } = await prepared(); + try { + if (kind === "context") { + await writeFile(join(h.root, "src/marker"), "changed source"); + } + if (kind === "tag") { + h.model.tag = `sha256:${"e".repeat(64)}`; + } + if (kind === "missing image") { + h.model.imageExists = false; + } + if (kind === "image birth") { + h.model.imageBirth = "2026-02-02T01:02:03Z"; + } + if (kind === "container birth") { + h.model.containerBirth = "2026-02-02T01:02:03Z"; + } + if (kind === "volume birth") { + h.model.volumeBirth = "2026-02-02T01:02:03Z"; + } + if (kind === "config hash") { + h.model.hash = "e".repeat(64); + } + await h.persist(); + await red(store.publish({ generation, binary: h.compiler })); + expect((await h.receipt()).publication).toBeNull(); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + await assertNoAllocation(); + } finally { + await store.close(); + } + }, + 30_000 +); +test("post-effect context drift retains pending ownership; exact same-inode byte repair permits explicit stop recovery", async () => { + const { store, generation } = await prepared(); + try { + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("missing synthetic active generation"); + } + h.model.sourceRace = true; + await h.persist(); + await red(h.operation(store, active, "start")); + expect((await h.receipt()).pendingOperation?.operation).toBe("start"); + await red(store.loadActive()); + h.model.sourceRace = false; + await h.persist(); + await writeFile(join(h.root, "src/marker"), BUILD_CANARY); + const repair = await store.loadActive({ recoverOperation: true }); + if (!repair) { + throw new Error("missing synthetic repair generation"); + } + expect(await h.operation(store, repair, "stop", true)).toBe(0); + expect((await h.receipt()).pendingOperation).toBeNull(); + await store.rollback(); + expect((await h.readModel()).row).toBe(BUILD_CANARY); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("candidate build edits refuse before any retained effect or rollback overwrite", async () => { + const { store, generation } = await prepared(); + try { + await store.publish({ generation, binary: h.compiler }); + const path = join(h.root, ".hack/hack.project.json"); + const original = await readFile(path, "utf8"); + await writeFile( + path, + original.replace('"context":"."', '"context":"changed"') + ); + await red(store.loadActive()); + await red(store.rollback()); + expect((await h.receipt()).publication?.phase).toBe("active"); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("foreign receipt versions cannot label a build manifest", async () => { + const { store } = await prepared(); + try { + const receipt = await h.receipt(); + expect( + parseLegacyComposeAdoptionReceipt(receipt, receipt.checkout) + .adoption_receipt_version + ).toBe(9); + for (const version of [1, 2, 3, 4, 5, 6, 7, 10, 11]) { + await writeReceipt({ ...receipt, adoption_receipt_version: version }); + await red(store.loadPrepared()); + } + await writeReceipt(receipt); + expect( + (await store.loadPrepared())?.report.adoption_generation_version + ).toBe(9); + } finally { + await store.close(); + } +}); +test("rolled-back version9 does not relabel a later image-only generation", async () => { + const { store, generation } = await prepared(); + try { + await store.publish({ generation, binary: h.compiler }); + await store.rollback(); + const plainCompose = JSON.stringify({ + name: "fixture", + services: { + db: { image: "postgres:16-alpine", volumes: ["data:/data"] }, + }, + volumes: { data: { name: "fixture_original_data" } }, + }); + await writeFile(join(h.root, ".hack/docker-compose.yml"), plainCompose); + await h.acceptCompose(plainCompose); + const plain = await store.prepare({ binary: h.compiler }); + expect(plain.report.adoption_generation_version).toBe(1); + expect((await h.receipt()).adoption_receipt_version).toBe(1); + expect( + (await store.loadPrepared())?.report.adoption_generation_version + ).toBe(1); + const receipt = await h.receipt(); + await writeReceipt({ ...receipt, adoption_receipt_version: 9 }); + await red(store.loadPrepared()); + await writeReceipt(receipt); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("remaining operation budget settles a held pre-effect ownership query without calling the mutation", async () => { + const { store, generation } = await prepared(); + try { + h.model.probeHold = true; + await h.persist(); + const start = performance.now(); + let entered = false; + await red( + store.withPreparationStop({ + generation, + binary: h.compiler, + deadline: Date.now() + 1000, + run: async () => { + entered = true; + return 0; + }, + }) + ); + expect(await Bun.file(join(h.outer, "probe-started")).exists()).toBe(true); + expect(performance.now() - start).toBeLessThan(5000); + expect(entered).toBe(false); + expect((await h.receipt()).pendingOperation).toBeNull(); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); +test("version9 requires one deadline before any mutation callback and preserves pending evidence on failed stop", async () => { + const { store, generation } = await prepared(); + try { + let entered = false; + await red( + store.withPreparationStop({ + generation, + run: async () => { + entered = true; + return 0; + }, + }) + ); + expect(entered).toBe(false); + expect((await h.receipt()).pendingOperation).toBeNull(); + h.model.running = true; + h.model.partial = true; + await h.persist(); + const deadline = Date.now() + 15_000; + expect( + await store.withPreparationStop({ + generation, + deadline, + binary: h.compiler, + run: (input) => + import("../src/lib/native-compose-adoption-execution.ts").then( + ({ runLegacyComposeRetainedOperation }) => + runLegacyComposeRetainedOperation({ + input, + operation: "stop", + deadline, + signal: new AbortController().signal, + }) + ), + }) + ).toBe(7); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + h.model.partial = false; + await h.persist(); + const repair = await store.loadPrepared({ recoverOperation: true }); + if (!repair) { + throw new Error("missing synthetic prepared repair"); + } + const nextDeadline = Date.now() + 15_000; + expect( + await store.withPreparationStop({ + generation: repair, + recover: true, + deadline: nextDeadline, + binary: h.compiler, + run: (input) => + import("../src/lib/native-compose-adoption-execution.ts").then( + ({ runLegacyComposeRetainedOperation }) => + runLegacyComposeRetainedOperation({ + input, + operation: "stop", + deadline: nextDeadline, + signal: new AbortController().signal, + }) + ), + }) + ).toBe(0); + await store.publish({ generation: repair, binary: h.compiler }); + await store.rollback(); + await assertNoAllocation(); + } finally { + await store.close(); + } +}); diff --git a/tests/native-compose-adoption-build-recovery-transport.test.ts b/tests/native-compose-adoption-build-recovery-transport.test.ts new file mode 100644 index 000000000..2737b40bf --- /dev/null +++ b/tests/native-compose-adoption-build-recovery-transport.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCommand } from "./e2e/harness.ts"; +import { + retainedBuildFixtureImage, + retainedBuildFixtureMutationAllowed, +} from "./e2e/scenarios/native-compose-adoption-build-inputs.ts"; +import { buildFixtureCli } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; + +const ID = "a".repeat(64); +const OTHER = "b".repeat(64); +const IMAGE = `sha256:${"c".repeat(64)}`; +const ANCHOR = { + id: "e".repeat(32), + manifest: { dev: 1, ino: 2, hash: "f".repeat(64) }, +}; +const PENDING_START = { + adoption_receipt_version: 9, + prepared: ANCHOR, + publication: { phase: "active", generation: ANCHOR }, + pendingOperation: { + generation: ANCHOR, + operation: "start", + services: ["db", "worker"], + }, +}; +const RECOVER = ["down", "--recover", "--json"]; + +async function emitted(opts: { + readonly cliArgs: string[]; + readonly receipt?: unknown; + readonly effectArgs?: readonly string[]; + readonly observedEngineId?: string; + readonly mutateCaller?: boolean; +}) { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-recovery-transport-")) + ); + const root = join(outer, "checkout"); + const engine = join(outer, "synthetic-engine"); + const forwarded = join(outer, "forwarded"); + const saved = join( + root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const instance = { + root, + name: "fixture", + marker: "synthetic-sql-marker", + basicBuild: "root-specific" as const, + }; + try { + await chmod(outer, 0o700); + await mkdir(join(root, ".hack/.internal/legacy-compose-adoption-v1"), { + mode: 0o700, + recursive: true, + }); + const before = JSON.stringify(opts.receipt ?? PENDING_START); + await writeFile(saved, before, { mode: 0o600 }); + await writeFile( + engine, + [ + `#!${process.execPath}`, + "import {appendFile} from 'node:fs/promises';", + "const args=process.argv.slice(2);", + `await appendFile(${JSON.stringify(forwarded)},JSON.stringify(args)+'\\n');`, + `if(JSON.stringify(args)===JSON.stringify(['info','--format','{{json .ID}}'])){console.log(${JSON.stringify(opts.observedEngineId ?? "synthetic-daemon")});process.exit(0);}`, + `if(JSON.stringify(args)===JSON.stringify(['container','stop',${JSON.stringify(ID)}]))process.exit(0);`, + "process.exit(97);", + ].join("\n"), + { mode: 0o700 } + ); + let captured: readonly string[] = []; + const cli: Parameters[0]["cli"] = async ( + selected, + args, + extra + ) => { + expect(selected).toBe(instance); + captured = args; + if (opts.mutateCaller) { + opts.cliArgs.splice(0, opts.cliArgs.length, "up", "--detach", "--json"); + } + return await runCommand({ + argv: ["docker", ...(opts.effectArgs ?? ["container", "stop", ID])], + cwd: root, + env: extra, + timeoutMs: 5000, + }); + }; + const result = await buildFixtureCli( + { + ctx: { tempRoot: outer }, + engine, + engineId: "synthetic-daemon", + baseImage: `sha256:${"1".repeat(64)}`, + anchors: new Map([ + [ + instance, + { + source: "synthetic-original-source", + resources: { + container: [ + { id: ID, service: "db" }, + { id: OTHER, service: "worker" }, + ], + network: [{ id: "d".repeat(64) }], + volume: [{ id: "fixture_data" }], + }, + }, + ], + ]), + builtImages: new Map([ + [ + instance, + retainedBuildFixtureImage({ + value: { + id: IMAGE, + created: "2026-10-08T20:00:00.123456789Z", + owner: "fixture", + stage: "retained", + tags: ["fixture-db:latest"], + digests: null, + }, + reference: "fixture-db", + owner: "fixture", + originalImageIds: [], + }), + ], + ]), + cli, + }, + instance, + opts.cliArgs + ); + const calls: string[][] = (await Bun.file(forwarded).exists()) + ? (await readFile(forwarded, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + return { + result, + calls, + captured, + before, + after: await readFile(saved, "utf8"), + }; + } finally { + await rm(outer, { recursive: true, force: true }); + } +} + +test("pending-start stop counterexample requires the explicit recovery capability and active original anchor", () => { + const selection = { + args: ["container", "stop", ID], + receipt: PENDING_START, + ids: [ID, OTHER], + services: ["db", "worker"], + }; + expect(retainedBuildFixtureMutationAllowed(selection)).toBe(false); + expect( + retainedBuildFixtureMutationAllowed({ + ...selection, + recoverPendingStartStop: true, + }) + ).toBe(true); + for (const publication of [null, { phase: "prepared", generation: ANCHOR }]) { + expect( + retainedBuildFixtureMutationAllowed({ + ...selection, + receipt: { ...PENDING_START, publication }, + recoverPendingStartStop: true, + }) + ).toBe(false); + } +}); + +test("emitted exact recovery-stop forwards only the journaled original ID without rewriting pending start", async () => { + const observed = await emitted({ cliArgs: [...RECOVER], mutateCaller: true }); + expect(observed.captured).toEqual(RECOVER); + expect(Object.isFrozen(observed.captured)).toBe(true); + expect(observed.result.exitCode).toBe(0); + expect(observed.result.timedOut).toBe(false); + expect(observed.calls).toEqual([ + ["info", "--format", "{{json .ID}}"], + ["container", "stop", ID], + ]); + expect(observed.after).toBe(observed.before); +}); + +for (const [name, cliArgs] of [ + ["ordinary", ["down", "--json"]], + ["reordered", ["down", "--json", "--recover"]], + ["extended", [...RECOVER, "--force"]], +] as const) { + test(`emitted pending-start stop rejects an unissued ${name} recovery spelling`, async () => { + const observed = await emitted({ cliArgs: [...cliArgs] }); + expect(observed.result.exitCode).toBe(94); + expect(observed.result.stderr).toBe( + "retained-build-refused stage=mutation-admission code=94\n" + ); + expect(observed.calls).toEqual([]); + expect(observed.after).toBe(observed.before); + }); +} + +for (const [name, receipt] of [ + ["unpublished", { ...PENDING_START, publication: null }], + [ + "foreign-generation", + { + ...PENDING_START, + publication: { + phase: "active", + generation: { ...ANCHOR, id: "1".repeat(32) }, + }, + }, + ], + [ + "partial-services", + { + ...PENDING_START, + pendingOperation: { ...PENDING_START.pendingOperation, services: ["db"] }, + }, + ], + ["foreign-version", { ...PENDING_START, adoption_receipt_version: 10 }], +] as const) { + test(`emitted exact recovery-stop refuses ${name} before engine forwarding`, async () => { + const observed = await emitted({ cliArgs: [...RECOVER], receipt }); + expect(observed.result.exitCode).toBe(94); + expect(observed.calls).toEqual([]); + expect(observed.after).toBe(observed.before); + }); +} + +test("emitted exact recovery-stop refuses a foreign original ID and a changed daemon", async () => { + const foreign = await emitted({ + cliArgs: [...RECOVER], + effectArgs: ["container", "stop", "f".repeat(64)], + }); + expect(foreign.result.exitCode).toBe(94); + expect(foreign.calls).toEqual([]); + const changed = await emitted({ + cliArgs: [...RECOVER], + observedEngineId: "different-daemon", + }); + expect(changed.result.exitCode).toBe(95); + expect(changed.calls).toEqual([["info", "--format", "{{json .ID}}"]]); + expect(changed.after).toBe(changed.before); +}); + +test("emitted ordinary journaled stop retains its previous admission", async () => { + const observed = await emitted({ + cliArgs: ["down", "--json"], + receipt: { + ...PENDING_START, + pendingOperation: { + ...PENDING_START.pendingOperation, + operation: "stop", + }, + }, + }); + expect(observed.result.exitCode).toBe(0); + expect(observed.calls).toEqual([ + ["info", "--format", "{{json .ID}}"], + ["container", "stop", ID], + ]); + expect(observed.after).toBe(observed.before); +}); diff --git a/tests/native-compose-adoption-build-source.test.ts b/tests/native-compose-adoption-build-source.test.ts new file mode 100644 index 000000000..41ca5b0e2 --- /dev/null +++ b/tests/native-compose-adoption-build-source.test.ts @@ -0,0 +1,703 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { + chmod, + link, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + acquireLegacyComposeBuildSource, + assertSavedLegacyComposeBuildSource, +} from "../src/lib/native-compose-adoption-build.ts"; +import { legacyComposeBuildIgnore } from "../src/lib/native-compose-adoption-build-ignore.ts"; +import * as importInputs from "../src/lib/native-config-import-inputs.ts"; +import { acquireLegacyAdoptionSourceInputs } from "../src/lib/native-config-import-inputs.ts"; +import { + mapLegacyNativeAdoptionBaseline, + mapLegacyNativeImport, + mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const CANARY = "synthetic-private-build-source"; +let root: string; +let composeText: string; +let previousCi: string | undefined; +let previousExecutionMode: string | undefined; +beforeEach(async () => { + previousCi = process.env.CI; + previousExecutionMode = process.env.HACK_EXECUTION_MODE; + // These inherited-input cases intentionally exercise the ordinary local + // selection rather than CI/slim's existing primary-scope opt-out. + process.env.CI = ""; + process.env.HACK_EXECUTION_MODE = ""; + root = await realpath( + await mkdtemp(join(tmpdir(), "retained-build-source-")) + ); + await mkdir(join(root, ".hack")); + await mkdir(join(root, ".git")); + await mkdir(join(root, "src")); + await writeFile(join(root, "src/marker"), CANARY); + await writeFile(join(root, "Dockerfile"), "FROM scratch\nCOPY src /source\n"); + await writeFile(join(root, ".dockerignore"), "**\n!Dockerfile\n!src\n"); + await writeFile(join(root, ".hack/hack.config.json"), '{"name":"fixture"}'); + composeText = JSON.stringify({ + name: "fixture", + services: { db: { build: "..", volumes: ["data:/data"] } }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); +}); +afterEach(async () => { + restoreEnv("CI", previousCi); + restoreEnv("HACK_EXECUTION_MODE", previousExecutionMode); + await rm(root, { recursive: true, force: true }); +}); +async function acquire(signal?: AbortSignal) { + const source = await acquireLegacyAdoptionSourceInputs({ + projectRoot: root, + signal, + allowLinkedWorktree: true, + }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + return await acquireLegacyComposeBuildSource({ source, signal }); +} +async function red(pending: Promise) { + try { + await pending; + throw new Error("unexpected build source admission"); + } catch (error) { + expect(String(error)).toContain("values omitted"); + expect(String(error)).not.toContain(root); + expect(String(error)).not.toContain(CANARY); + expect(JSON.stringify(error)).not.toContain(CANARY); + } +} +async function linkedSource() { + async function git(args: readonly string[]) { + const child = Bun.spawn(["/usr/bin/git", "-C", root, ...args], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + }, + }); + if ((await child.exited) !== 0) { + throw new Error("Synthetic linked source setup refused; values omitted."); + } + } + await git(["init", "--quiet", "-b", "main"]); + await git(["add", ".hack", "Dockerfile", ".dockerignore", "src"]); + await git([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ]); + const checkout = join(root, "linked"); + await git(["worktree", "add", "--quiet", "-b", "linked", checkout]); + const source = await acquireLegacyAdoptionSourceInputs({ + projectRoot: checkout, + allowLinkedWorktree: true, + }); + if (!source.ok) { + throw new Error("Synthetic linked source setup refused; values omitted."); + } + return source; +} + +test("retained pure intent does not broaden either image-only baseline API", () => { + const inputs = { configText: '{"name":"fixture"}', composeText }; + expect(mapLegacyNativeAdoptionBaseline(inputs).candidate).toBeUndefined(); + expect(mapLegacyNativeStorageAdoption(inputs).candidate).toBeUndefined(); + expect(mapLegacyNativeRetainedBasicBuild(inputs).candidate).toMatchObject({ + services: { db: { build: { context: "." } } }, + }); +}); +test("retained mapper explicitly projects non-enumerable private source fields", async () => { + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + expect(Object.keys(source)).not.toContain("composeText"); + expect(mapLegacyNativeRetainedBasicBuild(source).candidate).toEqual( + mapLegacyNativeRetainedBasicBuild({ + configText: source.configText, + composeText: source.composeText, + }).candidate + ); + expect(mapLegacyNativeRetainedBasicBuild(source).candidate).toMatchObject({ + services: { db: { build: { context: "." } } }, + }); +}); +test.each([ + ["one-shot", false], + ["one-shot", true], + ["completed", false], + ["completed", true], +] as const)("preview-qualified %s build job (inactive=%s) cannot acquire retained build source", async (kind, inactive) => { + const db = { + build: "..", + ...(inactive ? { profiles: ["later"] } : {}), + ...(kind === "one-shot" + ? { labels: { "hack.service.one-shot": "true" } } + : {}), + }; + composeText = JSON.stringify({ + name: "fixture", + services: { + db, + ...(kind === "completed" + ? { + web: { + image: "fixture", + depends_on: { + db: { condition: "service_completed_successfully" }, + }, + }, + } + : {}), + }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const inputs = { configText: '{"name":"fixture"}', composeText }; + expect(mapLegacyNativeImport(inputs).report.complete).toBe(true); + const retained = mapLegacyNativeRetainedBasicBuild(inputs); + expect(retained.candidate).toBeUndefined(); + expect(retained.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/services/db", + status: "refused", + code: "completed_job_adoption_unqualified", + }) + ); + const source = await acquireLegacyAdoptionSourceInputs({ + projectRoot: root, + }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + const reader = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(reader).not.toHaveBeenCalled(); + } finally { + reader.mockRestore(); + } +}); +test("root context pins included source and keeps private proof/candidate/callbacks out of reports", async () => { + const captured = await acquire(); + expect(Object.keys(captured)).toEqual([]); + expect(JSON.stringify(captured)).toBe("{}"); + expect(Object.isFrozen(captured.proof)).toBe(true); + expect(JSON.stringify(captured.proof)).not.toContain(CANARY); + await captured.assertFresh(); + await assertSavedLegacyComposeBuildSource({ + projectRoot: root, + configText: '{"name":"fixture"}', + composeText, + proof: captured.proof, + checkOwner: async () => {}, + }); +}); +test.each([ + [false, false], + [false, true], + [true, false], + [true, true], +] as const)("preview-qualified owned bridge build (internal=%s, inactive=%s) refuses retained source before context reads", async (internal, inactive) => { + composeText = JSON.stringify({ + name: "fixture", + services: { + db: { + build: "..", + ...(inactive ? { profiles: ["later"] } : {}), + networks: { private: { aliases: ["db-reader"] } }, + }, + }, + networks: { private: { driver: "bridge", internal } }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const inputs = { configText: '{"name":"fixture"}', composeText }; + const preview = mapLegacyNativeImport(inputs); + expect(preview.report.complete).toBe(true); + expect(preview.candidate).toMatchObject({ + services: { + db: { + build: { context: "." }, + networks: { private: { aliases: ["db-reader"] } }, + }, + }, + networks: { private: { internal } }, + }); + const retained = mapLegacyNativeRetainedBasicBuild(inputs); + expect(retained.candidate).toBeUndefined(); + expect(retained.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/networks", + status: "refused", + code: "retained_build_network_adoption_unqualified", + }) + ); + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + const reader = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(reader).not.toHaveBeenCalled(); + } finally { + reader.mockRestore(); + } +}); +test.each([ + false, + true, +])("preview-qualified two bridge build (inactive=%s) refuses retained source before context reads", async (inactive) => { + composeText = JSON.stringify({ + name: "fixture", + services: { + db: { + build: "..", + ...(inactive ? { profiles: ["later"] } : {}), + networks: { + lab: { aliases: ["database"] }, + edge: { aliases: ["writer"] }, + }, + }, + }, + networks: { + lab: { driver: "bridge", internal: true }, + edge: { driver: "bridge", internal: false }, + }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const inputs = { configText: '{"name":"fixture"}', composeText }; + const preview = mapLegacyNativeImport(inputs); + expect(preview.report.complete).toBe(true); + expect(preview.candidate).toMatchObject({ + services: { + db: { + build: { context: "." }, + networks: { + lab: { aliases: ["database"] }, + edge: { aliases: ["writer"] }, + }, + }, + }, + networks: { lab: { internal: true }, edge: { internal: false } }, + }); + const retained = mapLegacyNativeRetainedBasicBuild(inputs); + expect(retained.candidate).toBeUndefined(); + expect(retained.report.fields).toContainEqual( + expect.objectContaining({ + pointer: "/networks", + status: "refused", + code: "retained_build_network_adoption_unqualified", + }) + ); + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + const reader = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(reader).not.toHaveBeenCalled(); + } finally { + reader.mockRestore(); + } +}); +test.each([ + ["config", false], + ["config", true], + ["secret", false], + ["secret", true], +] as const)("preview-qualified %s build grant (inactive=%s) refuses retained source before context reads", async (kind, inactive) => { + const namespace = `${kind}s`; + composeText = JSON.stringify({ + name: "fixture", + services: { + db: { + build: "..", + ...(inactive ? { profiles: ["later"] } : {}), + [namespace]: ["settings"], + }, + }, + [namespace]: { settings: { file: `./${CANARY}` } }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const inputs = { configText: '{"name":"fixture"}', composeText }; + const preview = mapLegacyNativeImport(inputs); + expect(preview.report.complete).toBe(true); + expect(preview.candidate).toMatchObject({ + [namespace]: { settings: { file: `.hack/${CANARY}` } }, + services: { + db: { + build: { context: "." }, + mounts: [{ [kind]: "settings", access: "read-only", mode: "0444" }], + }, + }, + }); + const retained = mapLegacyNativeRetainedBasicBuild(inputs); + expect(retained.candidate).toBeUndefined(); + expect(retained.report.fields).toContainEqual( + expect.objectContaining({ + pointer: `/services/db/${namespace}`, + status: "refused", + code: "unsupported_field", + }) + ); + expect(JSON.stringify(preview.report)).not.toContain(CANARY); + expect(JSON.stringify(retained.report)).not.toContain(CANARY); + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + const reader = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(reader).not.toHaveBeenCalled(); + } finally { + reader.mockRestore(); + } +}); +test("creation and edits inside actually ignored owned outputs do not invalidate included source", async () => { + const captured = await acquire(); + await mkdir(join(root, ".hack/.internal")); + await writeFile(join(root, ".hack/.internal/private-output"), CANARY); + await captured.assertFresh(); + await writeFile( + join(root, ".hack/.internal/private-output"), + `${CANARY}-changed` + ); + await captured.assertFresh(); + // A native selector changes the issued authored-source family. Only the saved + // owner (which separately verifies its published candidate) may read it. + await writeFile(join(root, ".hack/hack.project.json"), CANARY); + await red(captured.assertFresh()); + await assertSavedLegacyComposeBuildSource({ + projectRoot: root, + configText: '{"name":"fixture"}', + composeText, + proof: captured.proof, + checkOwner: async () => {}, + }); +}); +test("Dockerfile-specific ignore wins, while both optional ignore identities remain pinned", async () => { + await writeFile(join(root, ".dockerignore"), "src\n.git\n.hack\n"); + await writeFile( + join(root, "Dockerfile.dockerignore"), + "**\n!Dockerfile\n!src\n" + ); + const captured = await acquire(); + expect(captured.proof.contexts[0]?.effectiveIgnore).toBe( + "Dockerfile.dockerignore" + ); + await writeFile(join(root, "src/marker"), "changed included source"); + await red(captured.assertFresh()); + await writeFile(join(root, "src/marker"), CANARY); + await captured.assertFresh(); + await rm(join(root, "Dockerfile.dockerignore")); + await red(captured.assertFresh()); +}); +test("a newly added specific ignore refuses even when its effective rules are byte-equal", async () => { + const captured = await acquire(); + await writeFile( + join(root, "Dockerfile.dockerignore"), + await readFile(join(root, ".dockerignore")) + ); + await red(captured.assertFresh()); +}); +test.each([ + "bytes", + "added", + "removed", + "replaced", + "symlink", + "hardlink", + "unsafe mode", +])("included %s drift refuses with fixed redacted errors", async (kind) => { + const captured = await acquire(), + path = join(root, "src/marker"); + if (kind === "bytes") { + await writeFile(path, "changed"); + } + if (kind === "added") { + await writeFile(join(root, "src/new"), "new"); + } + if (kind === "removed") { + await rm(path); + } + if (kind === "replaced") { + await rename(path, join(root, "outside")); + await writeFile(path, CANARY); + } + if (kind === "symlink") { + await rename(path, join(root, "outside")); + await symlink(join(root, "outside"), path); + } + if (kind === "hardlink") { + await link(path, join(root, "outside")); + } + if (kind === "unsafe mode") { + await chmod(path, 0o666); + } + await red(captured.assertFresh()); +}); +test("a held root or included directory replacement cannot match same bytes", async () => { + const captured = await acquire(); + await rename(join(root, "src"), join(root, "old-src")); + await mkdir(join(root, "src")); + await writeFile(join(root, "src/marker"), CANARY); + await red(captured.assertFresh()); +}); +test("parents of an excluded Dockerfile remain identity-bound special builder inputs", async () => { + await mkdir(join(root, "definitions")); + await rename(join(root, "Dockerfile"), join(root, "definitions/Dockerfile")); + await writeFile(join(root, ".dockerignore"), "**\n!src\n"); + composeText = JSON.stringify({ + name: "fixture", + services: { + db: { + build: { context: "..", dockerfile: "definitions/Dockerfile" }, + volumes: ["data:/data"], + }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const captured = await acquire(); + await rename(join(root, "definitions"), join(root, "old-definitions")); + await mkdir(join(root, "definitions")); + await rename( + join(root, "old-definitions/Dockerfile"), + join(root, "definitions/Dockerfile") + ); + await red(captured.assertFresh()); +}); +test("Dockerfile whitespace is raw source drift even when the recipe remains valid", async () => { + const captured = await acquire(); + await writeFile( + join(root, "Dockerfile"), + "FROM scratch\nCOPY src /source\n\n" + ); + await red(captured.assertFresh()); +}); +test("an ignored symlink cannot become a included input or leak its target", async () => { + await symlink(join(root, "src/marker"), join(root, "ignored-link")); + const captured = await acquire(); + expect( + captured.proof.contexts[0]?.nodes.some( + (node) => node.path === "ignored-link" + ) + ).toBe(false); + await rm(join(root, "ignored-link")); + await symlink(join(root, "Dockerfile"), join(root, "ignored-link")); + await captured.assertFresh(); +}); +test("a disjoint context without ignore files pins absent ignore presence", async () => { + await mkdir(join(root, ".hack/build")); + await writeFile(join(root, ".hack/build/Dockerfile"), "FROM scratch\n"); + composeText = composeText.replace('"build":".."', '"build":"build"'); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const captured = await acquire(); + expect(captured.proof.contexts[0]?.effectiveIgnore).toBeNull(); + await writeFile( + join(root, ".hack/build/.dockerignore"), + "# no effective rules\n" + ); + await red(captured.assertFresh()); +}); +test("same ignore bytes at a replacement inode cannot repair saved source", async () => { + const captured = await acquire(); + const text = await readFile(join(root, ".dockerignore")); + await rename(join(root, ".dockerignore"), join(root, "old-ignore")); + await writeFile(join(root, ".dockerignore"), text); + await red(captured.assertFresh()); +}); +test("a cloned source cannot issue a build capability", async () => { + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + await red(acquireLegacyComposeBuildSource({ source: { ...source } })); +}); +test("captured cancellation cannot be replaced and the abort reason stays private", async () => { + const original = new AbortController(), + captured = await acquire(original.signal); + original.abort(CANARY); + await red(captured.assertFresh({ signal: new AbortController().signal })); + await red(acquire(original.signal)); +}); +test("explicit build policy refuses before any included context file is opened", async () => { + composeText = JSON.stringify({ + name: "fixture", + services: { + db: { build: "..", pull_policy: "build", volumes: ["data:/data"] }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const source = await acquireLegacyAdoptionSourceInputs({ + projectRoot: root, + allowLinkedWorktree: true, + }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + const read = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } +}); +test.each([ + ".env", + ".hack/.env", + ".hack/hack.env.default.yaml", + ".hack/hack.local.json", +])("unsupported private layout %s refuses before any context/private material opens", async (relative) => { + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + await writeFile(join(root, relative), CANARY); + const read = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } +}); +test("new unsupported private inputs refuse saved read before context acquisition", async () => { + const captured = await acquire(); + await writeFile(join(root, ".env"), CANARY); + const read = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red( + assertSavedLegacyComposeBuildSource({ + projectRoot: root, + configText: '{"name":"fixture"}', + composeText, + proof: captured.proof, + checkOwner: async () => {}, + }) + ); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } +}); +test("private material appearing after the layout check cannot be opened as an included context file", async () => { + await writeFile( + join(root, ".dockerignore"), + ".git\n.hack/.internal\n.hack/.branch\n.hack/hack.config.json\n.hack/docker-compose.yml\n.hack/hack.project.json\n" + ); + const source = await acquireLegacyAdoptionSourceInputs({ projectRoot: root }); + if (!source.ok) { + throw new Error("Synthetic source setup refused; values omitted."); + } + const original = importInputs.readNativeConfigImportSourceFile; + const paths: string[] = []; + const read = spyOn( + importInputs, + "readNativeConfigImportSourceFile" + ).mockImplementation(async (opts) => { + paths.push(opts.path); + if (opts.path === join(root, "Dockerfile")) { + await writeFile(join(root, ".env"), CANARY); + } + return await original(opts); + }); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(paths).toContain(join(root, "Dockerfile")); + expect(paths).not.toContain(join(root, ".env")); + } finally { + read.mockRestore(); + } +}); +test.each([ + ".hack/hack.env.default.local.yaml", + ".hack/hack.local.json", +])("inherited primary %s refuses before context/private reads", async (relative) => { + const source = await linkedSource(); + await writeFile(join(root, relative), CANARY); + const read = spyOn(importInputs, "readNativeConfigImportSourceFile"); + try { + await red(acquireLegacyComposeBuildSource({ source })); + expect(read).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + } +}); +test(".hack context excludes switched authored files while preserving its included inputs", async () => { + await writeFile(join(root, ".hack/Dockerfile"), "FROM scratch\n"); + await writeFile(join(root, ".hack/.dockerignore"), "**\n!Dockerfile\n"); + composeText = composeText.replace('"build":".."', '"build":"."'); + await writeFile(join(root, ".hack/docker-compose.yml"), composeText); + const captured = await acquire(); + expect(captured.proof.contexts[0]?.context).toBe(".hack"); + await captured.assertFresh(); +}); +test("parent negation includes descendants: toolchain pattern is not assumed to be a safe whitelist", async () => { + const rules = + "**\n!mise.toml\n!.hack\n!.hack/toolchain\n!.hack/toolchain/Dockerfile\n!.hack/toolchain/run.sh\n"; + const ignore = legacyComposeBuildIgnore(rules); + expect(ignore.excluded(".hack/.internal/arbitrary-future-file")).toBe(false); + expect(ignore.subtreeExcluded(".hack/.internal")).toBe(false); + await writeFile(join(root, "Dockerfile.dockerignore"), rules); + await red(acquire()); +}); +test("later owned subtree exclusion closes earlier broad inclusion", async () => { + const ignore = legacyComposeBuildIgnore( + "**\n!.hack\n.hack/.internal\n.hack/.branch\n" + ); + expect(ignore.subtreeExcluded(".hack/.internal")).toBe(true); + expect(ignore.excluded(".hack/toolchain/run.sh")).toBe(false); + expect( + legacyComposeBuildIgnore( + "**\n!.hack\n.hack/.internal\n!.hack/.internal/later\n" + ).subtreeExcluded(".hack/.internal") + ).toBe(false); +}); +test.each([ + "*.ts", + "a/**", + "a?", + "[ab]", + "a\\b", + "!", + "a/../b", + "./a", + "/a", + "a b", + "\uFEFF**", +])("unqualified ignore grammar %p refuses", (rule) => { + expect(() => legacyComposeBuildIgnore(rule)).toThrow("values omitted"); +}); diff --git a/tests/native-compose-adoption-generation.test.ts b/tests/native-compose-adoption-generation.test.ts index dab4423d6..d86003ff8 100644 --- a/tests/native-compose-adoption-generation.test.ts +++ b/tests/native-compose-adoption-generation.test.ts @@ -1279,22 +1279,61 @@ for (const linked of [false, true]) { boundedTest( `rolled-back v5 can reprepare a verified plain generation with ${linked ? "linked" : "directory"} checkout receipt version`, async () => { + const captureStages = + process.env.HACK_TEST_ADOPTION_REPREPARE_STAGES === "1"; + const started = performance.now(); + const stage = ( + value: + | "entry" + | "checkout" + | "dependencies" + | "v5-prepared" + | "v5-published" + | "v5-rolled-back" + | "plain-source" + | "plain-prepared" + | "plain-loaded-prepared" + | "plain-published" + | "plain-loaded-active" + | "closed" + ) => { + if (captureStages) { + // Opt-in diagnostic only: fixed stages and elapsed time, no fixture + // path, source, private receipt, child argv or resource values. + process.stderr.write( + `${JSON.stringify({ + diagnostic: "v5-reprepare-stage", + layout: linked ? "linked" : "directory", + stage: value, + elapsedMs: Math.round(performance.now() - started), + })}\n` + ); + } + }; + stage("entry"); if (linked) { await linkedCheckout(); } + stage("checkout"); const worker = await dependencyFixture(); + stage("dependencies"); const { store, generation } = await prepared(); + stage("v5-prepared"); try { const binary = await compiler(); await store.publish({ generation, binary }); + stage("v5-published"); await store.rollback(); + stage("v5-rolled-back"); const composePath = join(projectRoot, ".hack/docker-compose.yml"); const authored = JSON.parse(await readFile(composePath, "utf8")); authored.services.web.depends_on = undefined; authored.services.db.healthcheck = undefined; await writeFile(composePath, JSON.stringify(authored)); + stage("plain-source"); // The synthetic engine config-hash owner continues to attest the new source. const plain = await store.prepare({ binary }); + stage("plain-prepared"); expect(plain.report.adoption_generation_version).toBe(1); expect((await readReceipt()).adoption_receipt_version).toBe( linked ? 2 : 1 @@ -1302,15 +1341,19 @@ for (const linked of [false, true]) { expect( (await store.loadPrepared())?.report.adoption_generation_version ).toBe(1); + stage("plain-loaded-prepared"); await store.publish({ generation: plain, binary }); + stage("plain-published"); expect( (await store.loadActive())?.report.adoption_generation_version ).toBe(1); + stage("plain-loaded-active"); expect(await mutationCommands()).toEqual([]); expect(fixture.container.map((row) => row.id)).toEqual([ID, worker]); expect(fixture.volume[0]?.createdAt).toBe(CREATED); } finally { await store.close(); + stage("closed"); } }, 30_000 diff --git a/tests/native-compose-adoption-jobs.test.ts b/tests/native-compose-adoption-jobs.test.ts index 941da122f..4fc2c902d 100644 --- a/tests/native-compose-adoption-jobs.test.ts +++ b/tests/native-compose-adoption-jobs.test.ts @@ -99,6 +99,23 @@ function fixture() { }; } +test("a retained basic-build flag cannot select the completed-job scheduler", async () => { + const h = fixture(); + let reads = 0; + await expect( + executeLegacyComposeRetainedPlan({ + ...h, + retainedBuild: true, + observe: async () => { + reads += 1; + return await h.observe(); + }, + }) + ).rejects.toThrow("Values omitted"); + expect(reads).toBe(0); + expect(h.commands).toEqual([]); +}); + test.each([ "", "no",