diff --git a/docs/reference/native-compose-commands.md b/docs/reference/native-compose-commands.md index 41662e86..ddb87da1 100644 --- a/docs/reference/native-compose-commands.md +++ b/docs/reference/native-compose-commands.md @@ -107,7 +107,7 @@ documents can contain decrypted values and remain in owned private files with restricted permissions. They are never part of the public plan or JSON output. Do not copy these documents into source control or attach them to diagnostics. -## Finite host up hooks +## Finite host lifecycle hooks Whole-project `up` and `restart` run `host.up.before` before engine startup, then `host.up.after` after the exact workloads and routes are ready. Each phase runs in @@ -123,7 +123,7 @@ the compiler's effective bindings. `env_target` can select the default host or a declared workload's host view. Managed references read that immutable baseline; literal overrides, empty defaults and explicit unset destinations retain their meaning. HTTP/HTTPS external endpoint bindings are supported. Other endpoint -owners remain refused. `run` currently refuses projects with either nonempty up +owners remain refused. `run` currently refuses projects with any nonempty lifecycle hooks instead of assigning new lifecycle semantics to a one-off command. Each hook phase has a separate budget equal to `HACK_COMPOSE_STARTUP_TIMEOUT_MS`; @@ -149,8 +149,8 @@ operation pending. The generation becomes ready only after those checks pass. Before spawning, Hack synchronizes a private hook intent under the same instance mutation lock used for engine effects. An interrupted or unverified hook retains that intent and blocks `up`, `restart` and `run` without replay. Saved `ps --json` -reports `beforeHooksPending` for either phase and `hostHookPhase` as `before`, -`after`, or null, even if no engine generation was created. Older token-only +reports `beforeHooksPending` for every phase and `hostHookPhase` as `before`, +`after`, `down.before`, `down.after`, or null, even if no engine generation was created. Older token-only receipts remain uncertain before intents. An after intent also binds its exact pending startup operation and generation. Saved `down` can stop a retained engine generation but reports incomplete cleanup while @@ -158,7 +158,32 @@ hook intent remains. `down --recover` can recover a verified dead CLI mutation owner; it cannot prove hook process ownership, clear hook uncertainty or rerun a hook. Explicit recovery for interrupted hooks remains a later lifecycle slice. An uncertain hook also prevents routing claim retirement after an owned stop. -Down hooks and persistent host processes remain unsupported. +Normal `down` runs `host.down.before` before stopping the owned engine and +`host.down.after` only after fresh exact container, network and proxy dispatch +absence. Hostname claims remain held through after hooks. A hook failure returns +its exit status and leaves stop pending; a before failure runs no engine mutation. +Private target values for both phases are captured under the instance lock before +any hook journal or spawn. Each phase receives its own finite budget. + +The private saved generation binds down hooks to its immutable source revision, +profiles, effective overlay (including no overlay) and original explicit overlay +selection. Normal hook-enabled down rechecks source, local, routing and managed +environment freshness before engine effects and final claim retirement. Hooks +cannot rebind that generation. Any pre-existing pending operation or unknown hook +intent refuses normal hook-enabled down before private value acquisition. Changed +or malformed inputs require explicit recovery. Generations saved without this +binding retain saved-only stop semantics; newly authored hooks cannot attach to an +already-running old generation. Completed stopped generations do not replay hooks. + +`down --recover` skips authored down hooks, compiler/source parsing and private +value acquisition. A successful hook-enabled engine recovery reports +`hostHooksSkipped: true`; it does not report skipped or previously failed hooks as +successful. A known finite nonzero with proven group absence clears only its exact +hook intent, so later explicit engine recovery can retire claims after fresh absence. +Unknown completion preserves host intent, pending generation and claims even after +owned engine stop, and still reports incomplete. Persistent host processes and +explicit uncertain-hook recovery remain unsupported. `restart` retains its current +up/recreate contract; down hooks run on explicit `down`. ## Saved operations and recovery @@ -229,7 +254,7 @@ from orphan generation files by this recovery path. ## Remaining coverage This slice explicitly refuses foreground or partial-service startup, non-plain -logs, pruning options, all `host.down` hooks, persistent host +logs, pruning options, persistent host processes, browser opening, route bindings, typed host/gateway endpoints, TCP endpoint derivation, and HTTP/TCP readiness. It preserves ordinary project DNS and outbound networking and adds no @@ -249,3 +274,11 @@ HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 \ Container checks do not establish normal browser routing, native trust, host lifecycle recovery, persistent host ownership, or interactive TTY acceptance. Those remain separate NC03 gates. + +The registered `native-config-down-hooks` Docker scenario requires the current +compiled CLI and a cached exact Bun image. It checks finite before/after order and +managed host isolation around production ownership probes, exit 17 with pending +stop, malformed-source/env recovery with skipped hooks, and the same owned volume +with a preserved data counter across stops. Final cleanup removes only that +freshly verified isolated fixture volume. PTY, cancellation, timeout, orphan +completion and routed claim retirement require their separate lifecycle controls. diff --git a/docs/reference/native-compose-generation.md b/docs/reference/native-compose-generation.md index dd613128..55eac763 100644 --- a/docs/reference/native-compose-generation.md +++ b/docs/reference/native-compose-generation.md @@ -15,8 +15,9 @@ checks that reservation, reserved delivery identity and the document budget, the synchronizes one private immutable `compose.json` and its manifest. Directory permissions are `0700`; private files are `0600`. Current/pending receipts anchor the exact manifest and document device, inode and digest. Input revisions and -document digests stay in private files and are absent from returned generation -identity and engine labels. +document digests stay in private files. The private generation handle exposes its +non-enumerable immutable input revision only for saved down-hook binding validation; public +reports and engine labels omit it. All rendered resources also carry the exact random store owner nonce, so a new store cannot silently adopt resources with the same stable Compose namespace. Container resources carry the reserved workload discriminator `service` or `job` @@ -56,7 +57,9 @@ does not prune them or delete persistent data. Saved mode reads only saved ownership state and Git/filesystem identity. It neither reads authored contents nor resolves environment values. `loadCurrent`, `loadPending` and `withLease` support pinned observation. A retained-data `down` -uses saved generation identity without requiring current credentials. Explicit +uses saved generation identity. The adapter may opt into fresh finite down hooks +only when the immutable document carries their saved source-selection binding. +Explicit recovery and generations without that binding require no current credentials. Explicit pending recovery records a recovery token and clears uncertainty only after the caller verifies its owned stop completed. No store API deletes persistent data, prunes generations, or removes abandoned leases. @@ -99,7 +102,20 @@ host intent and retains the pending engine effect. Uncertain completion retains both. Final source/environment checks and workload/route readiness precede the ready receipt; an after hook cannot rebind the running generation. -Saved observation exposes `beforeHooksPending` for either phase and +Finite `down.before` and `down.after` use a separate `downHooks` callback contract, +accepted only for a fresh normal down with current running generation, null pending +operation, null host intent and a freshness callback. Recovery never replays these +callbacks. Each intent binds phase, exact down operation/token and generation; an +up/run/recovery callback cannot clear it. The adapter captures both phases' private +values before the stop journal. Before failure prevents engine effects. After runs +only after exact owned engine/proxy absence while route claims remain held. A known +finite nonzero clears only its exact hook intent and retains stop pending; unknown +completion retains both. Completion requires null host intent at both receipt +checks around the finalizer. The finalizer rechecks source/env freshness and whole +owner/proxy absence before claim retirement; a later failure or crash preserves the +pending recovery generation, including the retirement/receipt publication gap. + +Saved observation exposes `beforeHooksPending` for every phase and `hostHookPhase`. A legacy token-only receipt decodes as an uncertain before intent. Unknown phases refuse without rewriting or clearing the receipt. Saved retaining down may diff --git a/docs/reference/native-compose-renderer.md b/docs/reference/native-compose-renderer.md index ab50d97b..857d1dda 100644 --- a/docs/reference/native-compose-renderer.md +++ b/docs/reference/native-compose-renderer.md @@ -26,12 +26,11 @@ execution owner still fences source and environment generations before apply. The default renderer refuses nonempty host declarations. The command owner can set `beforeHooksOwned: true` for its validated and journaled finite `host.up.before` -and `host.up.after` execution. The flag retains its original name. This admits that +`host.up.after`, `host.down.before` and `host.down.after` execution. The flag retains its original name. This admits that bounded host contract without placing host commands or host environment values in the Compose document. Shared effective-binding checks serve both guest rendering and the hook owner; each supplies its selected baseline -and allowed endpoint policy. Down phases and persistent processes still -refuse. The flag itself provides no execution or freshness proof. +and allowed endpoint policy. Persistent processes still refuse. The flag itself provides no execution or freshness proof. The component performs no filesystem or process operations, engine requests, environment lookup, decryption, hook execution, or registry/cache inspection. Its diff --git a/src/lib/native-compose-after-hooks.ts b/src/lib/native-compose-after-hooks.ts index aa5a12c3..2378e6c6 100644 --- a/src/lib/native-compose-after-hooks.ts +++ b/src/lib/native-compose-after-hooks.ts @@ -1,6 +1,7 @@ import { HackCliError } from "./cli-result.ts"; import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts"; import { + type NativeComposeHook, NativeComposeHostHookError, selectNativeComposeAfterHooks, } from "./native-compose-host-contract.ts"; @@ -18,9 +19,23 @@ export async function prepareNativeComposeAfterHooks(opts: { readonly projectRoot: string; readonly signal: AbortSignal; readonly json: boolean; +}) { + return await prepareNativeComposeFiniteHooks({ + ...opts, + hooks: selectNativeComposeAfterHooks(opts.inputs.result.plan), + }); +} + +/** Capture a finite phase's target values before its journal; no commands or values are persisted. */ +export async function prepareNativeComposeFiniteHooks(opts: { + readonly inputs: Inputs; + readonly hooks: readonly NativeComposeHook[]; + readonly projectRoot: string; + readonly signal: AbortSignal; + readonly json: boolean; }) { await opts.inputs.assertFresh(); - const hooks = selectNativeComposeAfterHooks(opts.inputs.result.plan); + const hooks = opts.hooks; const values = new Map>>(); for (const hook of hooks) { values.set(hook.name, await opts.inputs.resolveHostValues(hook.name)); diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index e9484cef..62639a50 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -20,6 +20,11 @@ import { nativeComposeRunDependenciesReady, nativeComposeWorkloadsReady as ready, } from "./native-compose-completion.ts"; +import { + bindNativeComposeDownHooks, + prepareNativeComposeDownHooks, + readNativeComposeDownHookBinding, +} from "./native-compose-down-hooks.ts"; import { type NativeComposeGeneration, NativeComposeGenerationError, @@ -33,6 +38,7 @@ import { NativeComposeHostHookError, selectNativeComposeAfterHooks, selectNativeComposeBeforeHooks, + selectNativeComposeDownHooks, } from "./native-compose-host-contract.ts"; import { assertNativeComposeBeforeHookBindings, @@ -59,6 +65,7 @@ import { prepareNativeComposeSavedRunRouting, readNativeComposeRouteMetadata, releaseNativeComposeSavedRoutes, + verifyNativeComposeSavedRoutesAbsent, } from "./native-compose-route-owner.ts"; import { NativeComposeRoutingError } from "./native-compose-routing.ts"; import { @@ -458,17 +465,31 @@ async function savedCommand(opts: { if (options.operation === "down") { return await store.withMutation(async (mutation) => { const saved = await savedRouteDocuments(store); + const prepared = await prepareSavedStop({ + store, + generation, + document, + selection, + saved, + options, + signal, + }); + const { assertOwned, assertAbsent, hooks, hasDownHooks } = prepared; let finalizationError: HackCliError | null = null; const result = await mutation .runEffect({ generation, operation: "down", recoverPending: options.recover === true && pending !== null, - assertOwned: async () => { - await assertNativeComposeOwned(selection); - }, + assertOwned, + assertFresh: hooks?.assertFresh, + downHooks: hooks?.downHooks, beforeComplete: async () => { try { + if (hooks) { + await hooks.assertSelectionUnchanged(); + await assertAbsent(); + } await finalizeNativeComposeStop({ store, saved, @@ -494,6 +515,11 @@ async function savedCommand(opts: { } ); const observed = await assertNativeComposeOwned(selection); + prepared.setStopped( + code === 0 && + observed.containers.length === 0 && + observed.networks.length === 0 + ); return { value: code, outcome: @@ -509,11 +535,7 @@ async function savedCommand(opts: { throw finalizationError ?? error; }); if (result.outcome !== "complete") { - throw new HackCliError({ - code: "E_COMPOSE_FAILED", - message: - "Native Compose stop is incomplete; saved ownership and persistent data are retained.", - }); + return reportNativeStopIncomplete(hooks?.code(), options.json === true); } if (options.json) { emitCliResult({ @@ -522,10 +544,18 @@ async function savedCommand(opts: { status: "stopped", composeProject: generation.identity.composeProject, dataRetained: true, + ...(hasDownHooks && options.recover + ? { hostHooksSkipped: true } + : {}), }, }), }); } + if (hasDownHooks && options.recover && !options.json) { + process.stderr.write( + "Saved native Compose resources stopped; authored down hooks were skipped by explicit recovery.\n" + ); + } return result.value; }); } @@ -574,6 +604,111 @@ async function savedCommand(opts: { }); } +async function prepareSavedStop(opts: { + readonly store: NativeComposeGenerationStore; + readonly generation: NativeComposeGeneration; + readonly document: PrivateDocument; + readonly selection: NativeComposeOwnershipOptions; + readonly saved: Awaited>; + readonly options: NativeComposeCommandOptions; + readonly signal: AbortSignal; +}) { + const { store, generation, document, selection, saved, options, signal } = + opts; + const current = await store.loadCurrent(); + const hasDownHooks = Object.hasOwn(document, "x-hack-native-down-hooks"); + let engineStopped = false; + const assertOwned = async () => { + const observed = await assertNativeComposeOwned(selection); + if ( + engineStopped && + (observed.containers.length !== 0 || observed.networks.length !== 0) + ) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); + } + }; + const assertAbsent = async () => { + const observed = await assertNativeComposeOwned(selection); + if (observed.containers.length !== 0 || observed.networks.length !== 0) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); + } + for (const value of saved) { + await store.readGenerationDocument(value.generation); + } + await verifyNativeComposeSavedRoutesAbsent({ + owner: store.identity, + saved, + signal, + deadline: Date.now() + resolveComposeStartupTimeoutMs(), + }); + }; + let hooks: + | Awaited> + | undefined; + if ( + hasDownHooks && + !options.recover && + !( + current.stopped && + current.pending === null && + !current.beforeHooksPending + ) + ) { + // This check precedes compiler/environment acquisition and cannot replay an interrupted phase. + assertStartupAvailable(current); + if (current.generation?.generationId !== generation.generationId) { + return invalid(); + } + const binding = readNativeComposeDownHookBinding({ + generation, + document, + }); + if (!binding) { + return invalid(); + } + await assertOwned(); + hooks = await prepareNativeComposeDownHooks({ + binding, + projectRoot: store.identity.checkoutRoot, + signal, + json: options.json === true, + assertAbsent, + }); + } + return { + assertOwned, + assertAbsent, + hooks, + hasDownHooks, + setStopped: (stopped: boolean) => { + engineStopped = stopped; + }, + }; +} + +function reportNativeStopIncomplete( + code: number | undefined, + json: boolean +): number { + if (code === undefined || code === 0) { + throw new HackCliError({ + code: "E_COMPOSE_FAILED", + message: + "Native Compose stop is incomplete; saved ownership and persistent data are retained.", + }); + } + const message = + "Native host down hook failed; stop remains incomplete and saved ownership is retained. Values omitted."; + if (json) { + emitCliResult({ + result: errorResult({ code: "E_LIFECYCLE_FAILED", message }), + }); + } else { + process.stderr.write(`${message}\n`); + } + return code; +} + /** Retire routes before the stop receipt can forget its recovery generation. */ async function finalizeNativeComposeStop(opts: { readonly store: NativeComposeGenerationStore; @@ -975,6 +1110,8 @@ async function prepareCommand(opts: { const hooks = [ ...selectNativeComposeBeforeHooks(inputs.result.plan), ...selectNativeComposeAfterHooks(inputs.result.plan), + ...selectNativeComposeDownHooks(inputs.result.plan).before, + ...selectNativeComposeDownHooks(inputs.result.plan).after, ]; assertNativeComposeBeforeHookBindings({ hooks, @@ -1038,7 +1175,11 @@ async function prepareCommand(opts: { declaredWorkloads: inputs.result.declared_workloads, beforeHooksOwned: true, }); - const { routing, runRouting, document } = await prepareExecutionRouting({ + const { + routing, + runRouting, + document: routedDocument, + } = await prepareExecutionRouting({ options, store, existingRun, @@ -1049,6 +1190,12 @@ async function prepareCommand(opts: { signal, }); try { + const document = bindNativeComposeDownHooks({ + inputs, + profiles: rendered.profiles, + explicitOverlay: options.overlay, + document: routedDocument, + }); const generation = await publishPreparedGeneration({ existing: existingRun, reservation, diff --git a/src/lib/native-compose-down-hooks.ts b/src/lib/native-compose-down-hooks.ts new file mode 100644 index 00000000..66f7c567 --- /dev/null +++ b/src/lib/native-compose-down-hooks.ts @@ -0,0 +1,181 @@ +import { HackCliError } from "./cli-result.ts"; +import { isRecord } from "./guards.ts"; +import { prepareNativeComposeFiniteHooks } from "./native-compose-after-hooks.ts"; +import type { NativeComposeGeneration } from "./native-compose-generation.ts"; +import { selectNativeComposeDownHooks } from "./native-compose-host-contract.ts"; +import { acquireNativeComposeInputs } from "./native-compose-inputs.ts"; + +const EXTENSION = "x-hack-native-down-hooks"; +type Inputs = Awaited>; +type Binding = { + readonly version: 1; + readonly inputRevision: string; + readonly profiles: readonly string[]; + readonly overlay: string | null; + readonly explicitOverlay?: string | null; +}; +function refuse(): never { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native down-hook inputs differ from the saved generation; stop remains incomplete. Use explicit down --recover to stop saved resources without replaying hooks. Values omitted.", + }); +} + +/** Saved-only generations without this private binding never acquire newly authored hooks. */ +export function bindNativeComposeDownHooks(opts: { + readonly inputs: Inputs; + readonly profiles: readonly string[]; + readonly explicitOverlay?: string | null; + readonly document: Readonly>; +}): Readonly> { + const hooks = selectNativeComposeDownHooks(opts.inputs.result.plan); + if (hooks.before.length + hooks.after.length === 0) { + return opts.document; + } + const binding: Binding = { + version: 1, + inputRevision: opts.inputs.inputRevision, + profiles: [...opts.profiles], + overlay: opts.inputs.result.local_resolution.overlay, + ...(opts.explicitOverlay === undefined + ? {} + : { explicitOverlay: opts.explicitOverlay }), + }; + return { ...opts.document, [EXTENSION]: binding }; +} + +/** Must be called on an owner-verified immutable document. Cross-check the extension with the manifest. */ +export function readNativeComposeDownHookBinding(opts: { + readonly generation: NativeComposeGeneration; + readonly document: Readonly>; +}): Binding | null { + if (!Object.hasOwn(opts.document, EXTENSION)) { + return null; + } + const value = opts.document[EXTENSION]; + if ( + !( + isRecord(value) && + ["version", "inputRevision", "profiles", "overlay"].every((key) => + Object.hasOwn(value, key) + ) && + Object.keys(value).every((key) => + [ + "version", + "inputRevision", + "profiles", + "overlay", + "explicitOverlay", + ].includes(key) + ) && + value.version === 1 && + value.inputRevision === opts.generation.inputRevision && + Array.isArray(value.profiles) && + JSON.stringify(value.profiles) === + JSON.stringify(opts.generation.profiles) && + Object.hasOwn(value, "overlay") && + (value.overlay === null || typeof value.overlay === "string") && + (!Object.hasOwn(value, "explicitOverlay") || + value.explicitOverlay === null || + typeof value.explicitOverlay === "string") + ) + ) { + return refuse(); + } + return Object.freeze({ + version: 1, + inputRevision: opts.generation.inputRevision, + profiles: opts.generation.profiles, + overlay: value.overlay, + ...(Object.hasOwn(value, "explicitOverlay") + ? { + explicitOverlay: + typeof value.explicitOverlay === "string" + ? value.explicitOverlay + : null, + } + : {}), + }); +} + +/** Call under the mutation lock only after rejecting pre-existing pending/host intent. */ +export async function prepareNativeComposeDownHooks(opts: { + readonly binding: Binding; + readonly projectRoot: string; + readonly signal: AbortSignal; + readonly json: boolean; + readonly assertAbsent: () => Promise; +}) { + const acquire = () => + acquireNativeComposeInputs({ + projectRoot: opts.projectRoot, + profiles: opts.binding.profiles, + explicitOverlay: opts.binding.explicitOverlay, + signal: opts.signal, + }); + const inputs = await acquire(); + const sameSelection = (selected: Inputs) => { + if ( + selected.inputRevision !== opts.binding.inputRevision || + selected.result.local_resolution.overlay !== opts.binding.overlay + ) { + refuse(); + } + }; + sameSelection(inputs); + const hooks = selectNativeComposeDownHooks(inputs.result.plan); + if (hooks.before.length + hooks.after.length === 0) { + refuse(); + } + // Both native approvals/value captures happen before journaling any host effects. + const before = hooks.before.length + ? await prepareNativeComposeFiniteHooks({ + ...opts, + inputs, + hooks: hooks.before, + }) + : undefined; + const after = hooks.after.length + ? await prepareNativeComposeFiniteHooks({ + ...opts, + inputs, + hooks: hooks.after, + }) + : undefined; + let code: number | undefined; + const assertSelectionUnchanged = async () => { + await inputs.assertFresh(); + const fresh = await acquire(); + sameSelection(fresh); + await fresh.assertFresh(); + await inputs.assertFresh(); + }; + const phase = ( + execute: NonNullable, + afterEngine: boolean + ) => ({ + prepare: async () => { + await assertSelectionUnchanged(); + if (afterEngine) { + await opts.assertAbsent(); + } + return async () => { + const result = await execute(); + code = result.value; + return result; + }; + }, + }); + return { + // The store fences exact source and env-owner bytes around every slow + // ownership probe. Reacquire the whole selection at phase/final boundaries. + assertFresh: inputs.assertFresh, + assertSelectionUnchanged, + downHooks: { + ...(before ? { before: phase(before, false) } : {}), + ...(after ? { after: phase(after, true) } : {}), + }, + code: () => code, + }; +} diff --git a/src/lib/native-compose-generation.ts b/src/lib/native-compose-generation.ts index 424d00c4..67d095c9 100644 --- a/src/lib/native-compose-generation.ts +++ b/src/lib/native-compose-generation.ts @@ -53,6 +53,8 @@ export type NativeComposeGeneration = { readonly generationId: string; readonly composeFile: string; readonly profiles: readonly string[]; + /** Immutable private manifest binding; never include in public reports. */ + readonly inputRevision: string; }; export type NativeComposeReservation = { @@ -102,6 +104,13 @@ type HostHookIntent = readonly pendingToken: string; readonly generationId: string; readonly operation: "up" | "restart"; + } + | { + readonly token: string; + readonly phase: "down.before" | "down.after"; + readonly pendingToken: string; + readonly generationId: string; + readonly operation: "down"; }; type Receipt = { readonly version: 1; @@ -217,8 +226,10 @@ function beforeHooksValid( (keys(value, "token") || (keys(value, "phase,token") && value.phase === "before") || (keys(value, "generationId,operation,pendingToken,phase,token") && - value.phase === "after" && - (value.operation === "up" || value.operation === "restart") && + ((value.phase === "after" && + (value.operation === "up" || value.operation === "restart")) || + ((value.phase === "down.before" || value.phase === "down.after") && + value.operation === "down")) && typeof value.pendingToken === "string" && TOKEN.test(value.pendingToken) && typeof value.generationId === "string" && @@ -235,7 +246,11 @@ function normalizeHostIntent( if (value == null) { return null; } - if (value.phase === "after") { + if ( + value.phase === "after" || + value.phase === "down.before" || + value.phase === "down.after" + ) { return value; } return { token: value.token, phase: "before" }; @@ -456,6 +471,15 @@ function requireDocumentOwned( } } +export type NativeComposeFiniteHookPhase = { + readonly prepare: () => Promise< + () => Promise<{ + readonly outcome: "complete" | "uncertain"; + readonly value: T; + readonly ready: boolean; + }> + >; +}; export type NativeComposeEffectOptions = { readonly generation: NativeComposeGeneration; readonly operation: NativeComposeOperation; @@ -472,14 +496,11 @@ export type NativeComposeEffectOptions = { */ readonly beforeComplete?: () => Promise; /** Prepare private values after verified engine readiness, then journal a generation-bound finite phase before spawn. */ - readonly afterHooks?: { - readonly prepare: () => Promise< - () => Promise<{ - readonly outcome: "complete" | "uncertain"; - readonly value: T; - readonly ready: boolean; - }> - >; + readonly afterHooks?: NativeComposeFiniteHookPhase; + /** Fresh normal stop only: never replay a pending operation or unknown host intent. */ + readonly downHooks?: { + readonly before?: NativeComposeFiniteHookPhase; + readonly after?: NativeComposeFiniteHookPhase; }; readonly effect: () => Promise<{ readonly outcome: "complete" | "uncertain"; @@ -500,6 +521,61 @@ function publishInputValid(input: PublishOptions): boolean { Buffer.byteLength(input.composeJson) <= NATIVE_COMPOSE_DOCUMENT_LIMIT ); } +function admitDownHooks( + input: NativeComposeEffectOptions, + state: Receipt +): void { + if (input.downHooks) { + if ( + input.operation !== "down" || + input.recoverPending || + !input.assertFresh + ) { + refuse(); + } + if ( + state.pending !== null || + state.beforeHooks !== null || + state.stopped || + state.current?.generationId !== input.generation.generationId + ) { + throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_UNCERTAIN"); + } + } +} +function boundHookIntent( + operation: NativeComposeOperation, + pending: NonNullable, + phase: "after" | "down.before" | "down.after" +): HostHookIntent { + const binding = { + token: token(), + pendingToken: pending.token, + generationId: pending.generationId, + }; + if (phase === "after") { + return Object.freeze({ + ...binding, + phase, + operation: afterHookOperation(operation), + }); + } + if ( + operation !== "down" || + pending.operation !== "down" || + pending.recoveryToken !== undefined + ) { + refuse(); + } + return Object.freeze({ ...binding, phase, operation: "down" }); +} +function hookComplete(result: { + readonly outcome: "complete" | "uncertain"; + readonly ready: boolean; + readonly value: T; +}): boolean { + return result.outcome === "complete" && result.ready; +} function admitEffect( input: NativeComposeEffectOptions, state: Receipt, @@ -508,6 +584,7 @@ function admitEffect( if (!["up", "restart", "run", "down"].includes(input.operation)) { refuse(); } + admitDownHooks(input, state); if ( input.afterHooks && input.operation !== "up" && @@ -605,7 +682,7 @@ export type NativeComposeGenerationStore = { readonly stopped: boolean; readonly pending: NativeComposePending | null; readonly beforeHooksPending: boolean; - readonly hostHookPhase: "before" | "after" | null; + readonly hostHookPhase: HostHookIntent["phase"] | null; }>; loadPending(): Promise; /** Private values: never serialize/log this object. Use within a saved-generation lease. */ @@ -896,12 +973,19 @@ export async function openNativeComposeGenerationStore(opts: { } await recheckDirectories([held]); await check(); - const generation = Object.freeze({ - identity: ownedIdentity, - generationId, - composeFile: join(generationRoot, "compose.json"), - profiles: Object.freeze([...manifest.profiles]), - }); + const generation = Object.freeze( + Object.defineProperty( + { + identity: ownedIdentity, + generationId, + composeFile: join(generationRoot, "compose.json"), + profiles: Object.freeze([...manifest.profiles]), + inputRevision: manifest.inputRevision, + }, + "inputRevision", + { enumerable: false } + ) + ); known.set(generation, manifest); anchors.set(generation, anchor); return generation; @@ -1011,11 +1095,12 @@ export async function openNativeComposeGenerationStore(opts: { const requireFinalPending = ( latest: Receipt, pending: Receipt["pending"], - operation: NativeComposeOperation + operation: NativeComposeOperation, + hasDownHooks: boolean ) => { if ( JSON.stringify(latest.pending) !== JSON.stringify(pending) || - (operation !== "down" && latest.beforeHooks !== null) + ((operation !== "down" || hasDownHooks) && latest.beforeHooks !== null) ) { refuse(); } @@ -1034,7 +1119,12 @@ export async function openNativeComposeGenerationStore(opts: { } await input.assertOwned(); let latest = await receipt(); - requireFinalPending(latest, pending, input.operation); + requireFinalPending( + latest, + pending, + input.operation, + input.downHooks !== undefined + ); if (input.beforeComplete) { await input.beforeComplete(); if (input.assertFresh) { @@ -1046,7 +1136,12 @@ export async function openNativeComposeGenerationStore(opts: { } await input.assertOwned(); latest = await receipt(); - requireFinalPending(latest, pending, input.operation); + requireFinalPending( + latest, + pending, + input.operation, + input.downHooks !== undefined + ); } await save(completedReceipt(latest, anchor, input.operation)); }; @@ -1144,15 +1239,14 @@ export async function openNativeComposeGenerationStore(opts: { await check(); requireActive(); }; - const performAfterHooks = async ( + const performBoundHooks = async ( input: NativeComposeEffectOptions, - pending: NonNullable + pending: NonNullable, + phase: "after" | "down.before" | "down.after", + hooks: NativeComposeFiniteHookPhase ) => { - if (!input.afterHooks) { - return refuse(); - } await checkEffect(input); - const execute = await input.afterHooks.prepare(); + const execute = await hooks.prepare(); await checkEffect(input); const state = await receipt(); if ( @@ -1161,13 +1255,7 @@ export async function openNativeComposeGenerationStore(opts: { ) { return refuse(); } - const intent: HostHookIntent = Object.freeze({ - token: token(), - phase: "after", - pendingToken: pending.token, - generationId: pending.generationId, - operation: afterHookOperation(input.operation), - }); + const intent = boundHookIntent(input.operation, pending, phase); await save({ ...state, beforeHooks: intent }); await checkEffect(input); const result = await execute(); @@ -1192,9 +1280,15 @@ export async function openNativeComposeGenerationStore(opts: { if (result.outcome !== "complete") { return result; } - if (input.afterHooks) { - const after = await performAfterHooks(input, pending); - if (after.outcome !== "complete" || !after.ready) { + const hooks = input.afterHooks ?? input.downHooks?.after; + if (hooks) { + const after = await performBoundHooks( + input, + pending, + input.afterHooks ? "after" : "down.after", + hooks + ); + if (!hookComplete(after)) { return { value: after.value, outcome: "uncertain" as const }; } } @@ -1223,6 +1317,34 @@ export async function openNativeComposeGenerationStore(opts: { } return pending; }; + const prepareEffectBoundary = async ( + input: NativeComposeEffectOptions, + pending: NonNullable + ) => { + await checkEffect(input); + if (input.downHooks?.before) { + const before = await performBoundHooks( + input, + pending, + "down.before", + input.downHooks.before + ); + if (!hookComplete(before)) { + return { value: before.value, outcome: "uncertain" as const }; + } + await checkEffect(input); + } + if (input.downHooks) { + const latest = await receipt(); + if ( + latest.beforeHooks !== null || + JSON.stringify(latest.pending) !== JSON.stringify(pending) + ) { + refuse(); + } + } + return null; + }; const mutation: NativeComposeMutation = { async publishRunProjection(options) { const captured = Object.freeze({ ...options }); @@ -1435,7 +1557,17 @@ export async function openNativeComposeGenerationStore(opts: { }); }, async runEffect(options: NativeComposeEffectOptions) { - const input = Object.freeze({ ...options }); + const downHooks = options.downHooks + ? Object.freeze({ + before: options.downHooks.before + ? Object.freeze({ ...options.downHooks.before }) + : undefined, + after: options.downHooks.after + ? Object.freeze({ ...options.downHooks.after }) + : undefined, + }) + : undefined; + const input = Object.freeze({ ...options, downHooks }); return await runAction(async () => { requireActive(); const state = await receipt(); @@ -1445,7 +1577,10 @@ export async function openNativeComposeGenerationStore(opts: { const pending = effectPending(state, input, anchor); await save({ ...state, pending }); try { - await checkEffect(input); + const before = await prepareEffectBoundary(input, pending); + if (before) { + return before; + } const result = await input.effect(); return await finishEffect(input, pending, anchor, result); } catch { diff --git a/src/lib/native-compose-host-contract.ts b/src/lib/native-compose-host-contract.ts index 13a31afc..acdee0d0 100644 --- a/src/lib/native-compose-host-contract.ts +++ b/src/lib/native-compose-host-contract.ts @@ -12,7 +12,7 @@ export class NativeComposeHostHookError extends Error { readonly code = "E_NATIVE_PROJECT_UNSUPPORTED"; constructor() { super( - "Native Compose host hooks require finite up commands and complete selected host bindings; values omitted." + "Native Compose host hooks require finite lifecycle commands and complete selected host bindings; values omitted." ); this.name = "NativeComposeHostHookError"; } @@ -62,25 +62,34 @@ function relative(value: unknown): value is string { ); } -/** Validate the normalized compiler contract. Other lifecycle phases and persistent owners refuse. */ +/** Validate all finite normalized lifecycle phases. Persistent owners refuse. */ export function selectNativeComposeBeforeHooks( plan: Readonly> ): readonly NativeComposeHook[] { - return selectNativeComposeUpHooks(plan).before; + return selectNativeComposeLifecycleHooks(plan).up.before; } export function selectNativeComposeAfterHooks( plan: Readonly> ): readonly NativeComposeHook[] { - return selectNativeComposeUpHooks(plan).after; + return selectNativeComposeLifecycleHooks(plan).up.after; } -function selectNativeComposeUpHooks(plan: Readonly>): { +export function selectNativeComposeDownHooks( + plan: Readonly> +): HookSequence { + return selectNativeComposeLifecycleHooks(plan).down; +} + +type HookSequence = { readonly before: readonly NativeComposeHook[]; readonly after: readonly NativeComposeHook[]; -} { +}; +function selectNativeComposeLifecycleHooks( + plan: Readonly> +): { readonly up: HookSequence; readonly down: HookSequence } { if (plan.host === undefined) { - return { before: [], after: [] }; + return { up: { before: [], after: [] }, down: { before: [], after: [] } }; } const host = plan.host; assert(isRecord(host) && only(host, ["up", "down", "processes"])); @@ -88,9 +97,12 @@ function selectNativeComposeUpHooks(plan: Readonly>): { host.processes === undefined || (isRecord(host.processes) && Object.keys(host.processes).length === 0) ); - const result: { before: NativeComposeHook[]; after: NativeComposeHook[] } = { - before: [], - after: [], + const result: Record< + "up" | "down", + { before: NativeComposeHook[]; after: NativeComposeHook[] } + > = { + up: { before: [], after: [] }, + down: { before: [], after: [] }, }; const names = new Set(); for (const phase of ["up", "down"] as const) { @@ -105,12 +117,11 @@ function selectNativeComposeUpHooks(plan: Readonly>): { continue; } assert(Array.isArray(entries)); - assert(phase === "up" || entries.length === 0); for (const entry of entries) { const hook = readHook(entry); assert(!names.has(hook.name)); names.add(hook.name); - result[order].push(hook); + result[phase][order].push(hook); } } } diff --git a/src/lib/native-compose-inputs.ts b/src/lib/native-compose-inputs.ts index 42667834..bba844ad 100644 --- a/src/lib/native-compose-inputs.ts +++ b/src/lib/native-compose-inputs.ts @@ -44,7 +44,7 @@ function sourceRevision(prepared: NativePreparedProject): string { digest.update( JSON.stringify({ projectRoot: prepared.projectRoot, - profiles: prepared.selection.profiles ?? [], + profiles: [...(prepared.selection.profiles ?? [])].sort(), explicitOverlay: prepared.selection.explicitOverlay, explicitDomain: prepared.selection.explicitDomain, routing: prepared.routingInputs, diff --git a/src/lib/native-compose-route-claims.ts b/src/lib/native-compose-route-claims.ts index 768eefe0..b7c1448d 100644 --- a/src/lib/native-compose-route-claims.ts +++ b/src/lib/native-compose-route-claims.ts @@ -471,6 +471,13 @@ export type NativeComposeRouteClaims = { readonly owner: NativeComposeRouteOwner; }) => Promise; }): Promise; + /** Read-only stop proof under the instance lock; anchors saved references and keeps all active claims held. */ + verifyAbsent(opts: { + readonly references: readonly NativeComposeRouteReference[]; + readonly assertAbsent: Parameters< + NativeComposeRouteClaims["release"] + >[0]["assertAbsent"]; + }): Promise; /** * Explicit stop recovery under the instance mutation lock. Every uncertain * journal must have an exact saved reference. Fresh proof must establish all @@ -1035,7 +1042,22 @@ export async function openNativeComposeRouteClaims(opts: { const uncertain = (record: Journal) => !record.stopped && (record.retained || (record.armed && !record.complete)); + const requireStopAdmission = ( + records: readonly Journal[], + recovering: readonly Journal[], + verifyOnly: boolean + ) => { + if ( + records.some( + (record) => + uncertain(record) && (verifyOnly || !recovering.includes(record)) + ) + ) { + refuse("E_NATIVE_COMPOSE_ROUTE_RETAINED"); + } + }; const retire = async (options: { + readonly verifyOnly?: boolean; readonly keepHostnames?: readonly string[]; readonly references?: readonly NativeComposeRouteReference[]; readonly assertAbsent: Parameters< @@ -1045,6 +1067,7 @@ export async function openNativeComposeRouteClaims(opts: { const keep = new Set(hostnames(options.keepHostnames ?? [])); const assertAbsent = options.assertAbsent; const references = options.references?.map(snapshotReference); + const verifyOnly = options.verifyOnly === true; if (typeof assertAbsent !== "function") { refuse(); } @@ -1054,13 +1077,7 @@ export async function openNativeComposeRouteClaims(opts: { (references ?? []).map((reference) => find(records, reference)) ), ]; - if ( - records.some( - (record) => uncertain(record) && !recovering.includes(record) - ) - ) { - refuse("E_NATIVE_COMPOSE_ROUTE_RETAINED"); - } + requireStopAdmission(records, recovering, verifyOnly); const current = await activeEntries(records); const entries = [...current.entries.values()] .filter((entry) => !keep.has(entry.claim.hostname)) @@ -1080,6 +1097,9 @@ export async function openNativeComposeRouteClaims(opts: { refuse(); } await activeEntries(latest); + if (verifyOnly) { + return; + } for (const record of recovering) { if (!(record.stopped || record.aborted)) { await publish( @@ -1243,6 +1263,14 @@ export async function openNativeComposeRouteClaims(opts: { await removeEntries(added); }), release: (options) => guard(() => retire(options)), + verifyAbsent: (options) => + guard(() => + retire({ + references: options.references, + assertAbsent: options.assertAbsent, + verifyOnly: true, + }) + ), recoverStopped: (options) => guard(() => retire({ diff --git a/src/lib/native-compose-route-owner.ts b/src/lib/native-compose-route-owner.ts index a0258aea..8b9f5cd6 100644 --- a/src/lib/native-compose-route-owner.ts +++ b/src/lib/native-compose-route-owner.ts @@ -767,14 +767,32 @@ export async function prepareNativeComposeRouteOwner(input: { * Proxy loss leaves claims in place after owned stop. Explicit recovery may retire * anchored uncertain attempts only after fresh whole-owner and proxy absence proof. */ -export async function releaseNativeComposeSavedRoutes(input: { +type SavedRouteOperation = { readonly owner: Owner; readonly saved: readonly NativeComposeSavedRouteDocument[]; readonly signal?: AbortSignal; readonly deadline: number; readonly recover?: boolean; readonly io?: IO; -}): Promise { +}; + +/** Verify exact saved dispatch absence while retaining every referenced hostname claim. */ +export async function verifyNativeComposeSavedRoutesAbsent( + input: SavedRouteOperation +): Promise { + await savedRouteOperation(input, false); +} + +export async function releaseNativeComposeSavedRoutes( + input: SavedRouteOperation +): Promise { + await savedRouteOperation(input, true); +} + +async function savedRouteOperation( + input: SavedRouteOperation, + retire: boolean +): Promise { const opts = Object.freeze({ ...input, owner: Object.freeze({ @@ -827,7 +845,12 @@ export async function releaseNativeComposeSavedRoutes(input: { }); }, } satisfies Parameters[0]; - if (opts.recover) { + if (!retire) { + await claims.verifyAbsent({ + ...retirement, + references: group.map((value) => value.reference), + }); + } else if (opts.recover) { await claims.recoverStopped({ ...retirement, references: group.map((value) => value.reference), diff --git a/tests/e2e/native-compose-owned-fixture.ts b/tests/e2e/native-compose-owned-fixture.ts new file mode 100644 index 00000000..2430a91a --- /dev/null +++ b/tests/e2e/native-compose-owned-fixture.ts @@ -0,0 +1,185 @@ +import { isRecord } from "../../src/lib/guards.ts"; +import { openNativeComposeGenerationStore } from "../../src/lib/native-compose-generation.ts"; +import { + assertNativeComposeOwned, + type NativeComposeOwnershipObservation, + type NativeComposeOwnershipOptions, +} from "../../src/lib/native-compose-ownership.ts"; + +/** Read immutable saved artifacts and use the production whole-owner probe. Never return delivered values. */ +export async function observeNativeComposeFixture(root: string) { + const store = await openNativeComposeGenerationStore({ + projectRoot: root, + instance: null, + mode: "saved", + }); + try { + const state = await store.loadCurrent(); + const pending = await store.loadPending(); + const generation = pending ?? state.generation; + if (!generation) { + throw new Error( + "Owned fixture generation is missing; retain fixture for inspection" + ); + } + const generations = [state.generation, pending].filter( + (value) => value !== null + ); + const services = new Set(); + const volumes = new Map(); + let network: string | undefined; + for (const selected of generations) { + await store.withLease({ + generation: selected, + run: async () => { + const document = await store.readGenerationDocument(selected); + if ( + !( + isRecord(document.services) && + isRecord(document.volumes) && + isRecord(document.networks) && + isRecord(document.networks.default) && + typeof document.networks.default.name === "string" + ) + ) { + throw new Error( + "Owned fixture document is invalid; values omitted" + ); + } + for (const name of Object.keys(document.services)) { + services.add(name); + } + for (const [storage, volume] of Object.entries(document.volumes)) { + if (!(isRecord(volume) && typeof volume.name === "string")) { + throw new Error("Owned fixture volume name is missing"); + } + volumes.set(volume.name, storage); + } + if ( + network !== undefined && + network !== document.networks.default.name + ) { + throw new Error("Owned fixture default network changed"); + } + network = document.networks.default.name; + }, + }); + } + const selection: NativeComposeOwnershipOptions = { + composeProject: store.identity.composeProject, + runtimeIdentity: store.identity.composeProject, + ownerToken: store.identity.ownerToken, + generationIds: [ + ...new Set(generations.map((value) => value.generationId)), + ], + expectedServices: [...services], + expectedVolumes: [...volumes].map(([name, storage]) => ({ + name, + storage, + })), + expectedNetwork: network, + }; + return { + selection, + observed: await assertNativeComposeOwned(selection), + stopped: state.stopped, + pending: state.pending, + hostHookPhase: state.hostHookPhase, + beforeHooksPending: state.beforeHooksPending, + }; + } finally { + await store.close(); + } +} + +export type NativeComposeFixtureVolumePin = { + readonly name: string; + readonly storage: string; + readonly createdAt: string; + readonly composeProject: string; + readonly runtimeIdentity: string; + readonly ownerToken: string; +}; + +/** Volumes have no immutable engine ID. Pin creation time and the exact saved storage/owner before cleanup. */ +export async function pinNativeComposeFixtureVolume(opts: { + readonly selection: NativeComposeOwnershipOptions; + readonly observed: NativeComposeOwnershipObservation; + readonly probe: (args: readonly string[]) => Promise; +}): Promise { + const volume = opts.observed.volumes[0]; + const saved = opts.selection.expectedVolumes?.[0]; + if ( + !( + volume && + saved && + opts.observed.volumes.length === 1 && + opts.selection.expectedVolumes?.length === 1 && + volume.name === saved.name && + volume.storage === saved.storage + ) + ) { + throw new Error("Expected original singleton owned fixture volume"); + } + const value: unknown = JSON.parse( + await opts.probe([ + "volume", + "inspect", + volume.name, + "--format", + '{"name":{{json .Name}},"createdAt":{{json .CreatedAt}},"storage":{{json (index .Labels "io.hack.native-config.storage")}},"ownerToken":{{json (index .Labels "io.hack.native-config.owner")}},"runtimeIdentity":{{json (index .Labels "io.hack.native-config.instance")}},"composeProject":{{json (index .Labels "com.docker.compose.project")}},"version":{{json (index .Labels "io.hack.native-config.version")}}}', + ]) + ); + if ( + !( + isRecord(value) && + Object.keys(value).sort().join() === + "composeProject,createdAt,name,ownerToken,runtimeIdentity,storage,version" && + typeof value.createdAt === "string" && + value.createdAt.length > 0 && + value.name === saved.name && + value.storage === saved.storage && + value.ownerToken === opts.selection.ownerToken && + value.runtimeIdentity === opts.selection.runtimeIdentity && + value.composeProject === opts.selection.composeProject && + value.version === "1" + ) + ) { + throw new Error("Fixture volume identity changed; values omitted"); + } + return Object.freeze({ + name: saved.name, + storage: saved.storage, + createdAt: value.createdAt, + ownerToken: opts.selection.ownerToken, + runtimeIdentity: opts.selection.runtimeIdentity, + composeProject: opts.selection.composeProject, + }); +} + +/** Cleanup failure fails successful acceptance; an earlier failure keeps its original evidence. */ +export async function runWithOwnedCleanup(opts: { + readonly run: () => Promise; + readonly cleanup: () => Promise; + readonly secondaryFailure: () => void; +}): Promise { + let failed = false; + let failure: unknown; + try { + await opts.run(); + } catch (error: unknown) { + failed = true; + failure = error; + } + try { + await opts.cleanup(); + } catch (error: unknown) { + if (!failed) { + throw error; + } + opts.secondaryFailure(); + } + if (failed) { + throw failure; + } +} diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 92498402..51ea5508 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -15,6 +15,7 @@ import { lifecycleHostProcessScenario } from "./scenarios/lifecycle-host-process import { lifecycleSessionRecoveryScenario } from "./scenarios/lifecycle-session-recovery.ts"; import { nativeComposeAdoptionWorktreesScenario } from "./scenarios/native-compose-adoption-worktrees.ts"; import { nativeConfigComposeScenario } from "./scenarios/native-config-compose.ts"; +import { nativeConfigDownHooksScenario } from "./scenarios/native-config-down-hooks.ts"; import { nativeConfigProcessPolicyScenario } from "./scenarios/native-config-process-policy.ts"; import { nativeConfigRoutingScenario } from "./scenarios/native-config-routing.ts"; import { portableMultiserviceScenario } from "./scenarios/portable-multiservice.ts"; @@ -62,6 +63,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ upDownScenario, portableMultiserviceScenario, nativeConfigComposeScenario, + nativeConfigDownHooksScenario, nativeConfigProcessPolicyScenario, nativeConfigRoutingScenario, nativeComposeAdoptionWorktreesScenario, diff --git a/tests/e2e/scenarios/native-config-compose.ts b/tests/e2e/scenarios/native-config-compose.ts index 31efbe69..a56fff83 100644 --- a/tests/e2e/scenarios/native-config-compose.ts +++ b/tests/e2e/scenarios/native-config-compose.ts @@ -15,6 +15,8 @@ import { type Scenario, } from "../harness.ts"; +import { runWithOwnedCleanup } from "../native-compose-owned-fixture.ts"; + const TIMEOUT = 180_000; const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; const TOKEN = /^[a-f0-9]{32}$/; @@ -95,33 +97,6 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -/** Cleanup failure fails successful acceptance; an earlier failure keeps its original evidence. */ -async function runWithOwnedCleanup(opts: { - readonly run: () => Promise; - readonly cleanup: () => Promise; - readonly secondaryFailure: () => void; -}): Promise { - let failed = false; - let failure: unknown; - try { - await opts.run(); - } catch (error: unknown) { - failed = true; - failure = error; - } - try { - await opts.cleanup(); - } catch (error: unknown) { - if (!failed) { - throw error; - } - opts.secondaryFailure(); - } - if (failed) { - throw failure; - } -} - /** Assert this before a harness failure can attach CLI captures to an error. */ function noPrivateOutput(result: CliResult): void { expect({ diff --git a/tests/e2e/scenarios/native-config-down-hooks.ts b/tests/e2e/scenarios/native-config-down-hooks.ts new file mode 100644 index 00000000..5882cfcf --- /dev/null +++ b/tests/e2e/scenarios/native-config-down-hooks.ts @@ -0,0 +1,405 @@ +import { mkdir, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import type { + HostHook, + Project, +} from "../../../packages/config-compiler/generated/native-config.ts"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { createNativeComposeProbe } from "../../../src/lib/native-compose-ownership.ts"; +import { setProjectEnvValue } from "../../../src/lib/project-env-config.ts"; +import { run } from "../../../src/lib/shell.ts"; +import { createMonorepoFixture } from "../fixture.ts"; +import { + type CliResult, + expect, + expectExit, + resolveCliSpawnArgs, + type Scenario, + type ScenarioContext, +} from "../harness.ts"; +import { + type NativeComposeFixtureVolumePin, + observeNativeComposeFixture, + pinNativeComposeFixtureVolume, + runWithOwnedCleanup, +} from "../native-compose-owned-fixture.ts"; + +const TIMEOUT = 90_000; +const HOST_VALUE = "synthetic-down-host-$literal"; +function data(result: CliResult): Record { + const value: unknown = JSON.parse(result.stdout); + if (!(isRecord(value) && value.ok === true && isRecord(value.data))) { + throw new Error( + "Expected successful saved native fixture state; values omitted" + ); + } + return value.data; +} +function noValues(result: CliResult): void { + expect({ + that: !result.combined.includes(HOST_VALUE), + message: "Synthetic host values must stay out of CLI output", + }); +} +async function setup(ctx: ScenarioContext) { + expect({ + that: resolveCliSpawnArgs([]).length === 1, + message: + "Down-hook acceptance requires the current compiled CLI and companion compiler", + }); + const docker = (args: readonly string[]) => + createNativeComposeProbe({ timeoutMs: 10_000 })(args); + const image = ( + await docker([ + "image", + "inspect", + "oven/bun:1.4.2-slim", + "--format", + "{{.Id}}", + ]) + ).trim(); + expect({ + that: /^sha256:[a-f0-9]{64}$/.test(image), + message: + "Down-hook fixture requires the cached exact Bun image; no image pulls", + }); + const created = await createMonorepoFixture({ + parentDir: ctx.tempRoot, + withHackConfig: false, + }); + const root = await realpath(created.root); + const projectDir = join(root, ".hack"); + await mkdir(projectDir); + for (const [scope, value] of [ + ["global", "synthetic-down-guest"], + ["host", HOST_VALUE], + ] as const) { + await setProjectEnvValue({ + projectRoot: root, + projectDir, + envName: null, + scope, + key: "DOWN_TOKEN", + value, + secret: false, + }); + } + const helper = new URL("../native-compose-owned-fixture.ts", import.meta.url) + .href; + await Bun.write( + join(root, "down-hook.ts"), + ` +import {appendFile} from "node:fs/promises"; +import {observeNativeComposeFixture} from ${JSON.stringify(helper)}; +const phase=process.argv[2]; +if(process.env.SEEN!==${JSON.stringify(HOST_VALUE)}||Object.hasOwn(process.env,"DOWN_TOKEN")||process.env.EMPTY!=="")process.exit(41); +const state=await observeNativeComposeFixture(process.cwd()); +if(state.pending?.operation!=="down"||state.hostHookPhase!=="down."+phase||state.observed.volumes.length!==1)process.exit(42); +if(phase==="before"?state.observed.containers.length!==1||state.observed.containers[0].state!=="running":state.observed.containers.length!==0||state.observed.networks.length!==0)process.exit(43); +await appendFile("order",phase+"\\n"); +if((await Bun.file("mode").text())===phase+"17")process.exit(17); +` + ); + const hook = (phase: string): HostHook => ({ + name: `down-${phase}`, + command: { exec: [process.execPath, "down-hook.ts", phase] }, + environment: { + SEEN: { env_ref: "DOWN_TOKEN" }, + DOWN_TOKEN: { unset: true }, + EMPTY: { literal: "" }, + }, + }); + const project: Project = { + schema_version: 1, + name: created.name, + source: { root: ".", mode: "host-mounted" }, + worktree: { inherit_local: false, auto_branch: false }, + storage: { data: { kind: "persistent", scope: "worktree" } }, + services: { + app: { + image, + pull_policy: "never", + restart: { kind: "no" }, + shutdown: { signal: "SIGTERM", grace: "1s" }, + command: { + exec: [ + "bun", + "-e", + 'const file=Bun.file("/data/marker");const next=(await file.exists()?Number(await file.text()):0)+1;if(!Number.isSafeInteger(next))process.exit(44);await Bun.write("/data/marker",String(next));await Bun.write("/tmp/started","yes");setInterval(()=>{},60000)', + ], + }, + mounts: [{ storage: "data", target: "/data", access: "read-write" }], + readiness: { + kind: "exec", + command: { + exec: [ + "bun", + "-e", + 'process.exit(await Bun.file("/tmp/started").exists()&&process.env.DOWN_TOKEN==="synthetic-down-guest"&&!Object.hasOwn(process.env,"SEEN")?0:1)', + ], + }, + interval: "1s", + timeout: "2s", + retries: 10, + }, + }, + }, + host: { + down: { + before: [hook("before")], + after: [ + hook("after"), + { + name: "down-tail", + command: { shell: "printf 'tail\\n' >> order" }, + }, + ], + }, + }, + }; + const projectPath = join(projectDir, "hack.project.json"); + const projectText = JSON.stringify(project); + await Bun.write(projectPath, projectText); + const envPath = join(projectDir, "hack.env.default.yaml"); + const envText = await Bun.file(envPath).text(); + const raw = async (args: readonly string[]) => { + const result = await ctx.cli({ + args, + cwd: root, + timeoutMs: TIMEOUT, + env: { + HACK_RUNTIME_BACKEND: "compose", + HACK_COMPOSE_STARTUP_TIMEOUT_MS: "15000", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + }, + }); + noValues(result); + if (result.timedOut) { + ctx.retainFixtures( + "CLI timeout leaves unverified finite down effects; inspect saved owner before cleanup" + ); + } + return result; + }; + const pins: { volume: NativeComposeFixtureVolumePin | null } = { + volume: null, + }; + return { + root, + projectPath, + projectText, + envPath, + envText, + raw, + docker, + pins, + }; +} +type Fixture = Awaited>; +async function assertStopped(fixture: Fixture, volume: string): Promise { + const state = await observeNativeComposeFixture(fixture.root); + expect({ + that: + state.observed.containers.length === 0 && + state.observed.networks.length === 0 && + state.observed.volumes.length === 1 && + state.observed.volumes[0]?.name === volume, + message: "Exact engine stop must retain the same owned data volume", + }); +} +async function cleanup(ctx: ScenarioContext, fixture: Fixture): Promise { + const result = await fixture.raw(["down", "--recover", "--json"]); + expectExit({ + result, + codes: [0], + message: "Cleanup must explicitly stop saved ownership without replay", + }); + const state = await observeNativeComposeFixture(fixture.root); + expect({ + that: + state.stopped && + state.pending === null && + !state.beforeHooksPending && + state.observed.containers.length === 0 && + state.observed.networks.length === 0, + message: "Unknown hook or engine effects prevent volume cleanup", + }); + const pin = fixture.pins.volume; + const current = await pinNativeComposeFixtureVolume({ + ...state, + probe: fixture.docker, + }); + if (!pin || JSON.stringify(current) !== JSON.stringify(pin)) { + throw new Error("Original fixture volume pin is missing or changed"); + } + // Repeat whole-owner absence and the original creation/storage/owner pin immediately before deletion. + const immediate = await observeNativeComposeFixture(fixture.root); + expect({ + that: + immediate.stopped && + immediate.pending === null && + !immediate.beforeHooksPending && + immediate.observed.containers.length === 0 && + immediate.observed.networks.length === 0, + message: "Changed stop ownership prevents fixture volume cleanup", + }); + const final = await pinNativeComposeFixtureVolume({ + ...immediate, + probe: fixture.docker, + }); + if (JSON.stringify(final) !== JSON.stringify(pin)) { + throw new Error("Fixture volume changed immediately before cleanup"); + } + const code = await run(["docker", "volume", "rm", pin.name], { + stdin: "ignore", + timeoutMs: 10_000, + forwardSignals: true, + }); + expect({ + that: code === 0, + message: "Remove only the immediately reverified original fixture volume", + }); + const absent = await observeNativeComposeFixture(fixture.root); + expect({ + that: + absent.observed.volumes.length === 0 && + absent.observed.containers.length === 0 && + absent.observed.networks.length === 0, + message: "Exact owned fixture cleanup must leave no resources", + }); + ctx.log("finite down-hook fixture cleanup verified"); +} +async function exercise(fixture: Fixture): Promise { + for (const [index, mode] of ["success", "before17", "after17"].entries()) { + await Bun.write(join(fixture.root, "mode"), mode); + await Bun.write(join(fixture.root, "order"), ""); + const up = await fixture.raw(["up", "--detach", "--json"]); + expectExit({ + result: up, + codes: [0], + message: "Each finite down mode starts a ready owned generation", + }); + expect({ + that: data(up).status === "ready", + message: "Fixture startup must commit ready", + }); + const observed = await observeNativeComposeFixture(fixture.root); + const current = await pinNativeComposeFixtureVolume({ + ...observed, + probe: fixture.docker, + }); + expect({ + that: + fixture.pins.volume === null || + JSON.stringify(fixture.pins.volume) === JSON.stringify(current), + message: "Later starts preserve original volume creation/storage/owner", + }); + fixture.pins.volume ??= current; + const volume = current.name; + const marker = await fixture.raw([ + "exec", + "app", + "--", + "bun", + "-e", + 'console.log(await Bun.file("/data/marker").text())', + ]); + expectExit({ + result: marker, + codes: [0], + message: + "Read only the synthetic retained-data counter in the owned workload", + }); + expect({ + that: marker.stdout.trim() === String(index + 1), + message: "Persistent data must survive each completed or recovered stop", + }); + const result = await fixture.raw(["down", "--json"]); + expectExit({ + result, + codes: [mode === "success" ? 0 : 17], + message: "Finite down mode preserves exact hook exit", + }); + const order = await Bun.file(join(fixture.root, "order")).text(); + expect({ + that: + order === + (mode === "success" + ? "before\nafter\ntail\n" + : mode === "before17" + ? "before\n" + : "before\nafter\n"), + message: + "Authored phases surround verified teardown and skip later hooks on failure", + }); + const state = data(await fixture.raw(["ps", "--json"])); + expect({ + that: + state.pending === (mode !== "success") && + state.beforeHooksPending === false && + state.stopped === (mode === "success"), + message: + "Known finite exit clears only its hook intent and keeps failed stop pending", + }); + if (mode === "before17") { + expect({ + that: + (await observeNativeComposeFixture(fixture.root)).observed.containers + .length === 1, + message: "Before failure must not stop the owned workload", + }); + } else { + await assertStopped(fixture, volume); + } + if (mode !== "success") { + await Bun.write(fixture.projectPath, "malformed source"); + await Bun.write(fixture.envPath, "malformed managed env"); + const recovered = await fixture.raw(["down", "--recover", "--json"]); + expectExit({ + result: recovered, + codes: [0], + message: "Saved recovery bypasses malformed authored source and env", + }); + expect({ + that: + data(recovered).status === "stopped" && + data(recovered).hostHooksSkipped === true, + message: "Engine recovery explicitly reports skipped hooks", + }); + expect({ + that: (await Bun.file(join(fixture.root, "order")).text()) === order, + message: "Recovery never replays known failed authored hooks", + }); + await assertStopped(fixture, volume); + await Bun.write(fixture.projectPath, fixture.projectText); + await Bun.write(fixture.envPath, fixture.envText); + } + } +} + +export const nativeConfigDownHooksScenario: Scenario = { + name: "native-config-down-hooks", + tier: "docker", + summary: + "finite down before/after, exact pending recovery and retained owned data", + run: async (ctx) => { + const fixture = await setup(ctx); + await runWithOwnedCleanup({ + run: () => exercise(fixture), + cleanup: async () => { + try { + await cleanup(ctx, fixture); + } catch (error) { + ctx.retainFixtures( + "Finite down fixture cleanup is incomplete; saved ownership/resources retained" + ); + throw error; + } + }, + secondaryFailure: () => + ctx.log( + "Exact finite down cleanup also failed; retain the first failure and owned fixture" + ), + }); + }, +}; diff --git a/tests/helpers/native-compose-command.ts b/tests/helpers/native-compose-command.ts new file mode 100644 index 00000000..c4aa6c00 --- /dev/null +++ b/tests/helpers/native-compose-command.ts @@ -0,0 +1,200 @@ +import { afterEach, expect } from "bun:test"; +import { chmod, mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +const PROTOCOL = { + transport_version: 1, + authored_version: 1, + plan_version: 1, + resolve_version: 1, + local_version: 1, + env_plan_version: 1, + host_env_plan_version: 1, +}; + +/** Compiler/Docker transports are stand-ins; phase ordering, owners, shell execution and durable receipts use the real source CLI. */ +export async function fixture( + program: string, + failedStartup = false, + opts: { + readonly phase?: "up" | "down"; + readonly before?: string; + readonly hooks?: unknown; + readonly noHooks?: boolean; + } = {} +) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-after-command-")) + ); + roots.push(root); + await mkdir(join(root, ".hack")); + await Bun.write( + join(root, ".hack/hack.project.json"), + JSON.stringify({ + schema_version: 1, + name: "fixture", + source: { root: ".", mode: "host-mounted" }, + worktree: { inherit_local: false, auto_branch: false }, + services: { web: { image: "fixture/web:1" } }, + ...(opts.noHooks + ? {} + : { + host: opts.hooks ?? { + [opts.phase ?? "up"]: { + before: [ + { + name: "before", + command: { + exec: [ + process.execPath, + "-e", + opts.before ?? 'await Bun.write("order","before\\n")', + ], + }, + }, + ], + after: [ + { + name: "after-exec", + command: { exec: [process.execPath, "-e", program] }, + }, + { + name: "after-shell", + command: { shell: 'printf "after-shell\\n" >> order' }, + }, + ], + }, + }, + }), + }) + ); + await Bun.write( + join(root, ".hack/hack.env.default.yaml"), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { + global: { TOKEN: "global" }, + host: { TOKEN: "host", HOST_ONLY: "host" }, + }, + }) + ); + const compiler = join(root, "compiler"); + await Bun.write( + compiler, + `#!${process.execPath} +import {createHash} from "node:crypto"; +await import("node:fs/promises").then(m=>m.appendFile("compiler-requests",process.argv[2]+"\\n")); +if(process.argv[2]==="--protocol") console.log(${JSON.stringify(JSON.stringify(PROTOCOL))}); +else { + const operation=process.argv[2];const raw=await Bun.stdin.text();const request=operation==="compile"?{}:JSON.parse(raw); + const text=operation==="compile"?raw:request.project;const source=JSON.parse(text); + const normalized=entries=>entries.map(h=>({...h,environment:h.environment??{},env_target:{kind:"host"}})); + const host=Object.fromEntries(["up","down"].map(phase=>[phase,{before:normalized(source.host?.[phase]?.before??[]),after:normalized(source.host?.[phase]?.after??[])}])); + const profiles=process.argv.slice(3).filter((_,i,a)=>a[i-1]==="--profile").sort(); + const allHooks=[...host.up.before,...host.up.after,...host.down.before,...host.down.after]; + const plan={...source,plan_version:1,selected_profiles:profiles,jobs:{},environment:{},storage:{},host};delete plan.schema_version;delete plan.profiles; + const result={transport_version:1,ok:true,semantic_hash:createHash("sha256").update(text).digest("hex"),declared_workloads:{web:"service"},host_env_targets:{include_default:[...host.up.before,...host.up.after,...host.down.before,...host.down.after].length>0,workloads:[]},plan}; + if(allHooks.length===0) {delete plan.host;delete result.host_env_targets;} + if(operation!=="compile") result.local_resolution={overlay:null,origin:"project",auto_branch:false,inherit_local:false,resolution_hash:"b".repeat(64)}; + if(operation==="plan") { + const metadata=request.env_metadata;const bindings=entries=>Object.fromEntries(Object.entries(entries).map(([key,entry])=>[key,{kind:"managed",key,scope:entry.scope,secret:entry.secret}])); + result.environment_plan={plan_version:1,overlay:metadata.overlay,overlay_exists:metadata.overlay_exists,complete:true,workloads:{web:bindings(metadata.workloads.web)},host:Object.fromEntries(allHooks.map(h=>[h.name,{env_target:h.env_target,bindings:bindings(metadata.host.default)}])),warnings:[],diagnostics:[]}; + } + if(result.environment_plan && allHooks.length===0) delete result.environment_plan.host; + console.log(JSON.stringify(result)); +} +` + ); + const docker = join(root, "docker"); + await Bun.write( + docker, + `#!${process.execPath} +import {appendFile,rm} from "node:fs/promises"; +const root=${JSON.stringify(root)};const args=process.argv.slice(2); +await appendFile(root+"/requests",JSON.stringify(args)+"\\n"); +const engine=root+"/engine"; +if(args[0]==="compose") { + if(args.includes("up")) { + if(${failedStartup}) process.exit(19); + const doc=await Bun.file(args[args.indexOf("-f")+1]).json();await Bun.write(engine,JSON.stringify(doc));await appendFile(root+"/order","engine-ready\\n");process.exit(0); + } + if(args.includes("down")) {await rm(engine,{force:true});await appendFile(root+"/order","engine-stopped\\n");process.exit(0);} + process.exit(99); +} +if(args[1]==="ls") { + if(args[0]==="container" && await Bun.file(engine).exists()) { + const doc=await Bun.file(engine).json();console.log(JSON.stringify({id:"c".repeat(64),name:doc.name+"-web-1",project:doc.name})); + } + process.exit(0); +} +if(args[0]==="container" && args[1]==="inspect" && await Bun.file(engine).exists()) { + const doc=await Bun.file(engine).json();const labels=doc.services.web.labels; + console.log(JSON.stringify({id:"c".repeat(64),name:"/"+doc.name+"-web-1",project:doc.name,version:"1",instance:doc.name,owner:labels["io.hack.native-config.owner"],generation:labels["io.hack.native-config.generation"],service:"web",oneoff:"False",state:await Bun.file(root+"/unready").exists()?"exited":"running",exitCode:0,health:null}));process.exit(0); +} +process.exit(99); +` + ); + await chmod(compiler, 0o700); + await chmod(docker, 0o700); + return root; +} + +export async function invoke( + root: string, + args = ["up", "--detach", "--json"] +) { + const child = Bun.spawn( + [ + process.execPath, + resolve(import.meta.dir, "../../index.ts"), + "--path", + root, + ...args, + ], + { + cwd: root, + env: { + HOME: process.env.HOME, + LANG: "C", + PATH: `${root}:/usr/bin:/bin`, + HACK_HOME: join(root, "home"), + HACK_GLOBAL_CONFIG_PATH: join(root, "global.json"), + HACK_CONFIG_COMPILER_BINARY: join(root, "compiler"), + HACK_RUNTIME_BACKEND: "compose", + HACK_LOGGER: "console", + HACK_COMPOSE_STARTUP_TIMEOUT_MS: "1000", + CI: "1", + HACK_EXECUTION_MODE: "non_interactive", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const timer = setTimeout(() => child.kill("SIGKILL"), 15_000); + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { code, stdout, stderr }; + } finally { + clearTimeout(timer); + } +} + +export async function state(root: string) { + const result = await invoke(root, ["ps", "--json"]); + expect(result.code).toBe(0); + return JSON.parse(result.stdout).data; +} diff --git a/tests/native-compose-after-command.test.ts b/tests/native-compose-after-command.test.ts index 4aadcacf..b39d4cf2 100644 --- a/tests/native-compose-after-command.test.ts +++ b/tests/native-compose-after-command.test.ts @@ -1,180 +1,6 @@ -import { afterEach, expect, test } from "bun:test"; -import { chmod, mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -const roots: string[] = []; -afterEach(async () => { - await Promise.all( - roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) - ); -}); -const PROTOCOL = { - transport_version: 1, - authored_version: 1, - plan_version: 1, - resolve_version: 1, - local_version: 1, - env_plan_version: 1, - host_env_plan_version: 1, -}; - -/** Compiler/Docker transports are stand-ins; phase ordering, owners, shell execution and durable receipts use the real source CLI. */ -async function fixture(program: string, failedStartup = false) { - const root = await realpath( - await mkdtemp(join(tmpdir(), "native-after-command-")) - ); - roots.push(root); - await mkdir(join(root, ".hack")); - await Bun.write( - join(root, ".hack/hack.project.json"), - JSON.stringify({ - schema_version: 1, - name: "fixture", - source: { root: ".", mode: "host-mounted" }, - worktree: { inherit_local: false, auto_branch: false }, - services: { web: { image: "fixture/web:1" } }, - host: { - up: { - before: [ - { - name: "before", - command: { - exec: [ - process.execPath, - "-e", - 'await Bun.write("order","before\\n")', - ], - }, - }, - ], - after: [ - { - name: "after-exec", - command: { exec: [process.execPath, "-e", program] }, - }, - { - name: "after-shell", - command: { shell: 'printf "after-shell\\n" >> order' }, - }, - ], - }, - }, - }) - ); - await Bun.write( - join(root, ".hack/hack.env.default.yaml"), - JSON.stringify({ - version: 1, - environment: "default", - secretsprovider: "project_key", - values: { - global: { TOKEN: "global" }, - host: { TOKEN: "host", HOST_ONLY: "host" }, - }, - }) - ); - const compiler = join(root, "compiler"); - await Bun.write( - compiler, - `#!${process.execPath} -import {createHash} from "node:crypto"; -if(process.argv[2]==="--protocol") console.log(${JSON.stringify(JSON.stringify(PROTOCOL))}); -else { - const operation=process.argv[2];const raw=await Bun.stdin.text();const request=operation==="compile"?{}:JSON.parse(raw); - const text=operation==="compile"?raw:request.project;const source=JSON.parse(text); - const normalized=entries=>entries.map(h=>({...h,environment:h.environment??{},env_target:{kind:"host"}})); - const plan={...source,plan_version:1,selected_profiles:[],jobs:{},environment:{},storage:{},host:{up:{before:normalized(source.host.up.before),after:normalized(source.host.up.after)}}};delete plan.schema_version; - const result={transport_version:1,ok:true,semantic_hash:createHash("sha256").update(text).digest("hex"),declared_workloads:{web:"service"},host_env_targets:{include_default:true,workloads:[]},plan}; - if(operation!=="compile") result.local_resolution={overlay:null,origin:"project",auto_branch:false,inherit_local:false,resolution_hash:"b".repeat(64)}; - if(operation==="plan") { - const metadata=request.env_metadata;const bindings=entries=>Object.fromEntries(Object.entries(entries).map(([key,entry])=>[key,{kind:"managed",key,scope:entry.scope,secret:entry.secret}])); - result.environment_plan={plan_version:1,overlay:metadata.overlay,overlay_exists:metadata.overlay_exists,complete:true,workloads:{web:bindings(metadata.workloads.web)},host:Object.fromEntries([...plan.host.up.before,...plan.host.up.after].map(h=>[h.name,{env_target:h.env_target,bindings:bindings(metadata.host.default)}])),warnings:[],diagnostics:[]}; - } - console.log(JSON.stringify(result)); -} -` - ); - const docker = join(root, "docker"); - await Bun.write( - docker, - `#!${process.execPath} -import {appendFile,rm} from "node:fs/promises"; -const root=${JSON.stringify(root)};const args=process.argv.slice(2); -await appendFile(root+"/requests",JSON.stringify(args)+"\\n"); -const engine=root+"/engine"; -if(args[0]==="compose") { - if(args.includes("up")) { - if(${failedStartup}) process.exit(19); - const doc=await Bun.file(args[args.indexOf("-f")+1]).json();await Bun.write(engine,JSON.stringify(doc));await appendFile(root+"/order","engine-ready\\n");process.exit(0); - } - if(args.includes("down")) {await rm(engine,{force:true});process.exit(0);} - process.exit(99); -} -if(args[1]==="ls") { - if(args[0]==="container" && await Bun.file(engine).exists()) { - const doc=await Bun.file(engine).json();console.log(JSON.stringify({id:"c".repeat(64),name:doc.name+"-web-1",project:doc.name})); - } - process.exit(0); -} -if(args[0]==="container" && args[1]==="inspect" && await Bun.file(engine).exists()) { - const doc=await Bun.file(engine).json();const labels=doc.services.web.labels; - console.log(JSON.stringify({id:"c".repeat(64),name:"/"+doc.name+"-web-1",project:doc.name,version:"1",instance:doc.name,owner:labels["io.hack.native-config.owner"],generation:labels["io.hack.native-config.generation"],service:"web",oneoff:"False",state:await Bun.file(root+"/unready").exists()?"exited":"running",exitCode:0,health:null}));process.exit(0); -} -process.exit(99); -` - ); - await chmod(compiler, 0o700); - await chmod(docker, 0o700); - return root; -} - -async function invoke(root: string, args = ["up", "--detach", "--json"]) { - const child = Bun.spawn( - [ - process.execPath, - resolve(import.meta.dir, "../index.ts"), - "--path", - root, - ...args, - ], - { - cwd: root, - env: { - ...process.env, - PATH: `${root}:/usr/bin:/bin`, - HACK_HOME: join(root, "home"), - HACK_GLOBAL_CONFIG_PATH: join(root, "global.json"), - HACK_CONFIG_COMPILER_BINARY: join(root, "compiler"), - HACK_RUNTIME_BACKEND: "compose", - HACK_LOGGER: "console", - HACK_COMPOSE_STARTUP_TIMEOUT_MS: "1000", - CI: "1", - HACK_EXECUTION_MODE: "non_interactive", - }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - } - ); - const timer = setTimeout(() => child.kill("SIGKILL"), 15_000); - try { - const [code, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - return { code, stdout, stderr }; - } finally { - clearTimeout(timer); - } -} - -async function state(root: string) { - const result = await invoke(root, ["ps", "--json"]); - expect(result.code).toBe(0); - return JSON.parse(result.stdout).data; -} +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { fixture, invoke, state } from "./helpers/native-compose-command.ts"; test("source CLI runs ordered finite after hooks only after exact generation readiness and then commits ready", async () => { const root = await fixture( diff --git a/tests/native-compose-down-command.test.ts b/tests/native-compose-down-command.test.ts new file mode 100644 index 00000000..da459a63 --- /dev/null +++ b/tests/native-compose-down-command.test.ts @@ -0,0 +1,289 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { fixture, invoke, state } from "./helpers/native-compose-command.ts"; + +const append = (line: string) => + `await import("node:fs/promises").then(m=>m.appendFile("order",${JSON.stringify(`${line}\n`)}));`; +async function started(after: string, before = append("down-before")) { + const root = await fixture(after, false, { phase: "down", before }); + const up = await invoke(root); + expect(up.code).toBe(0); + expect(JSON.parse(up.stdout).data.status).toBe("ready"); + return root; +} +async function stops(root: string) { + return (await Bun.file(join(root, "requests")).text()) + .split("\n") + .filter((line) => line && JSON.parse(line).includes("down")).length; +} +const down = (root: string, recover = false) => + invoke(root, ["down", ...(recover ? ["--recover"] : []), "--json"]); + +test("source CLI orders down before, exact engine stop, after exec/shell; stopped calls never replay or acquire inputs", async () => { + const root = await started( + `if(await Bun.file("engine").exists()||process.env.TOKEN!=="host")process.exit(41);${append("down-after")}`, + `if(!(await Bun.file("engine").exists())||process.env.TOKEN!=="host")process.exit(42);${append("down-before")}` + ); + const result = await down(root); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + data: { status: "stopped", dataRetained: true }, + }); + expect(await Bun.file(join(root, "order")).text()).toBe( + "engine-ready\ndown-before\nengine-stopped\ndown-after\nafter-shell\n" + ); + expect(await state(root)).toMatchObject({ + stopped: true, + pending: false, + beforeHooksPending: false, + }); + const compiler = await Bun.file(join(root, "compiler-requests")).text(); + // Full private planning belongs to startup and finite phase/final boundaries, + // rather than each before/after ownership-observation freshness fence. + expect( + compiler.split("\n").filter((operation) => operation === "plan").length + ).toBeLessThanOrEqual(6); + await Bun.write(join(root, ".hack/hack.project.json"), "malformed source"); + await Bun.write(join(root, ".hack/hack.env.default.yaml"), "malformed env"); + expect((await down(root)).code).toBe(0); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); + expect( + (await Bun.file(join(root, "order")).text()).match(/down-before/g) + ).toHaveLength(1); +}, 30_000); + +test.each([ + "before", + "after", +] as const)("known down %s exit17 retains pending; normal retry refuses before acquisition; explicit recovery skips hooks", async (phase) => { + const root = await started( + phase === "after" ? "process.exit(17)" : append("down-after"), + phase === "before" ? "process.exit(17)" : append("down-before") + ); + const result = await down(root); + expect(result.code).toBe(17); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: "E_LIFECYCLE_FAILED" }, + }); + expect(await stops(root)).toBe(phase === "before" ? 0 : 1); + expect(await state(root)).toMatchObject({ + pending: true, + beforeHooksPending: false, + stopped: false, + hostHookPhase: null, + }); + const compiler = await Bun.file(join(root, "compiler-requests")).text(); + const retried = await down(root); + expect(retried.code).toBe(1); + expect(await stops(root)).toBe(phase === "before" ? 0 : 1); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); + await Bun.write(join(root, ".hack/hack.project.json"), "malformed source"); + await Bun.write(join(root, ".hack/hack.env.default.yaml"), "malformed env"); + const recovered = await down(root, true); + expect(recovered.code).toBe(0); + expect(JSON.parse(recovered.stdout)).toMatchObject({ + ok: true, + data: { status: "stopped", hostHooksSkipped: true }, + }); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); + expect(await state(root)).toMatchObject({ + stopped: true, + pending: false, + beforeHooksPending: false, + }); + expect(await Bun.file(join(root, "order")).text()).not.toContain( + "after-shell" + ); +}, 30_000); + +test.each([ + "before", + "after", +] as const)("down %s changed managed env keeps pending and cannot finalize or rebind", async (phase) => { + const change = + 'await Bun.write(".hack/hack.env.default.yaml",JSON.stringify({version:1,environment:"default",secretsprovider:"project_key",values:{global:{TOKEN:"changed"}}}));'; + const root = await started( + phase === "after" ? change : append("down-after"), + phase === "before" ? change : append("down-before") + ); + const result = await down(root); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout).ok).toBe(false); + expect(await stops(root)).toBe(phase === "before" ? 0 : 1); + expect(await state(root)).toMatchObject({ + stopped: false, + pending: true, + beforeHooksPending: false, + }); + expect((await down(root, true)).code).toBe(0); +}, 30_000); + +test("after hook resurrecting exact owned workload cannot complete stop", async () => { + const root = await started( + 'await Bun.write("engine",await Bun.file("engine-backup").text());' + ); + await Bun.write( + join(root, "engine-backup"), + await Bun.file(join(root, "engine")).text() + ); + const result = await down(root); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout).ok).toBe(false); + expect(await state(root)).toMatchObject({ + stopped: false, + pending: true, + beforeHooksPending: false, + }); + expect((await down(root, true)).code).toBe(0); +}, 30_000); + +test("changed source selection refuses hook-enabled normal down; saved explicit recovery never invokes compiler or hook values", async () => { + const root = await started(append("down-after")); + const source = await Bun.file(join(root, ".hack/hack.project.json")).json(); + source.name = "changed"; + await Bun.write( + join(root, ".hack/hack.project.json"), + JSON.stringify(source) + ); + expect((await down(root)).code).toBe(1); + expect(await stops(root)).toBe(0); + expect(await state(root)).toMatchObject({ stopped: false, pending: false }); + const compiler = await Bun.file(join(root, "compiler-requests")).text(); + await Bun.write(join(root, ".hack/hack.project.json"), "malformed source"); + await Bun.write(join(root, ".hack/hack.env.default.yaml"), "malformed env"); + expect((await down(root, true)).code).toBe(0); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); + expect(await Bun.file(join(root, "order")).text()).toBe( + "engine-ready\nengine-stopped\n" + ); +}, 30_000); + +test("saved generation without a down binding ignores later authored hooks and malformed env", async () => { + const root = await fixture("", false, { noHooks: true }); + expect(await invoke(root)).toMatchObject({ code: 0 }); + const compiler = await Bun.file(join(root, "compiler-requests")).text(); + await Bun.write( + join(root, ".hack/hack.project.json"), + JSON.stringify({ + host: { + down: { before: [{ name: "new", command: { shell: "exit 17" } }] }, + }, + }) + ); + await Bun.write(join(root, ".hack/hack.env.default.yaml"), "malformed env"); + expect((await down(root)).code).toBe(0); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); + expect(await state(root)).toMatchObject({ stopped: true, pending: false }); +}, 30_000); + +test("oneoff run refuses authored down hooks before any engine request", async () => { + const root = await fixture("", false, { phase: "down" }); + const result = await invoke(root, ["run", "web"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain( + "run with authored host hooks is not supported" + ); + expect(await Bun.file(join(root, "requests")).exists()).toBe(false); +}, 20_000); + +test("saved down selection uses canonical authored profiles even when startup flags were reordered", async () => { + const root = await fixture(append("down-after"), false, { + phase: "down", + before: append("down-before"), + }); + const source = await Bun.file(join(root, ".hack/hack.project.json")).json(); + source.profiles = ["a", "z"]; + await Bun.write( + join(root, ".hack/hack.project.json"), + JSON.stringify(source) + ); + const result = await invoke(root, [ + "up", + "--profile", + "z,a", + "--detach", + "--json", + ]); + expect(result).toMatchObject({ code: 0 }); + const document = await Bun.file(join(root, "engine")).json(); + expect(document["x-hack-native-down-hooks"].profiles).toEqual(["a", "z"]); + expect(document["x-hack-native-down-hooks"].overlay).toBeNull(); + expect((await down(root)).code).toBe(0); +}, 30_000); + +test.each([ + "source", + "local", +] as const)("down after %s drift retains exact pending and prevents final stop receipt", async (kind) => { + const change = + kind === "source" + ? 'const source=await Bun.file(".hack/hack.project.json").json();source.worktree.inherit_local=true;await Bun.write(".hack/hack.project.json",JSON.stringify(source));' + : 'await Bun.write(".hack/hack.local.json",JSON.stringify({version:1,auto_branch:false}));'; + const root = await started(change); + expect((await down(root)).code).toBe(1); + expect(await state(root)).toMatchObject({ + pending: true, + stopped: false, + beforeHooksPending: false, + }); + expect((await down(root, true)).code).toBe(0); +}, 30_000); + +test("finite before timeout returns124 with descendant absence, no engine stop and exact pending recovery", async () => { + const program = + 'const child=Bun.spawn([process.execPath,"-e",\'process.on("SIGTERM",()=>{});await Bun.sleep(60000)\'],{stdout:"inherit",stderr:"inherit"});await Bun.write("host-pid",String(child.pid));process.on("SIGTERM",()=>{});await Bun.sleep(60000);'; + const root = await started(append("down-after"), program); + const result = await down(root); + expect(result.code).toBe(124); + expect(await stops(root)).toBe(0); + expect(await state(root)).toMatchObject({ + pending: true, + beforeHooksPending: false, + hostHookPhase: null, + }); + const pid = Number(await Bun.file(join(root, "host-pid")).text()); + expect(() => process.kill(pid, 0)).toThrow(); + expect((await down(root, true)).code).toBe(0); +}, 30_000); + +test("an orphan before hook remains durable unknown even after later process absence and explicit saved engine stop", async () => { + const program = + 'const child=Bun.spawn(["/bin/sleep","3"],{stdout:"ignore",stderr:"ignore"});await Bun.write("host-pid",String(child.pid));process.exit(0);'; + const root = await started(append("down-after"), program); + const result = await down(root); + expect(result.code).toBe(1); + expect(await stops(root)).toBe(0); + expect(await state(root)).toMatchObject({ + pending: true, + beforeHooksPending: true, + hostHookPhase: "down.before", + }); + const pid = Number(await Bun.file(join(root, "host-pid")).text()); + const deadline = Date.now() + 5000; + let absent = false; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + absent = + error instanceof Error && "code" in error && error.code === "ESRCH"; + } + if (absent) { + break; + } + await Bun.sleep(20); + } + expect(absent).toBe(true); + const compiler = await Bun.file(join(root, "compiler-requests")).text(); + expect((await down(root, true)).code).toBe(1); + expect(await Bun.file(join(root, "compiler-requests")).text()).toBe(compiler); + expect(await state(root)).toMatchObject({ + pending: true, + stopped: false, + beforeHooksPending: true, + hostHookPhase: "down.before", + }); + expect(await Bun.file(join(root, "engine")).exists()).toBe(false); +}, 30_000); diff --git a/tests/native-compose-down-hooks.test.ts b/tests/native-compose-down-hooks.test.ts new file mode 100644 index 00000000..76600c8f --- /dev/null +++ b/tests/native-compose-down-hooks.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import { readNativeComposeDownHookBinding } from "../src/lib/native-compose-down-hooks.ts"; +import type { NativeComposeGeneration } from "../src/lib/native-compose-generation.ts"; + +const generation: NativeComposeGeneration = { + identity: { + checkoutRoot: "/fixture", + repositoryRoot: "/fixture", + instance: null, + instanceId: "a".repeat(64), + composeProject: "hack-fixture", + ownerToken: "b".repeat(32), + }, + generationId: "c".repeat(32), + composeFile: "/fixture/compose.json", + profiles: ["dev"], + inputRevision: "d".repeat(64), +}; +const extension = "x-hack-native-down-hooks"; +const binding = { + version: 1 as const, + inputRevision: generation.inputRevision, + profiles: ["dev"], + overlay: null, +}; + +test("down source binding preserves effective null and original automatic/explicit selection while matching immutable manifest", () => { + expect( + readNativeComposeDownHookBinding({ generation, document: {} }) + ).toBeNull(); + expect( + readNativeComposeDownHookBinding({ + generation, + document: { [extension]: binding }, + }) + ).toEqual(binding); + const explicit = { ...binding, explicitOverlay: null }; + const result = readNativeComposeDownHookBinding({ + generation, + document: { [extension]: explicit }, + }); + expect(result).toEqual(explicit); + expect(Object.isFrozen(result)).toBe(true); + expect( + readNativeComposeDownHookBinding({ + generation, + document: Object.create({ [extension]: binding }), + }) + ).toBeNull(); +}); + +test.each([ + { inputRevision: "e".repeat(64) }, + { profiles: [] }, + { profiles: ["dev", "dev"] }, + { overlay: undefined }, + { explicitOverlay: undefined }, + { explicitOverlay: 17 }, + { command: "private-command" }, + { version: 2 }, +])("down binding refuses stale/malformed saved extension %j before acquisition", (change) => { + expect(() => + readNativeComposeDownHookBinding({ + generation, + document: { [extension]: { ...binding, ...change } }, + }) + ).toThrow("Values omitted"); +}); + +test.each([ + "version", + "inputRevision", + "profiles", + "overlay", +] as const)("down binding refuses an inherited required %s", (field) => { + const own = { ...binding }; + Reflect.deleteProperty(own, field); + const value = Object.assign(Object.create({ [field]: binding[field] }), own); + expect(() => + readNativeComposeDownHookBinding({ + generation, + document: { [extension]: value }, + }) + ).toThrow("Values omitted"); +}); diff --git a/tests/native-compose-fixture-volume.test.ts b/tests/native-compose-fixture-volume.test.ts new file mode 100644 index 00000000..7e0d87ce --- /dev/null +++ b/tests/native-compose-fixture-volume.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import type { NativeComposeOwnershipOptions } from "../src/lib/native-compose-ownership.ts"; +import { pinNativeComposeFixtureVolume } from "./e2e/native-compose-owned-fixture.ts"; + +const selection: NativeComposeOwnershipOptions = { + composeProject: "hack-down-fixture", + runtimeIdentity: "hack-down-fixture", + ownerToken: "a".repeat(32), + generationIds: ["b".repeat(32)], + expectedServices: ["app"], + expectedVolumes: [{ name: "fixture_data", storage: "data" }], +}; +const volume = { name: "fixture_data", storage: "data" }; +const inspected = { + ...volume, + createdAt: "2026-10-08T00:00:00Z", + composeProject: selection.composeProject, + runtimeIdentity: selection.runtimeIdentity, + ownerToken: selection.ownerToken, + version: "1", +}; +const observed = { containers: [], networks: [], volumes: [volume] }; + +test("fixture cleanup pins singleton creation/storage/owner and observes a same-name replacement", async () => { + const probe = async (args: readonly string[]) => { + expect(args.slice(0, 4)).toEqual([ + "volume", + "inspect", + volume.name, + "--format", + ]); + return JSON.stringify(inspected); + }; + const original = await pinNativeComposeFixtureVolume({ + selection, + observed, + probe, + }); + expect(original).toEqual({ + ...volume, + createdAt: inspected.createdAt, + composeProject: selection.composeProject, + runtimeIdentity: selection.runtimeIdentity, + ownerToken: selection.ownerToken, + }); + expect(Object.isFrozen(original)).toBe(true); + const replacement = await pinNativeComposeFixtureVolume({ + selection, + observed, + probe: async () => + JSON.stringify({ ...inspected, createdAt: "2026-10-08T01:00:00Z" }), + }); + expect(JSON.stringify(replacement)).not.toBe(JSON.stringify(original)); +}); + +test.each([ + { volumes: [] }, + { volumes: [volume, volume] }, + { volumes: [{ ...volume, storage: "rebound" }] }, +])("fixture pin refuses missing/extra/rebound observed volumes before inspect: %j", async ({ + volumes, +}) => { + let calls = 0; + await expect( + pinNativeComposeFixtureVolume({ + selection, + observed: { ...observed, volumes }, + probe: async () => { + calls += 1; + return JSON.stringify(inspected); + }, + }) + ).rejects.toThrow("original singleton"); + expect(calls).toBe(0); +}); + +test.each([ + { name: "rebound" }, + { storage: "rebound" }, + { createdAt: "" }, + { ownerToken: "c".repeat(32) }, + { runtimeIdentity: "rebound" }, + { composeProject: "rebound" }, + { version: "2" }, + { extra: true }, +])("fixture pin refuses changed immediate engine identity: %j", async (change) => { + await expect( + pinNativeComposeFixtureVolume({ + selection, + observed, + probe: async () => JSON.stringify({ ...inspected, ...change }), + }) + ).rejects.toThrow("identity changed"); +}); diff --git a/tests/native-compose-generation.test.ts b/tests/native-compose-generation.test.ts index ba21ff8d..21f7c7a2 100644 --- a/tests/native-compose-generation.test.ts +++ b/tests/native-compose-generation.test.ts @@ -372,7 +372,10 @@ test("effect captures its dependent finalizer before the first asynchronous chec }); }); -test("SIGKILL after route retirement preserves the pending stop generation for idempotent recovery", async () => { +test.each([ + "recovery", + "finite-down", +] as const)("SIGKILL after %s route retirement preserves the pending stop generation for idempotent recovery", async (mode) => { const root = await fixture(); const owner = await store(root); const generation = await owner.withMutation(async (mutation) => { @@ -382,7 +385,10 @@ test("SIGKILL after route retirement preserves the pending stop generation for i operation: "up", assertFresh: async () => {}, assertOwned: async () => {}, - effect: async () => ({ outcome: "uncertain", value: 1 }), + effect: async () => ({ + outcome: mode === "recovery" ? "uncertain" : "complete", + value: 0, + }), }); return published; }); @@ -392,7 +398,11 @@ test("SIGKILL after route retirement preserves the pending stop generation for i generationIdentity: generation.generationId, }); await claims.markEffectsPossible(attempt); - await claims.retain(attempt); + if (mode === "recovery") { + await claims.retain(attempt); + } else { + await claims.complete({ attempt, assertTransition: async () => {} }); + } const generationModule = new URL( "../src/lib/native-compose-generation.ts", import.meta.url @@ -419,9 +429,9 @@ test("SIGKILL after route retirement preserves the pending stop generation for i import {openNativeComposeRouteClaims} from ${JSON.stringify(claimsModule)}; const owner=await openNativeComposeGenerationStore({projectRoot:${JSON.stringify(root)},instance:null,mode:"saved"}); const claims=await openNativeComposeRouteClaims({root:${JSON.stringify(join(root, "claims"))},binding:${JSON.stringify(binding)},owner:${JSON.stringify(claimOwner)}}); - const generation=await owner.loadPending(); + const generation=await owner.${mode === "recovery" ? "loadPending()" : "loadCurrent().then(s=>s.generation)"}; if(!generation) throw new Error("missing recovery generation"); - await owner.withMutation(m=>m.runEffect({generation,operation:"down",recoverPending:true,assertOwned:async()=>{},effect:async()=>({outcome:"complete",value:0}),beforeComplete:async()=>{ + await owner.withMutation(m=>m.runEffect({generation,operation:"down",${mode === "recovery" ? "recoverPending:true" : 'assertFresh:async()=>{},downHooks:{after:{prepare:async()=>async()=>({outcome:"complete",value:0,ready:true})}}'},assertOwned:async()=>{},effect:async()=>({outcome:"complete",value:0}),beforeComplete:async()=>{ await claims.recoverStopped({references:[${JSON.stringify(attempt.reference)}],assertAbsent:async()=>{}}); await Bun.write(${JSON.stringify(join(root, "claims-retired"))},"retired"); process.kill(process.pid,"SIGKILL"); @@ -1982,3 +1992,303 @@ for (const unsafe of [ ).toHaveLength(0); }); } + +test.each([ + "before", + "after", +] as const)("finite down %s has exact phase/pending/operation/generation binding and known17 keeps stop pending", async (phase) => { + const owner = await store(await fixture()); + const generation = await activate(owner); + const events: string[] = []; + let captured: unknown; + await owner.withMutation(async (mutation) => { + const hook = { + prepare: async () => async () => { + captured = (await Bun.file(receiptPath(owner)).json()).beforeHooks; + events.push(phase); + return { outcome: "complete" as const, value: 17, ready: false }; + }, + }; + const result = await mutation.runEffect({ + generation, + operation: "down", + assertFresh: async () => {}, + assertOwned: async () => {}, + downHooks: { [phase]: hook }, + effect: async () => { + events.push("engine"); + return { outcome: "complete", value: 0 }; + }, + beforeComplete: async () => { + events.push("retire"); + }, + }); + expect(result).toEqual({ outcome: "uncertain", value: 17 }); + }); + const state = await owner.loadCurrent(); + expect(captured).toEqual({ + token: expect.stringMatching(/^[a-f0-9]{32}$/), + phase: `down.${phase}`, + pendingToken: state.pending?.token, + generationId: generation.generationId, + operation: "down", + }); + expect(events).toEqual(phase === "before" ? ["before"] : ["engine", "after"]); + expect(state).toMatchObject({ stopped: false, beforeHooksPending: false }); + expect(state.pending?.generationId).toBe(generation.generationId); +}); + +test.each([ + "before", + "after", +] as const)("unknown down %s persists across decoding and explicit saved engine recovery without replay", async (phase) => { + const root = await fixture(); + const owner = await store(root); + const generation = await activate(owner); + const hook = { + prepare: async () => async () => ({ + outcome: "uncertain" as const, + value: 130, + ready: false, + }), + }; + await owner.withMutation(async (mutation) => { + const result = await mutation.runEffect({ + generation, + operation: "down", + assertFresh: async () => {}, + assertOwned: async () => {}, + downHooks: { [phase]: hook }, + effect: async () => ({ outcome: "complete", value: 0 }), + }); + expect(result.outcome).toBe("uncertain"); + }); + const intent = (await Bun.file(receiptPath(owner)).json()).beforeHooks; + const saved = await store(root, null, "saved"); + let prepared = false; + await saved.withMutation(async (mutation) => { + await rejected( + mutation.runEffect({ + generation: (await saved.loadPending()) ?? generation, + operation: "down", + assertFresh: async () => {}, + assertOwned: async () => {}, + recoverPending: true, + downHooks: { + before: { + prepare: async () => { + prepared = true; + return async () => ({ + outcome: "complete", + value: 0, + ready: true, + }); + }, + }, + }, + effect: async () => ({ outcome: "complete", value: 0 }), + }), + "E_NATIVE_COMPOSE_STATE" + ); + await rejected( + mutation.runEffect({ + generation: (await saved.loadPending()) ?? generation, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + effect: async () => ({ outcome: "complete", value: 0 }), + beforeComplete: async () => { + if ((await saved.loadCurrent()).beforeHooksPending) { + throw new Error("host uncertainty retained"); + } + }, + }), + "E_NATIVE_COMPOSE_UNCERTAIN" + ); + }); + expect(prepared).toBe(false); + expect((await Bun.file(receiptPath(owner)).json()).beforeHooks).toEqual( + intent + ); + expect(await saved.loadCurrent()).toMatchObject({ + stopped: false, + beforeHooksPending: true, + hostHookPhase: `down.${phase}`, + }); + expect((await saved.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); +}); + +test.each([ + "pendingToken", + "generationId", + "operation", +] as const)("down hook completion refuses changed %s without clearing exact intent", async (field) => { + const owner = await store(await fixture()); + const generation = await activate(owner); + await owner.withMutation(async (mutation) => { + await rejected( + mutation.runEffect({ + generation, + operation: "down", + assertFresh: async () => {}, + assertOwned: async () => {}, + downHooks: { + before: { + prepare: async () => async () => { + const receipt = await Bun.file(receiptPath(owner)).json(); + receipt.beforeHooks[field] = + field === "operation" ? "up" : "e".repeat(32); + await Bun.write(receiptPath(owner), JSON.stringify(receipt)); + return { outcome: "complete", value: 0, ready: true }; + }, + }, + }, + effect: async () => ({ outcome: "complete", value: 0 }), + }), + "E_NATIVE_COMPOSE_UNCERTAIN" + ); + }); + if (field !== "operation") { + expect((await owner.loadCurrent()).hostHookPhase).toBe("down.before"); + expect((await owner.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); + } else { + await expect(owner.loadCurrent()).rejects.toThrow(); + } +}); + +test.each([ + "effect", + "finalizer", +] as const)("hook-enabled down refuses a late host intent at the %s completion boundary", async (boundary) => { + const owner = await store(await fixture()); + const generation = await activate(owner); + const inject = async () => { + const receipt = await Bun.file(receiptPath(owner)).json(); + receipt.beforeHooks = { + token: "e".repeat(32), + phase: "down.after", + pendingToken: receipt.pending.token, + generationId: generation.generationId, + operation: "down", + }; + await Bun.write(receiptPath(owner), JSON.stringify(receipt)); + }; + let finalized = false; + await owner.withMutation(async (mutation) => { + await rejected( + mutation.runEffect({ + generation, + operation: "down", + assertFresh: async () => {}, + assertOwned: async () => {}, + downHooks: {}, + effect: async () => { + if (boundary === "effect") { + await inject(); + } + return { outcome: "complete", value: 0 }; + }, + beforeComplete: async () => { + finalized = true; + await inject(); + }, + }), + "E_NATIVE_COMPOSE_UNCERTAIN" + ); + }); + expect(finalized).toBe(boundary === "finalizer"); + expect(await owner.loadCurrent()).toMatchObject({ + stopped: false, + beforeHooksPending: true, + hostHookPhase: "down.after", + }); + expect((await owner.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); +}); + +test.each([ + "up", + "restart", + "run", +] as const)("down callback admission refuses %s before prepare/effect", async (operation) => { + const owner = await store(await fixture()); + const generation = await activate(owner); + let calls = 0; + await owner.withMutation(async (mutation) => { + await rejected( + mutation.runEffect({ + generation, + operation, + assertFresh: async () => {}, + assertOwned: async () => {}, + downHooks: { + before: { + prepare: async () => { + calls++; + return async () => ({ + outcome: "complete", + value: 0, + ready: true, + }); + }, + }, + }, + effect: async () => { + calls++; + return { outcome: "complete", value: 0 }; + }, + }), + "E_NATIVE_COMPOSE_STATE" + ); + }); + expect(calls).toBe(0); + expect((await owner.loadCurrent()).pending).toBeNull(); +}); + +test("finite down callbacks refuse a saved generation that is no longer current", async () => { + const owner = await store(await fixture()); + const old = await activate(owner); + let prepared = false; + await owner.withMutation(async (mutation) => { + const generation = await publish(mutation); + await mutation.runEffect({ + generation, + operation: "restart", + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => ({ outcome: "complete", value: 0 }), + }); + await rejected( + mutation.runEffect({ + generation: old, + operation: "down", + assertFresh: async () => {}, + assertOwned: async () => {}, + downHooks: { + before: { + prepare: async () => { + prepared = true; + return async () => ({ + outcome: "complete", + value: 0, + ready: true, + }); + }, + }, + }, + effect: async () => ({ outcome: "complete", value: 0 }), + }), + "E_NATIVE_COMPOSE_UNCERTAIN" + ); + }); + expect(prepared).toBe(false); + expect((await owner.loadCurrent()).pending).toBeNull(); + expect((await owner.loadCurrent()).generation?.generationId).not.toBe( + old.generationId + ); +}); diff --git a/tests/native-compose-host-hooks.test.ts b/tests/native-compose-host-hooks.test.ts index 40a38ea6..0b38b24f 100644 --- a/tests/native-compose-host-hooks.test.ts +++ b/tests/native-compose-host-hooks.test.ts @@ -6,6 +6,7 @@ import { type NativeComposeHook, selectNativeComposeAfterHooks, selectNativeComposeBeforeHooks, + selectNativeComposeDownHooks, } from "../src/lib/native-compose-host-contract.ts"; import { assertNativeComposeBeforeHookBindings, @@ -209,16 +210,16 @@ test("timeout reaps a resistant descendant group and blocks later hooks", async expect(await Bun.file(join(root, "later")).exists()).toBe(false); }); -test("persistent processes, other phases, traversal and unknown normalized fields refuse", () => { +test("all finite lifecycle phases select while persistent processes, traversal and unknown fields refuse", () => { const valid = hook("before", { shell: "true" }); for (const host of [ - { down: { before: [valid] } }, - { down: { after: [valid] } }, { processes: { worker: valid } }, { up: { before: [{ ...valid, cwd: "../outside" }] } }, { up: { before: [{ ...valid, persistent: true }] } }, ]) { - expect(() => selectNativeComposeBeforeHooks({ host })).toThrow("finite up"); + expect(() => selectNativeComposeBeforeHooks({ host })).toThrow( + "finite lifecycle" + ); } expect( selectNativeComposeBeforeHooks({ host: { up: { before: [valid] } } }) @@ -227,6 +228,21 @@ test("persistent processes, other phases, traversal and unknown normalized field const both = { host: { up: { before: [valid], after: [after] } } }; expect(selectNativeComposeBeforeHooks(both)).toEqual([valid]); expect(selectNativeComposeAfterHooks(both)).toEqual([after]); + const downBefore = hook("down-before", { shell: "true" }); + const downAfter = hook("down-after", { shell: "true" }); + expect( + selectNativeComposeDownHooks({ + host: { + ...both.host, + down: { before: [downBefore], after: [downAfter] }, + }, + }) + ).toEqual({ before: [downBefore], after: [downAfter] }); + expect(() => + selectNativeComposeDownHooks({ + host: { up: { before: [valid] }, down: { after: [valid] } }, + }) + ).toThrow(); expect(() => selectNativeComposeAfterHooks({ host: { up: { before: [valid], after: [valid] } }, diff --git a/tests/native-compose-renderer.test.ts b/tests/native-compose-renderer.test.ts index 679bfd04..b8687715 100644 --- a/tests/native-compose-renderer.test.ts +++ b/tests/native-compose-renderer.test.ts @@ -690,6 +690,21 @@ test("nonempty host hooks/processes and authored routing require their separate before: [{ name: "hook", command: { exec: ["private-sentinel"] } }], }, }, + { + up: { + after: [{ name: "hook", command: { exec: ["private-sentinel"] } }], + }, + }, + { + down: { + before: [{ name: "hook", command: { exec: ["private-sentinel"] } }], + }, + }, + { + down: { + after: [{ name: "hook", command: { exec: ["private-sentinel"] } }], + }, + }, { processes: { tunnel: { command: { shell: "private-sentinel" } } } }, ]) { refuses( diff --git a/tests/native-compose-route-claims.test.ts b/tests/native-compose-route-claims.test.ts index 1a353b38..f2fde419 100644 --- a/tests/native-compose-route-claims.test.ts +++ b/tests/native-compose-route-claims.test.ts @@ -732,6 +732,95 @@ test("failed absence proof retains every claim and does not publish deletion int expect(await fs.readdir(join(ownerPath(root), "releases"))).toEqual([]); }); +test("non-retiring absence proof keeps the completed journal and exact claim inode", async () => { + const root = await fixture(); + const result = await store(root); + const attempt = await acquire(result); + await complete(result, attempt); + const path = claimPath(root); + const before = await fs.lstat(path); + const text = await Bun.file(path).text(); + const journal = await fs.readdir(attemptPath(root, attempt)); + let proofs = 0; + await result.verifyAbsent({ + references: [attempt.reference], + assertAbsent: async (selection) => { + expect(selection).toEqual({ + binding: BINDING, + owner: OWNER, + hostnames: [HOST], + }); + proofs += 1; + }, + }); + expect(proofs).toBe(1); + expect((await fs.lstat(path)).ino).toBe(before.ino); + expect(await Bun.file(path).text()).toBe(text); + expect(await fs.readdir(attemptPath(root, attempt))).toEqual(journal); + expect(await fs.readdir(join(ownerPath(root), "releases"))).toEqual([]); + expect((await result.reopen(attempt.reference)).phase).toBe("complete"); +}); + +test.each([ + "armed", + "retained", +] as const)("non-retiring absence refuses anchored %s uncertainty before proof or journal writes", async (phase) => { + const root = await fixture(); + const result = await store(root); + const attempt = await acquire(result); + await result.markEffectsPossible(attempt); + if (phase === "retained") { + await result.retain(attempt); + } + const journal = await fs.readdir(attemptPath(root, attempt)); + const claim = await Bun.file(claimPath(root)).text(); + let proofs = 0; + await expect( + result.verifyAbsent({ + references: [attempt.reference], + assertAbsent: async () => { + proofs += 1; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); + expect(proofs).toBe(0); + expect(await fs.readdir(attemptPath(root, attempt))).toEqual(journal); + expect(await fs.readdir(join(ownerPath(root), "releases"))).toEqual([]); + expect(await Bun.file(claimPath(root)).text()).toBe(claim); + expect((await result.reopen(attempt.reference)).phase).toBe(phase); +}); + +test.each([ + "wrong-reference", + "journal-drift", +] as const)("non-retiring absence refuses %s and retains claims", async (failure) => { + const root = await fixture(); + const result = await store(root); + const attempt = await acquire(result); + await complete(result, attempt); + const before = await Bun.file(claimPath(root)).text(); + let proofs = 0; + await expect( + result.verifyAbsent({ + references: [ + failure === "wrong-reference" + ? { ...attempt.reference, generationIdentity: "e".repeat(32) } + : attempt.reference, + ], + assertAbsent: async () => { + proofs += 1; + await acquire(result, ["drift.fixture.hack.local"]); + }, + }) + ).rejects.toThrow("values omitted"); + expect(proofs).toBe(failure === "wrong-reference" ? 0 : 1); + expect(await Bun.file(claimPath(root)).text()).toBe(before); + expect(await fs.readdir(join(ownerPath(root), "releases"))).toEqual([]); + expect( + await Bun.file(join(attemptPath(root, attempt), "stopped.json")).exists() + ).toBe(false); +}); + for (const change of [ "inode", "symlink", diff --git a/tests/native-compose-route-owner.test.ts b/tests/native-compose-route-owner.test.ts index 4ea84f60..27983e10 100644 --- a/tests/native-compose-route-owner.test.ts +++ b/tests/native-compose-route-owner.test.ts @@ -21,6 +21,7 @@ import { prepareNativeComposeSavedRunRouting, readNativeComposeRouteMetadata, releaseNativeComposeSavedRoutes, + verifyNativeComposeSavedRoutesAbsent, } from "../src/lib/native-compose-route-owner.ts"; import { NativeComposeRoutingError } from "../src/lib/native-compose-routing.ts"; import type { NativeRoutingResolution } from "../src/lib/native-routing-plan-protocol.ts"; @@ -779,3 +780,79 @@ test("ordinary unrouted projects do not select ingress or open route state", asy expect(checks.events).toEqual([]); expect(await Bun.file(join(root, "compose-routing")).exists()).toBe(false); }); + +test.each([ + "complete", + "ingress", + "inventory", + "proxy", +] as const)("non-retiring saved route absence %s keeps exact hostname claim held", async (proof) => { + const owner = await prepare(fixture()); + await complete(owner); + const checks = probes(); + if (proof === "ingress") { + checks.loseIngress(); + } + if (proof === "inventory") { + checks.collideInventory(); + } + if (proof === "proxy") { + checks.staleProxy(); + } + const verify = verifyNativeComposeSavedRoutesAbsent({ + owner: OWNER, + saved: [{ generationId: GENERATION, document: owner.document }], + deadline: Date.now() + 5000, + io: checks.io, + }); + if (proof === "complete") { + await verify; + expect(checks.events).toEqual([ + "ingress-recheck", + "inventory-absent", + "proxy-absent-fixture.dev.test", + ]); + } else { + await expect(verify).rejects.toThrow(); + } + await expect( + (await claims(OTHER)).acquire({ + hostnames: ["fixture.dev.test"], + generationIdentity: NEXT, + }) + ).rejects.toThrow(); +}); + +test("non-retiring saved stop proof respects a foreign handoff of a retired previous hostname", async () => { + const original = await prepare(fixture()); + await complete(original); + const next = await prepare(fixture("next.test", NEXT), probes(), [ + { generationId: GENERATION, document: original.document }, + ]); + await complete(next); + const foreign = await claims(OTHER); + const handoff = await foreign.acquire({ + hostnames: ["fixture.dev.test"], + generationIdentity: "e".repeat(32), + }); + const checks = probes(); + await verifyNativeComposeSavedRoutesAbsent({ + owner: OWNER, + saved: [{ generationId: NEXT, document: next.document }], + deadline: Date.now() + 5000, + io: checks.io, + }); + expect(checks.events).toEqual([ + "ingress-recheck", + "inventory-absent", + "proxy-absent-fixture.next.test", + ]); + expect((await foreign.reopen(handoff.reference)).phase).toBe("reserved"); + await expect( + foreign.acquire({ + hostnames: ["fixture.next.test"], + generationIdentity: "f".repeat(32), + }) + ).rejects.toThrow(); + await foreign.rollback(handoff); +});