From e205e3543d23837f4d18c70bf52bcc6f78335efc Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 00:44:14 -0400 Subject: [PATCH 1/9] feat: map retained legacy routing without activation --- docs/reference/native-compose-adoption.md | 18 ++ src/lib/native-compose-adoption-plan.ts | 32 +- src/lib/native-config-import-plan.ts | 66 +++- src/lib/native-config-import-routing.ts | 349 +++++++++++++++++++++ src/lib/native-config-import-storage.ts | 3 + tests/native-config-import-routing.test.ts | 280 +++++++++++++++++ 6 files changed, 733 insertions(+), 15 deletions(-) create mode 100644 src/lib/native-config-import-routing.ts create mode 100644 tests/native-config-import-routing.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index a9b9fd75d..247bcc58d 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -497,3 +497,21 @@ static fixture's exact ownership and daemon checks for cleanup. Use the same prerequisites and flags as above with `--only=native-compose-adoption-managed-worktrees`. Registration and synthetic fixture controls do not establish a live pass. + +## Retained routing contract under implementation + +The separate private version 14 mapper admits literal legacy `dev_host`, its +already configured OAuth alias and `open.prefer`, together with closed static +Caddy HTTP upstream labels. It pins full HTTPS origins rather than deriving a +new host from the project name or a global domain. Routed services must configure +exactly the existing `hack-dev` attachment and the project default bridge; other +services retain only the default bridge and existing local named storage. + +This mapper is not enabled by the ordinary import preview or adoption command. +It supplies private intent and value-free field provenance only. Resource and +ingress incarnation, hostname reservations, active proxy dispatch, typed local +precedence, generated-source fidelity and stopped rollback must be admitted by +the distinct retained routing owner before activation. Builds, jobs, readiness, +source binds, files, branch overrides and custom bridges remain outside this +initial routing family. Neither the map nor a synthetic control proves browser +TLS, OAuth login or application acceptance; global DNS and trust are unchanged. diff --git a/src/lib/native-compose-adoption-plan.ts b/src/lib/native-compose-adoption-plan.ts index 4ddf0caaa..f2caad2b8 100644 --- a/src/lib/native-compose-adoption-plan.ts +++ b/src/lib/native-compose-adoption-plan.ts @@ -9,8 +9,13 @@ import { mapLegacyNativeAdoptionBaseline, mapLegacyNativeCompletedJobAdoptionBaseline, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { + legacyRoutingStorageDocument, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; import { type LegacyComposeStorageIntent, mapLegacyComposeStorage, @@ -90,9 +95,18 @@ export function planLegacyComposeRetainedBasicBuildAdoption(opts: { return plan(opts, mapLegacyNativeRetainedBasicBuild(opts)); } +/** Separate v14 authored contract; mixed bind/build/job/branch families remain refused. */ +export function planLegacyComposeRetainedRoutingAdoption(opts: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeAdoptionPlan { + return plan(opts, mapLegacyNativeRetainedRouting(opts), true); +} + function plan( opts: { readonly configText: string; readonly composeText: string }, - baseline: ReturnType + baseline: ReturnType, + routingFamily = false ): LegacyComposeAdoptionPlan { const config = parseImportDocument({ text: opts.configText, @@ -102,12 +116,22 @@ function plan( text: opts.composeText, document: "compose", }).value; - const qualified = mapLegacyComposeStorage({ config, compose }); + const routing = routingFamily + ? mapLegacyComposeRouting({ config, compose }) + : undefined; + const qualified = mapLegacyComposeStorage({ + config, + compose: + routing && compose ? legacyRoutingStorageDocument(compose) : compose, + }); const mapping = { - supported: qualified !== undefined, + supported: + qualified !== undefined && (!routingFamily || routing !== undefined), accepted: qualified?.accepted ?? new Map(), }; - const intent = qualified?.intent; + const intent = qualified + ? { ...qualified.intent, ...(routing ? { routing: routing.intent } : {}) } + : undefined; const fields = storageFields(baseline.report.fields, mapping); const supported = mapping.supported && !fields.some((field) => field.status === "refused"); diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 69eefd6aa..7d5d57c46 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -22,6 +22,10 @@ import { mapLegacyComposeDependencies, mapLegacyComposeHealthcheck, } from "./native-config-import-readiness.ts"; +import { + legacyRoutingStorageDocument, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; import { mapLegacyComposeStorage } from "./native-config-import-storage.ts"; import { normalizeEnvConfigName } from "./project.ts"; @@ -142,6 +146,7 @@ type NativeImportPurpose = | "adoption-baseline" | "completed-job-adoption" | "retained-basic-build" + | "retained-routing" | "storage-adoption"; /** @@ -189,6 +194,16 @@ function mapLegacyNativeInput(opts: { } } const context = { config: config.value, candidate, mark, refuse }; + const routing = + opts.purpose === "retained-routing" + ? mapLegacyComposeRouting({ + config: config.value, + compose: compose.value, + }) + : undefined; + if (opts.purpose === "retained-routing" && !routing) { + refuse("config", "/dev_host", "explicit_retained_routing_required"); + } mapOverlay(context); mapWorktree(context); mapServices({ @@ -200,26 +215,43 @@ function mapLegacyNativeInput(opts: { opts.purpose === "preview" || opts.purpose === "retained-basic-build", jobPreview: opts.purpose !== "adoption-baseline" && - opts.purpose !== "retained-basic-build", - }); - mapOwnedNetwork({ - project: name, - compose: compose.value, - candidate, - mark, - refuse, - purpose: opts.purpose, + opts.purpose !== "retained-basic-build" && + opts.purpose !== "retained-routing", }); + if (routing) { + Object.assign(candidate, routing.candidate); + for (const pointer of routing.pointers) { + mark( + pointer.document, + pointer.source, + pointer.target, + "existing_routing_binding", + true + ); + } + } else { + mapOwnedNetwork({ + project: name, + compose: compose.value, + candidate, + mark, + refuse, + purpose: opts.purpose, + }); + } if (opts.purpose === "preview") { mapFileCandidate({ compose: compose.value, candidate, mark, refuse }); } if ( opts.purpose === "storage-adoption" || - opts.purpose === "retained-basic-build" + opts.purpose === "retained-basic-build" || + opts.purpose === "retained-routing" ) { mapStorageCandidate({ config: config.value, - compose: compose.value, + compose: routing + ? legacyRoutingStorageDocument(compose.value) + : compose.value, candidate, mark, refuse, @@ -390,6 +422,18 @@ export function mapLegacyNativeRetainedBasicBuild(opts: { }); } +/** Literal legacy origins only. A complete map grants no ingress or retained resource authority. */ +export function mapLegacyNativeRetainedRouting(opts: { + readonly configText: string; + readonly composeText: string; +}): NativeImportPlan { + return mapLegacyNativeInput({ + configText: opts.configText, + composeText: opts.composeText, + purpose: "retained-routing", + }); +} + type FileMappingContext = Pick; function mapFileDeclarations( diff --git a/src/lib/native-config-import-routing.ts b/src/lib/native-config-import-routing.ts new file mode 100644 index 000000000..3faf89873 --- /dev/null +++ b/src/lib/native-config-import-routing.ts @@ -0,0 +1,349 @@ +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; +import { isRecord } from "./guards.ts"; +import { importPointer } from "./native-config-import-parser.ts"; +import { resolveProjectOauthAliasHost } from "./project.ts"; + +const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const UPSTREAM = /^\{\{upstreams (?:http )?([1-9][0-9]{0,4})\}\}$/; +const ROUTE_KEYS = [ + "caddy", + "caddy.reverse_proxy", + "caddy.tls", + "caddy_ingress_network", +]; + +export type LegacyComposeRoutingIntent = { + readonly version: 14; + readonly devHost: string; + readonly aliasHost: string | null; + readonly openPreference: "auto" | "alias" | "dev"; + readonly openOrigin: string; + readonly routes: readonly { + readonly service: string; + readonly hostname: string; + readonly port: number; + readonly origins: readonly string[]; + readonly labels: Readonly>; + }[]; +}; +type Pointer = { + readonly document: "config" | "compose"; + readonly source: string; + readonly target: string; +}; + +function exact( + value: Record, + allowed: readonly string[] +): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} +function host(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= 253 && + value.includes(".") && + value.split(".").every((part) => LABEL.test(part)) + ); +} +function labels(value: unknown): Readonly> | undefined { + const result: Record = Object.create(null); + let entries: unknown[][] = []; + if (isRecord(value)) { + entries = Object.entries(value); + } else if (Array.isArray(value)) { + entries = value.map((item) => + typeof item === "string" && item.includes("=") + ? [item.slice(0, item.indexOf("=")), item.slice(item.indexOf("=") + 1)] + : [] + ); + } + if (!entries.length) { + return undefined; + } + for (const entry of entries) { + const [key, raw] = entry; + if ( + typeof key !== "string" || + typeof raw !== "string" || + !ROUTE_KEYS.includes(key) || + Object.hasOwn(result, key) + ) { + return undefined; + } + result[key] = raw; + } + return result; +} +function attached(value: unknown, routed: boolean): boolean { + if (value === undefined) { + return !routed; + } + const expected = routed + ? [DEFAULT_INGRESS_NETWORK, "default"].sort() + : ["default"]; + return ( + Array.isArray(value) && + value.every((item) => typeof item === "string") && + JSON.stringify([...value].sort()) === JSON.stringify(expected) + ); +} +function network(source: unknown): boolean { + if ( + !(isRecord(source) && exact(source, [DEFAULT_INGRESS_NETWORK, "default"])) + ) { + return false; + } + const ingress = source[DEFAULT_INGRESS_NETWORK]; + const local = source.default; + return ( + isRecord(ingress) && + exact(ingress, ["external"]) && + ingress.external === true && + (!Object.hasOwn(source, "default") || + local === null || + (isRecord(local) && Object.keys(local).length === 0)) + ); +} + +/** Closed static route conversion. Literal origins preserve the original host even + * when it differs from the Compose name. This grants no ingress/resource authority. */ +export function mapLegacyComposeRouting(opts: { + readonly config: Record | undefined; + readonly compose: Record | undefined; +}): + | { + readonly intent: LegacyComposeRoutingIntent; + readonly candidate: Readonly>; + readonly pointers: readonly Pointer[]; + } + | undefined { + const { config, compose } = opts; + if ( + !( + config && + compose && + host(config.dev_host) && + isRecord(compose.services) && + network(compose.networks) + ) + ) { + return undefined; + } + const devHost = config.dev_host; + let aliasHost: string | null = null; + if (Object.hasOwn(config, "oauth")) { + if ( + !( + isRecord(config.oauth) && + exact(config.oauth, ["enabled", "tld"]) && + (!Object.hasOwn(config.oauth, "enabled") || + typeof config.oauth.enabled === "boolean") && + (!Object.hasOwn(config.oauth, "tld") || + (typeof config.oauth.tld === "string" && + LABEL.test(config.oauth.tld))) + ) + ) { + return undefined; + } + aliasHost = resolveProjectOauthAliasHost({ + devHost, + oauth: { + ...(config.oauth.enabled === true ? { enabled: true } : {}), + ...(typeof config.oauth.tld === "string" + ? { tld: config.oauth.tld } + : {}), + }, + }); + if (aliasHost !== null && !host(aliasHost)) { + return undefined; + } + } + let openPreference: "auto" | "alias" | "dev" = "auto"; + if (Object.hasOwn(config, "open")) { + if (!(isRecord(config.open) && exact(config.open, ["prefer"]))) { + return undefined; + } + if (Object.hasOwn(config.open, "prefer")) { + const value = config.open.prefer; + if (value !== "auto" && value !== "alias" && value !== "dev") { + return undefined; + } + openPreference = value; + } + } + if (openPreference === "alias" && aliasHost === null) { + return undefined; + } + const routes: LegacyComposeRoutingIntent["routes"][number][] = []; + const pointers: Pointer[] = [ + { document: "config", source: "/dev_host", target: "/routes/origin" }, + { document: "compose", source: "/networks", target: "/existing_ingress" }, + ]; + if (Object.hasOwn(config, "oauth")) { + pointers.push({ + document: "config", + source: "/oauth", + target: "/routes/aliases", + }); + } + if (Object.hasOwn(config, "open")) { + pointers.push({ document: "config", source: "/open", target: "/open" }); + } + const occupied = new Set(); + for (const [service, raw] of Object.entries(compose.services).sort( + ([a], [b]) => a.localeCompare(b) + )) { + if ( + !(isRecord(raw) && LABEL.test(service)) || + [ + "build", + "profiles", + "depends_on", + "healthcheck", + "ports", + "deploy", + "configs", + "secrets", + ].some((key) => Object.hasOwn(raw, key)) + ) { + return undefined; + } + const selected = Object.hasOwn(raw, "labels") + ? labels(raw.labels) + : undefined; + if (Object.hasOwn(raw, "labels") && !selected) { + return undefined; + } + if (!attached(raw.networks, selected !== undefined)) { + return undefined; + } + const servicePointer = importPointer("/services", service); + if (Object.hasOwn(raw, "networks")) { + pointers.push({ + document: "compose", + source: `${servicePointer}/networks`, + target: "/existing_ingress/attachments", + }); + } + if (!selected) { + continue; + } + const sites = selected.caddy?.split(",").map((site) => site.trim()); + const upstream = + typeof selected["caddy.reverse_proxy"] === "string" + ? UPSTREAM.exec(selected["caddy.reverse_proxy"]) + : null; + const port = Number(upstream?.[1]); + if ( + !( + sites?.length && + sites.every(host) && + new Set(sites).size === sites.length && + upstream && + port <= 65_535 && + selected["caddy.tls"] === "internal" && + (!Object.hasOwn(selected, "caddy_ingress_network") || + selected.caddy_ingress_network === DEFAULT_INGRESS_NETWORK) + ) + ) { + return undefined; + } + const primary = sites.find( + (site) => site === devHost || site.endsWith(`.${devHost}`) + ); + if (!primary) { + return undefined; + } + const prefix = + primary === devHost ? "" : primary.slice(0, -(devHost.length + 1)); + // The compiler reserves "project" as its apex sentinel, not a literal prefix. + if (prefix === "project" || (prefix !== "" && !LABEL.test(prefix))) { + return undefined; + } + const aliases = aliasHost + ? [prefix ? `${prefix}.${aliasHost}` : aliasHost] + : []; + if ( + JSON.stringify([...sites].sort()) !== + JSON.stringify([primary, ...aliases].sort()) || + sites.some((site) => occupied.has(site)) + ) { + return undefined; + } + for (const site of sites) { + occupied.add(site); + } + routes.push({ + service, + hostname: prefix || "project", + port, + origins: sites.map((site) => `https://${site}`).sort(), + labels: Object.freeze({ ...selected }), + }); + pointers.push({ + document: "compose", + source: `${servicePointer}/labels`, + target: `/routes/http/${service}`, + }); + } + if (!routes.some((route) => route.hostname === "project")) { + return undefined; + } + const openOrigin = `https://${openPreference !== "dev" && aliasHost ? aliasHost : devHost}`; + return { + intent: Object.freeze({ + version: 14, + devHost, + aliasHost, + openPreference, + openOrigin, + routes: Object.freeze(routes), + }), + candidate: Object.freeze({ + routes: { + origin: `https://${devHost}`, + ...(aliasHost + ? { + aliases: { oauth: { origin: `https://${aliasHost}` } }, + oauth_alias: "oauth", + } + : {}), + http: Object.fromEntries( + routes.map((route) => [ + route.service, + { + service: route.service, + port: route.port, + hostname: route.hostname, + }, + ]) + ), + }, + open: { prefer: openPreference }, + }), + pointers: Object.freeze(pointers), + }; +} + +/** Storage admission remains the existing default-bridge/named-volume contract; + * the separately verified routing owner owns the removed shared attachment. */ +export function legacyRoutingStorageDocument( + compose: Record +): Record { + const result: Record = { ...compose }; + Reflect.deleteProperty(result, "networks"); + if (isRecord(compose.services)) { + result.services = Object.fromEntries( + Object.entries(compose.services).map(([name, value]) => { + if (!isRecord(value)) { + return [name, value]; + } + const service = { ...value }; + Reflect.deleteProperty(service, "networks"); + return [name, service]; + }) + ); + } + return result; +} diff --git a/src/lib/native-config-import-storage.ts b/src/lib/native-config-import-storage.ts index d5cb04b2c..db7aad319 100644 --- a/src/lib/native-config-import-storage.ts +++ b/src/lib/native-config-import-storage.ts @@ -6,6 +6,7 @@ import { mapLegacyOwnedNetwork, } from "./native-config-import-network.ts"; import { importPointer } from "./native-config-import-parser.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/; @@ -16,6 +17,8 @@ export type LegacyComposeStorageIntent = { readonly services: readonly string[]; readonly ownedNetwork?: LegacyOwnedNetworkIntent; readonly ownedNetworks?: LegacyOwnedNetworksIntent; + /** Required only by the distinct private retained-routing owner. */ + readonly routing?: LegacyComposeRoutingIntent; readonly volumes: readonly { readonly storage: string; readonly name: string; diff --git a/tests/native-config-import-routing.test.ts b/tests/native-config-import-routing.test.ts new file mode 100644 index 000000000..57ddc5f3e --- /dev/null +++ b/tests/native-config-import-routing.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from "bun:test"; +import { + planLegacyComposeAdoption, + planLegacyComposeRetainedRoutingAdoption, +} from "../src/lib/native-compose-adoption-plan.ts"; +import { + mapLegacyNativeImport, + mapLegacyNativeRetainedRouting, + mapLegacyNativeStorageAdoption, +} from "../src/lib/native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "../src/lib/native-config-import-routing.ts"; + +const CANARY = "private-route-source-canary"; +function fixture() { + return { + config: { + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true, tld: "gy" }, + open: { prefer: "alias" }, + worktree: { auto_branch: false }, + }, + compose: { + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + web: { + image: CANARY, + networks: ["hack-dev", "default"], + labels: { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + }, + db: { image: "db:1", volumes: ["data:/data"] }, + }, + volumes: { data: {} }, + }, + }; +} +function inputs(config: unknown, compose: unknown) { + return { + configText: JSON.stringify(config), + composeText: JSON.stringify(compose), + }; +} +test("v14 preserves the literal legacy host, existing alias, open preference and named storage without changing old families", () => { + const { config, compose } = fixture(); + const source = inputs(config, compose); + const before = JSON.stringify({ config, compose }); + const mapped = mapLegacyNativeRetainedRouting(source); + const planned = planLegacyComposeRetainedRoutingAdoption(source); + expect(mapped.report.complete).toBe(true); + expect(mapped.candidate).toMatchObject({ + routes: { + origin: "https://original.hack.local", + aliases: { oauth: { origin: "https://original.hack.gy" } }, + oauth_alias: "oauth", + http: { web: { service: "web", port: 3000, hostname: "project" } }, + }, + open: { prefer: "alias" }, + storage: { data: { kind: "persistent", scope: "worktree" } }, + }); + expect(planned.report.supported).toBe(true); + expect(planned.intent?.routing?.openOrigin).toBe("https://original.hack.gy"); + expect(planned.intent?.volumes).toEqual([ + { name: "fixture_data", storage: "data" }, + ]); + expect(planned.intent?.ownedNetwork).toBeUndefined(); + expect(JSON.stringify({ config, compose })).toBe(before); + for (const older of [ + mapLegacyNativeImport(source), + mapLegacyNativeStorageAdoption(source), + ]) { + expect(older.report.complete).toBe(false); + } + expect(planLegacyComposeAdoption(source).report.supported).toBe(false); + expect(JSON.stringify(mapped)).not.toContain(CANARY); + expect(JSON.stringify(planned)).not.toContain("original.hack"); + expect(Object.isFrozen(planned.intent?.routing?.routes[0]?.labels)).toBe( + true + ); +}); +test("custom existing hosts keep their exact primary origin without inventing an OAuth alias", () => { + const { config, compose } = fixture(); + const selected = { + ...config, + dev_host: "app.example.test", + open: { prefer: "auto" }, + }; + compose.services.web.labels.caddy = "app.example.test"; + const mapped = mapLegacyComposeRouting({ config: selected, compose }); + expect(mapped?.intent.aliasHost).toBeNull(); + expect(mapped?.intent.openOrigin).toBe("https://app.example.test"); + expect(mapped?.candidate.routes).toEqual({ + origin: "https://app.example.test", + http: { web: { service: "web", port: 3000, hostname: "project" } }, + }); + expect( + mapLegacyComposeRouting({ + config: { ...selected, open: { prefer: "alias" } }, + compose, + }) + ).toBeUndefined(); +}); +test("closed literal label-list syntax and one relative service route preserve paired hosts", () => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + api: { + image: "api:1", + networks: ["default", "hack-dev"], + labels: [ + "caddy=api.original.hack.local,api.original.hack.gy", + "caddy.reverse_proxy={{upstreams http 8080}}", + "caddy.tls=internal", + "caddy_ingress_network=hack-dev", + ], + }, + }, + }; + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ?.routing?.routes[0] + ).toMatchObject({ service: "api", hostname: "api", port: 8080 }); +}); +test.each([ + { dev_host: "${PRIVATE}" }, + { dev_host: "UPPER.hack" }, + { dev_host: "https://original.hack.local" }, + { oauth: { enabled: true, tld: "../private" } }, + { oauth: { enabled: true, extra: CANARY } }, + { open: { prefer: "unknown" } }, + { open: { prefer: "dev", service: CANARY } }, + { domain: CANARY }, + { routes: { origin: CANARY } }, + { branch: CANARY }, +])("unsupported config cannot mint a routing candidate: %j", (change) => { + const { config, compose } = fixture(); + const result = mapLegacyNativeRetainedRouting( + inputs({ ...config, ...change }, compose) + ); + expect(result.report.complete).toBe(false); + expect(result.candidate).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain(CANARY); +}); +test.each([ + { "caddy.tls": "external" }, + { caddy: "original.hack.local" }, + { caddy: "*.original.hack.local,original.hack.gy" }, + { caddy: "original.hack.local,original.hack.gy,extra.example.test" }, + { "caddy.reverse_proxy": "{{upstreams 0}}" }, + { "caddy.reverse_proxy": "{{upstreams 65536}}" }, + { "caddy.reverse_proxy": "http://foreign:3000" }, + { caddy_ingress_network: "foreign" }, + { caddy_1: CANARY }, + { private: CANARY }, +])("unknown label or changed host/upstream refuses the full source: %j", (change) => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + web: { + ...compose.services.web, + labels: { ...compose.services.web.labels, ...change }, + }, + }, + }; + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ).toBeUndefined(); +}); +test.each([ + { networks: ["hack-dev"] }, + { networks: ["hack-dev", "default", "foreign"] }, + { networks: { "hack-dev": {}, default: {} } }, + { ports: ["3000:3000"] }, + { build: "." }, + { profiles: ["inactive"] }, + { healthcheck: { test: ["true"] } }, + { configs: [] }, + { secrets: [] }, + { volumes: ["./source:/app:ro"] }, +])("unqualified capability intersections stay refused: %j", (change) => { + const { config, compose } = fixture(); + const selected = { + ...compose, + services: { + ...compose.services, + web: { ...compose.services.web, ...change }, + }, + }; + const result = planLegacyComposeRetainedRoutingAdoption( + inputs(config, selected) + ); + expect(result.report.supported).toBe(false); + expect(result.intent).toBeUndefined(); +}); +test("extra inactive fields, duplicate host ownership and external network options never disappear from refusal", () => { + const { config, compose } = fixture(); + for (const selected of [ + { + ...compose, + networks: { "hack-dev": { external: true, name: "foreign" } }, + }, + { + ...compose, + services: { ...compose.services, other: { ...compose.services.web } }, + }, + { ...compose, "x-private": CANARY }, + { + ...compose, + services: { + ...compose.services, + db: { ...compose.services.db, network_mode: "host" }, + }, + }, + ]) { + expect( + planLegacyComposeRetainedRoutingAdoption(inputs(config, selected)).intent + ).toBeUndefined(); + } +}); +test("the compiler apex sentinel cannot stand in for a literal project prefix", () => { + const { config, compose } = fixture(); + const prefixed = { + ...compose.services.web, + labels: { + ...compose.services.web.labels, + caddy: "project.original.hack.local,project.original.hack.gy", + }, + }; + for (const services of [ + { ...compose.services, web: prefixed }, + { ...compose.services, prefixed }, + ]) { + const source = inputs(config, { ...compose, services }); + expect(mapLegacyNativeRetainedRouting(source).report.complete).toBe(false); + expect( + planLegacyComposeRetainedRoutingAdoption(source).intent + ).toBeUndefined(); + } +}); +test("an explicit routing mapper never upgrades a non-routing source", () => { + const source = inputs( + { name: "fixture" }, + { + name: "fixture", + services: { db: { image: "db:1", volumes: ["data:/data"] } }, + volumes: { data: {} }, + } + ); + expect(mapLegacyNativeStorageAdoption(source).report.complete).toBe(true); + expect(mapLegacyNativeRetainedRouting(source).report.complete).toBe(false); + expect( + planLegacyComposeRetainedRoutingAdoption(source).intent + ).toBeUndefined(); +}); +test("private non-enumerable preparation bytes reach the exact routing mapper", () => { + const { config, compose } = fixture(); + const source = inputs(config, compose); + const privateSource = Object.defineProperties( + {}, + { + configText: { value: source.configText }, + composeText: { value: source.composeText }, + } + ) as typeof source; + expect(Object.keys(privateSource)).toEqual([]); + expect(mapLegacyNativeRetainedRouting(privateSource).candidate).toEqual( + mapLegacyNativeRetainedRouting(source).candidate + ); + expect( + planLegacyComposeRetainedRoutingAdoption(privateSource).intent + ).toEqual(planLegacyComposeRetainedRoutingAdoption(source).intent); +}); From e326ea75f5dbffedddb596d0a9ec2073e1461abd Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 01:27:27 -0400 Subject: [PATCH 2/9] feat: retain original Compose routing during native adoption --- docs/reference/native-compose-adoption.md | 24 +- src/commands/config-adopt.ts | 9 + src/lib/native-compose-adoption-binding.ts | 102 +- src/lib/native-compose-adoption-command.ts | 15 +- src/lib/native-compose-adoption-env-inputs.ts | 37 +- src/lib/native-compose-adoption-execution.ts | 6 +- src/lib/native-compose-adoption-generation.ts | 964 ++++++++++++++---- src/lib/native-compose-adoption-local.ts | 49 +- src/lib/native-compose-adoption-projection.ts | 123 ++- src/lib/native-compose-adoption-receipt.ts | 107 +- ...tive-compose-adoption-routing-execution.ts | 146 +++ ...ive-compose-adoption-routing-resolution.ts | 51 + src/lib/native-compose-adoption-routing.ts | 490 +++++++++ src/lib/native-compose-adoption-runtime.ts | 3 +- src/lib/native-compose-open.ts | 65 +- src/lib/native-compose-proxy-routes.ts | 92 ++ src/lib/native-compose-route-claims.ts | 66 ++ tests/helpers/retained-routing-adoption.ts | 615 +++++++++++ ...compose-adoption-routing-execution.test.ts | 242 +++++ ...ompose-adoption-routing-generation.test.ts | 488 +++++++++ ...e-compose-adoption-routing-receipt.test.ts | 159 +++ ...ompose-adoption-routing-resolution.test.ts | 112 ++ tests/native-compose-route-claims.test.ts | 105 ++ 23 files changed, 3801 insertions(+), 269 deletions(-) create mode 100644 src/lib/native-compose-adoption-routing-execution.ts create mode 100644 src/lib/native-compose-adoption-routing-resolution.ts create mode 100644 src/lib/native-compose-adoption-routing.ts create mode 100644 tests/helpers/retained-routing-adoption.ts create mode 100644 tests/native-compose-adoption-routing-execution.test.ts create mode 100644 tests/native-compose-adoption-routing-generation.test.ts create mode 100644 tests/native-compose-adoption-routing-receipt.test.ts create mode 100644 tests/native-compose-adoption-routing-resolution.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 247bcc58d..8b03b2ce7 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -507,11 +507,19 @@ new host from the project name or a global domain. Routed services must configur exactly the existing `hack-dev` attachment and the project default bridge; other services retain only the default bridge and existing local named storage. -This mapper is not enabled by the ordinary import preview or adoption command. -It supplies private intent and value-free field provenance only. Resource and -ingress incarnation, hostname reservations, active proxy dispatch, typed local -precedence, generated-source fidelity and stopped rollback must be admitted by -the distinct retained routing owner before activation. Builds, jobs, readiness, -source binds, files, branch overrides and custom bridges remain outside this -initial routing family. Neither the map nor a synthetic control proves browser -TLS, OAuth login or application acceptance; global DNS and trust are unchanged. +Ordinary import preview remains outside this private family. The retained routing +owner binds the original resources and ingress incarnations, reserves the exact +hostnames, and verifies current proxy dispatch before clearing a startup receipt. +Saved reads preserve literal origins and typed local precedence without acquiring +managed values. Every original-ID lifecycle child has a durable prospective +record; only its one-use known-return and process-group-absence proof settles that +record. Explicit recovery can contain an uncertain child but cannot clear its +uncertainty merely because containers are stopped. + +Rollback requires restored source bytes, stopped original resources and absent +proxy dispatch before handing hostname claims back to the restored legacy source. +It retains a durable handoff state across interrupted claim removal. Builds, jobs, +readiness, source binds, files, branch overrides and custom bridges remain outside +this initial routing family. The owner and private-store model do not prove live +TLS, SQL fidelity, OAuth login or application acceptance. The maintained isolated +ingress lifecycle fixture remains required; global DNS and trust are unchanged. diff --git a/src/commands/config-adopt.ts b/src/commands/config-adopt.ts index 6fe298378..3f9c3da3e 100644 --- a/src/commands/config-adopt.ts +++ b/src/commands/config-adopt.ts @@ -16,6 +16,7 @@ import { } from "../lib/native-compose-adoption-generation.ts"; import { previewLegacyComposeAdoption } from "../lib/native-compose-adoption-preview.ts"; import { legacyComposeRetainedOrdered } from "../lib/native-compose-adoption-readiness.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../lib/native-compose-adoption-routing-execution.ts"; import { requireNativeComposeBackend } from "../lib/native-compose-selection.ts"; import { run } from "../lib/shell.ts"; @@ -111,6 +112,14 @@ async function adoptPrepared( if (opts.signal.aborted) { throw new Error("Legacy adoption cancelled; values omitted."); } + if (input.retainedRouting) { + return await runLegacyComposeRetainedRoutingOperation({ + input, + operation: "stop", + deadline, + signal: opts.signal, + }); + } if ( legacyComposeRetainedOrdered(input.retainedPlan) || input.retainedBuild diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index 48d355bb2..eed1aa338 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; import { isRecord } from "./guards.ts"; import { acquireLegacyComposeBuildSource, @@ -17,12 +18,21 @@ import { type LegacyComposeStorageIntent, planLegacyComposeAdoption, planLegacyComposeRetainedBasicBuildAdoption, + planLegacyComposeRetainedRoutingAdoption, } from "./native-compose-adoption-plan.ts"; import { hasLegacyComposeGeneratedSources, LegacyComposeAdoptionProjection, } from "./native-compose-adoption-projection.ts"; import { legacyComposeRetainedPlan } from "./native-compose-adoption-readiness.ts"; +import { + inspectLegacyComposeRetainedRouting, + type LegacyComposeRetainedRoutingProof, +} from "./native-compose-adoption-routing.ts"; +import { + type NativeComposeIngressBinding, + observeNativeComposeIngress, +} from "./native-compose-ingress.ts"; import { createNativeComposeProbe, NativeComposeOwnershipError, @@ -33,6 +43,7 @@ import { } from "./native-config-import-inputs.ts"; import { freezeImportValue, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; @@ -313,6 +324,12 @@ export type LegacyComposeVerifiedBinding = LegacyComposeVerifiedBindingBase & readonly composeFiles: readonly string[]; readonly networks: readonly LegacyComposeVerifiedNetwork[]; } + | { + readonly binding_version: 14; + readonly composeFiles: readonly string[]; + readonly network: LegacyComposeOriginalNetwork; + readonly routing: LegacyComposeRetainedRoutingProof; + } ); type ProjectedPreparation = Pick< @@ -430,6 +447,7 @@ function containerRows( readonly network?: { readonly name: string; readonly id: string }; readonly networks?: readonly LegacyComposeVerifiedNetwork[]; readonly composeFiles: readonly string[]; + readonly ingress?: NativeComposeIngressBinding; } ): LegacyComposeVerifiedContainer[] { requireValue(rows.length === opts.intent.services.length); @@ -521,8 +539,36 @@ function containerRows( } return { id: row.id, name: row.name.slice(1), service: row.service }; } - requireValue(Array.isArray(row.networks) && row.networks.length === 1); - const network = row.networks[0]; + requireValue(Array.isArray(row.networks)); + const routed = + opts.intent.routing?.routes.some( + (route) => route.service === row.service + ) === true; + if (opts.intent.routing) { + requireValue( + opts.ingress && + !opts.intent.ownedNetwork && + !opts.intent.ownedNetworks && + row.networks.length === (routed ? 2 : 1) + ); + const shared = row.networks.filter( + (item) => isRecord(item) && item.name === DEFAULT_INGRESS_NETWORK + ); + requireValue(routed ? shared.length === 1 : shared.length === 0); + if (routed) { + const item = shared[0]; + requireValue(isRecord(item)); + keys(item, ["id", "name"]); + requireValue(item.id === opts.ingress.networkId); + } + } else { + requireValue(row.networks.length === 1); + } + const local = row.networks.filter( + (item) => !isRecord(item) || item.name !== DEFAULT_INGRESS_NETWORK + ); + requireValue(local.length === 1); + const network = local[0]; requireValue(isRecord(network)); keys( network, @@ -748,12 +794,17 @@ export async function inspectLegacyComposeAdoptionResources(opts: { opts.intent.ownedNetwork !== undefined || opts.intent.ownedNetworks !== undefined, }); + const ingress = opts.intent.routing + ? await observeNativeComposeIngress({ signal: opts.signal }) + : undefined; + requireValue(!ingress || ingress.engineId === engineId); const containers = containerRows(containerFacts, { ...opts, composeFiles, volumes, network: single?.network, networks: plural?.networks, + ingress, }); // Docker drops stopped endpoints from network inspection. Each original must // still configure every declared NetworkID; live members are exact per bridge. @@ -820,6 +871,26 @@ export async function inspectLegacyComposeAdoptionResources(opts: { }; } requireValue(single); + if (opts.intent.routing) { + requireValue( + !(plural || opts.intent.ownedNetwork || opts.intent.ownedNetworks) && + ingress + ); + const routing = await inspectLegacyComposeRetainedRouting({ + binding: common, + routing: opts.intent.routing, + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }); + requireValue(JSON.stringify(routing.ingress) === JSON.stringify(ingress)); + return { + ...common, + binding_version: 14, + composeFiles: Object.freeze(composeFiles), + network: single.network, + routing, + }; + } return { ...(opts.composeFiles ? { @@ -839,7 +910,7 @@ export async function inspectLegacyComposeAdoptionResources(opts: { } export type LegacyComposeAdoptionBinding = { readonly report: { - readonly binding_version: 1 | 2 | 3 | 4 | 5 | 6; + readonly binding_version: 1 | 2 | 3 | 4 | 5 | 6 | 14; readonly status: "verified"; readonly adoption: "not_performed"; readonly containers: number; @@ -950,12 +1021,19 @@ async function acquireBinding( configText: source.configText, composeText: source.composeText, }); + const routed = + purpose === "preparation" && !ordinary.intent + ? planLegacyComposeRetainedRoutingAdoption(source) + : undefined; const basic = purpose === "basic-build" || - (purpose === "preparation" && !ordinary.intent); - const planned = basic - ? planLegacyComposeRetainedBasicBuildAdoption(source) - : ordinary; + (purpose === "preparation" && !ordinary.intent && !routed?.intent); + let planned = ordinary; + if (basic) { + planned = planLegacyComposeRetainedBasicBuildAdoption(source); + } else if (routed?.intent) { + planned = routed; + } const intent = planned.intent; if (!intent) { refuse("E_LEGACY_COMPOSE_BINDING_UNSUPPORTED"); @@ -963,7 +1041,11 @@ async function acquireBinding( const buildSource = basic ? await acquireLegacyComposeBuildSource({ source, signal }) : undefined; - const mapped = mapLegacyNativeStorageAdoption({ + const mapped = ( + intent.routing + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )({ configText: source.configText, composeText: source.composeText, }); @@ -984,7 +1066,8 @@ async function acquireBinding( ); if ( candidate && - (generatedPresent || + (intent.routing !== undefined || + generatedPresent || !(await legacyComposeAdoptionLayoutSupported({ projectRoot: root, candidate, @@ -998,6 +1081,7 @@ async function acquireBinding( source, signal, binary, + retainedRouting: intent.routing !== undefined, }); const resolved = await projection.resolve({ signal }); projected = Object.freeze({ diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index dfc31e5c0..2668ce8dd 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -11,6 +11,7 @@ import { } from "./native-compose-adoption-generation.ts"; import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts"; import { legacyComposeRetainedOrdered } from "./native-compose-adoption-readiness.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeContainerStates } from "./native-compose-adoption-runtime.ts"; import type { NativeComposeCommandOptions } from "./native-compose-command.ts"; import { requireNativeComposeBackend } from "./native-compose-selection.ts"; @@ -61,7 +62,9 @@ async function registeredAdoptedRoot(project: string) { } /** Check private adoption ownership before ordinary authored discovery can allocate a fresh native namespace. */ -async function adoptedRoot(options: NativeComposeCommandOptions) { +export async function selectLegacyComposeAdoptedRoot( + options: Pick +) { if (options.path && options.project !== undefined) { throw new CliUsageError("Use either --path or --project (not both)."); } @@ -253,7 +256,7 @@ export async function tryLegacyComposeAdoptedCommand( command: input.command ? [...input.command] : undefined, profiles: input.profiles ? [...input.profiles] : undefined, }; - const projectRoot = await adoptedRoot(options); + const projectRoot = await selectLegacyComposeAdoptedRoot(options); if (!projectRoot) { return null; } @@ -293,6 +296,14 @@ export async function tryLegacyComposeAdoptedCommand( deadline, run: async (privateInput) => { cancelled(signal); + if (privateInput.retainedRouting) { + return await runLegacyComposeRetainedRoutingOperation({ + input: privateInput, + operation, + deadline, + signal, + }); + } if ( legacyComposeRetainedOrdered(privateInput.retainedPlan) || privateInput.retainedBuild diff --git a/src/lib/native-compose-adoption-env-inputs.ts b/src/lib/native-compose-adoption-env-inputs.ts index 251bac097..c73a3a581 100644 --- a/src/lib/native-compose-adoption-env-inputs.ts +++ b/src/lib/native-compose-adoption-env-inputs.ts @@ -12,7 +12,11 @@ import { privateNativeConfigImportSourceProof, } from "./native-config-import-inputs.ts"; import { parseImportDocument } from "./native-config-import-parser.ts"; -import { mapLegacyNativeStorageAdoption } from "./native-config-import-plan.ts"; +import { + mapLegacyNativeRetainedRouting, + mapLegacyNativeStorageAdoption, +} from "./native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "./native-config-import-routing.ts"; import { acquireManagedProjectEnvFile } from "./native-project-inputs.ts"; import type { NativeProjectEnvSelectionOptions } from "./project-env-config.ts"; import { @@ -76,6 +80,7 @@ export class LegacyAdoptionManagedEnvAdmission { readonly source: NativeConfigImportInputs; readonly signal?: AbortSignal; readonly binary?: string; + readonly retainedRouting?: boolean; }): Promise { try { if ( @@ -86,11 +91,31 @@ export class LegacyAdoptionManagedEnvAdmission { const source = opts.source; const signal = opts.signal; const binary = opts.binary; + const retainedRouting = opts.retainedRouting === true; if (signal !== undefined && !(signal instanceof AbortSignal)) { refuse(); } await source.assertFresh({ signal }); - const mapped = mapLegacyNativeStorageAdoption(source); + const mapped = ( + retainedRouting + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )(source); + const routing = retainedRouting + ? mapLegacyComposeRouting({ + config: parseImportDocument({ + text: source.configText, + document: "config", + }).value, + compose: parseImportDocument({ + text: source.composeText, + document: "compose", + }).value, + })?.intent + : undefined; + if (retainedRouting && !routing) { + refuse(); + } const candidate = mapped.candidate; if (!(candidate && isRecord(candidate.services))) { refuse(); @@ -132,6 +157,7 @@ export class LegacyAdoptionManagedEnvAdmission { overlay, binary, signal, + routing, }); const context = { source, @@ -167,6 +193,13 @@ export class LegacyAdoptionManagedEnvAdmission { return this.#context.local.fields; } + get routingResolution() { + if (!ownedAdmissions.has(this)) { + refuse(); + } + return this.#context.local.routingResolution; + } + /** Private manifest provenance; captured sources are factory-issued and still fresh. No key or layer reread. */ async resolvePrivatePrimaryProof() { await this.assertRoot(this.selection); diff --git a/src/lib/native-compose-adoption-execution.ts b/src/lib/native-compose-adoption-execution.ts index 3cfa1c3a3..c5c47e651 100644 --- a/src/lib/native-compose-adoption-execution.ts +++ b/src/lib/native-compose-adoption-execution.ts @@ -17,6 +17,7 @@ import { legacyComposeRetainedReady, } from "./native-compose-adoption-readiness.ts"; import type { AdoptionOperation } from "./native-compose-adoption-receipt.ts"; +import type { LegacyComposeRoutingCompletion } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeJobStates, inspectLegacyComposeReadiness, @@ -38,7 +39,10 @@ function refuse( } const COMPLETION = Symbol("legacy-compose-job-completion"); export type LegacyComposeJobCompletion = { readonly [COMPLETION]: true }; -export type LegacyComposeRetainedOutcome = number | LegacyComposeJobCompletion; +export type LegacyComposeRetainedOutcome = + | number + | LegacyComposeJobCompletion + | LegacyComposeRoutingCompletion; type CompletionWitness = { readonly plan: LegacyComposeRetainedPlan; readonly binding: LegacyComposeVerifiedBinding; diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index b45fca7f5..828680bbe 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { Stats } from "node:fs"; import { link, lstat, mkdir, rename, unlink } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { resolveGlobalHackDir } from "./config-paths.ts"; import { isRecord } from "./guards.ts"; import { acquireLegacyComposeAdoptionPreparationBinding, @@ -31,6 +32,7 @@ import { import { planLegacyComposeAdoption, planLegacyComposeRetainedBasicBuildAdoption, + planLegacyComposeRetainedRoutingAdoption, } from "./native-compose-adoption-plan.ts"; import { readSavedLegacyComposeAdoptionProjection } from "./native-compose-adoption-projection.ts"; import { @@ -49,6 +51,8 @@ import { type Receipt, parseLegacyComposeAdoptionReceipt as receipt, } from "./native-compose-adoption-receipt.ts"; +import { assertLegacyComposeRetainedRoutingState } from "./native-compose-adoption-routing.ts"; +import { consumeLegacyComposeRoutingCompletion } from "./native-compose-adoption-routing-execution.ts"; import { inspectLegacyComposeContainerStates, inspectLegacyComposeJobStates, @@ -71,6 +75,13 @@ import { token, writeExclusive, } from "./native-compose-private-state.ts"; +import { + type NativeComposeRouteAttempt, + type NativeComposeRouteClaims, + type NativeComposeRouteReference, + openNativeComposeRouteClaims, + parseNativeComposeRouteReference, +} from "./native-compose-route-claims.ts"; import { NATIVE_CONFIG_INPUT_LIMIT } from "./native-config-compiler.ts"; import { type NativeConfigImportSourceIdentity, @@ -81,8 +92,14 @@ import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, mapLegacyNativeRetainedBasicBuild, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { + type LegacyComposeRoutingIntent, + mapLegacyComposeRouting, +} from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; import type { NativeProjectEnvMetadata } from "./project-env-config.ts"; const HASH = /^[a-f0-9]{64}$/; @@ -100,13 +117,25 @@ const ROUTING = [ "HACK_EXECUTION_MODE", ] as const; type SavedManifest = { - readonly adoption_generation_version: 1 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11; + readonly adoption_generation_version: + | 1 + | 3 + | 4 + | 5 + | 6 + | 7 + | 9 + | 10 + | 11 + | 14; readonly kind: typeof KIND; readonly projectRoot: string; readonly id: string; readonly binding: unknown; readonly runtimeConfig: unknown; readonly projectionProof?: unknown; + readonly routingClaims?: NativeComposeRouteReference; + readonly routingRoot?: string; readonly buildProof?: { readonly source: unknown; readonly images: unknown }; readonly sourceFiles: { readonly config: NativeConfigImportSourceIdentity; @@ -129,10 +158,14 @@ type PrivateInputs = { readonly projectionMetadata?: NativeProjectEnvMetadata; /** Private version9 policy; never serialized into a compiler report or receipt label. */ readonly retainedBuild?: true; + readonly retainedRouting?: true; + readonly routingResolution?: NativeRoutingResolution; }; type MutationInputs = PrivateInputs & { /** Issued while the journal and mutation lock are held; valid only during this callback. */ readonly assertFresh: () => Promise; + /** Synchronous revocation fence for the final spawn boundary. */ + readonly assertActive: () => void; readonly retainedPlan: LegacyComposeRetainedPlan; }; @@ -241,6 +274,9 @@ function sourceFileIdentity( ); } function manifestFieldKeys(value: Record) { + if (value.adoption_generation_version === 14) { + return "adoption_generation_version,binding,files,id,kind,projectRoot,projectionProof,routingClaims,routingRoot,runtimeConfig,sourceFiles"; + } if (value.adoption_generation_version === 9) { return "adoption_generation_version,binding,buildProof,files,id,kind,projectRoot,runtimeConfig,sourceFiles"; } @@ -259,6 +295,12 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { isRecord(value) && keys(value, manifestFieldKeys(value)) && (value.adoption_generation_version === 1 || + (value.adoption_generation_version === 14 && + isRecord(value.projectionProof) && + value.projectionProof.projection_version === 3 && + isRecord(value.routingClaims) && + typeof value.routingRoot === "string" && + resolve(value.routingRoot) === value.routingRoot) || value.adoption_generation_version === 7 || value.adoption_generation_version === 6 || (value.adoption_generation_version === 9 && @@ -301,6 +343,12 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { id, binding: value.binding, runtimeConfig: value.runtimeConfig, + ...(value.adoption_generation_version === 14 + ? { + routingClaims: parseNativeComposeRouteReference(value.routingClaims), + routingRoot: String(value.routingRoot), + } + : {}), ...(value.adoption_generation_version === 9 && isRecord(value.buildProof) ? { buildProof: { @@ -312,7 +360,8 @@ function manifest(value: unknown, root: string, id: string): SavedManifest { ...((value.adoption_generation_version === 5 && Object.hasOwn(value, "projectionProof")) || value.adoption_generation_version === 3 || - value.adoption_generation_version === 4 + value.adoption_generation_version === 4 || + value.adoption_generation_version === 14 ? { projectionProof: value.projectionProof } : {}), sourceFiles: { @@ -429,6 +478,7 @@ export type LegacyComposeAdoptedGenerationStore = { type Context = { readonly root: string; + readonly routingRoot: string; readonly checkout: Checkout; readonly directories: HeldDirectory[]; readonly stateRoot: string; @@ -522,7 +572,15 @@ async function requireSelectedTopologyOwner(opts: { requiresV5)) || (!plural && (meta.binding.binding_version === 5 || - meta.binding.binding_version === 6)) + meta.binding.binding_version === 6)) || + (meta.adoption_generation_version === 14) !== + (meta.binding.binding_version === 14) || + (meta.adoption_generation_version === 14 && + (custom || + plural || + requiresV5 || + !meta.routingClaims || + meta.routingClaims.generationIdentity !== selected.id)) ) { refuse(); } @@ -537,11 +595,114 @@ async function requireSelectedTopologyOwner(opts: { (state.adoption_receipt_version === 10) !== bridgeAndHealth || (state.adoption_receipt_version === 9) !== (meta.adoption_generation_version === 9) || + (state.adoption_receipt_version === 14) !== + (meta.adoption_generation_version === 14) || JSON.stringify(state.prepared) !== JSON.stringify(selected) ) { refuse(); } } +function retainedRoutingIntent(input: { + readonly configText: string; + readonly composeText: string; +}): LegacyComposeRoutingIntent { + const mapped = mapLegacyComposeRouting({ + config: parseImportDocument({ text: input.configText, document: "config" }) + .value, + compose: parseImportDocument({ + text: input.composeText, + document: "compose", + }).value, + }); + return mapped?.intent ?? refuse(); +} +function openRetainedRoutingClaims( + ctx: Context, + generation: string, + binding: LegacyComposeVerifiedBinding +): Promise { + if (binding.binding_version !== 14) { + refuse(); + } + return openNativeComposeRouteClaims({ + root: ctx.routingRoot, + binding: { + engineId: binding.engineId, + proxyId: binding.routing.ingress.proxyId, + networkId: binding.routing.ingress.networkId, + }, + owner: { composeProject: binding.composeProject, ownerToken: generation }, + }); +} +function requireRetainedClaimContext(opts: { + readonly inputs: PrivateInputs; + readonly generation: string; + readonly claim: Parameters< + Parameters[0]["assertAbsent"] + >[0]; + readonly handoff?: true; +}): void { + const binding = opts.inputs.binding; + const names = retainedRoutingIntent(opts.inputs) + .routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + .sort(); + if ( + binding.binding_version !== 14 || + (opts.handoff + ? opts.claim.hostnames.some((hostname) => !names.includes(hostname)) + : JSON.stringify([...opts.claim.hostnames].sort()) !== + JSON.stringify(names)) || + opts.claim.binding.engineId !== binding.engineId || + opts.claim.binding.proxyId !== binding.routing.ingress.proxyId || + opts.claim.binding.networkId !== binding.routing.ingress.networkId || + opts.claim.owner.composeProject !== binding.composeProject || + opts.claim.owner.ownerToken !== opts.generation + ) { + refuse(); + } +} +async function assertRetainedRouteState( + ctx: Context, + loaded: { readonly inputs: PrivateInputs }, + phase: "active" | "stopped", + deadline: number, + assertOwner: () => Promise +): Promise { + const binding = loaded.inputs.binding; + if (binding.binding_version !== 14) { + refuse(); + } + await assertLegacyComposeRetainedRoutingState({ + binding, + routing: retainedRoutingIntent(loaded.inputs), + proof: binding.routing, + phase, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + deadline, + assertOwner, + }); +} +function selectedMapper(routing: boolean, basic: boolean) { + if (routing) { + return mapLegacyNativeRetainedRouting; + } + if (basic) { + return mapLegacyNativeRetainedBasicBuild; + } + return mapLegacyNativeStorageAdoption; +} +function selectedPlanner(routing: boolean, basic: boolean) { + if (routing) { + return planLegacyComposeRetainedRoutingAdoption; + } + if (basic) { + return planLegacyComposeRetainedBasicBuildAdoption; + } + return planLegacyComposeAdoption; +} async function readInputs( ctx: Context, selected: Anchor, @@ -575,13 +736,14 @@ async function readInputs( meta.files.candidate ); const basic = meta.adoption_generation_version === 9; - const mapped = ( - basic ? mapLegacyNativeRetainedBasicBuild : mapLegacyNativeStorageAdoption - )({ configText, composeText }); - const planned = ( + const routing = meta.adoption_generation_version === 14; + if (routing && meta.routingRoot !== ctx.routingRoot) { + refuse(); + } + const mapped = selectedMapper(routing, basic)({ configText, composeText }); + const planned = selectedPlanner( + routing, basic - ? planLegacyComposeRetainedBasicBuildAdoption - : planLegacyComposeAdoption )({ configText, composeText }); const assertBuildSource = () => assertRetainedBuildSource({ ctx, meta, configText, composeText }); @@ -607,6 +769,7 @@ async function readInputs( const projection = (meta.adoption_generation_version === 5 && meta.projectionProof !== undefined) || + meta.adoption_generation_version === 14 || meta.adoption_generation_version === 3 || meta.adoption_generation_version === 4 ? await readSavedLegacyComposeAdoptionProjection(projectionOpts) @@ -647,6 +810,38 @@ async function readInputs( if (JSON.stringify(meta.binding) !== JSON.stringify(observed)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } + if (routing) { + if (!meta.routingClaims || observed.binding_version !== 14) { + refuse(); + } + const claims = await openRetainedRoutingClaims( + ctx, + selected.id, + observed + ); + try { + const attempt = await claims.reopen(meta.routingClaims); + if ( + attempt.reference.generationIdentity !== selected.id || + JSON.stringify(attempt.hostnames) !== + JSON.stringify( + retainedRoutingIntent({ configText, composeText }) + .routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + .sort() + ) + ) { + refuse(); + } + const current = preparing ? undefined : await publicationState(ctx); + if (current?.routingHandoff !== "releasing") { + await claims.assertHeld(meta.routingClaims); + } + } finally { + await claims.close(); + } + } const runtimeConfig = await inspectLegacyComposeRuntimeConfig({ binding: observed, composeFile: join(generationRoot, "legacy-compose.yml"), @@ -702,6 +897,10 @@ async function readInputs( binding: observed, ...(projection ? { projectionMetadata: projection.metadata } : {}), ...(basic ? { retainedBuild: true as const } : {}), + ...(routing ? { retainedRouting: true as const } : {}), + ...(projection?.routingResolution + ? { routingResolution: projection.routingResolution } + : {}), }), }; } finally { @@ -735,6 +934,11 @@ async function save( if (opts) { await opts.beforeCommit(); } + if (value.adoption_receipt_version === 14) { + // New routing proofs may await ingress and source owners. Preserve the + // exact receipt incarnation after that last awaited admission boundary. + await requireReceiptSnapshot(ctx, expected); + } await rename(temporary, ctx.receiptPath); await ctx.directories.at(-2)?.file.sync(); const published = await json(ctx.receiptPath); @@ -776,6 +980,12 @@ function manifestVersion( readonly projectionProof: { readonly projection_version: number }; } ): Manifest["adoption_generation_version"] { + if (binding.binding_version === 14) { + if (requiresV5 || projection?.projectionProof.projection_version !== 3) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + return 14; + } if (binding.binding_version === 5) { if (requiresV5 || projection) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); @@ -847,10 +1057,9 @@ async function prepare( ) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); } - const mapped = ( - acquired.build - ? mapLegacyNativeRetainedBasicBuild - : mapLegacyNativeStorageAdoption + const mapped = selectedMapper( + acquired.binding.binding_version === 14, + Boolean(acquired.build) )(acquired); if (!mapped.candidate) { refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); @@ -890,7 +1099,19 @@ async function prepare( const generationRoot = join(ctx.generationsRoot, id); await mkdir(generationRoot, { mode: 0o700 }); const held = await holdDirectory(generationRoot, true); + let routeClaims: NativeComposeRouteClaims | undefined; + let routeAttempt: NativeComposeRouteAttempt | undefined; + let prepared = false; try { + if (acquired.binding.binding_version === 14) { + routeClaims = await openRetainedRoutingClaims(ctx, id, acquired.binding); + routeAttempt = await routeClaims.acquire({ + generationIdentity: id, + hostnames: retainedRoutingIntent(acquired).routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ), + }); + } const files = { config: await writeArtifact( join(generationRoot, "legacy-config.json"), @@ -919,6 +1140,12 @@ async function prepare( projectRoot: ctx.root, id, binding: acquired.binding, + ...(routeAttempt + ? { + routingClaims: routeAttempt.reference, + routingRoot: ctx.routingRoot, + } + : {}), ...(acquired.build ? { buildProof: acquired.build } : {}), ...(acquired.projection ? { projectionProof: acquired.projection.projectionProof } @@ -945,9 +1172,25 @@ async function prepare( await recheckDirectories([held]); await binding.assertFresh({ projectRoot: ctx.root, signal: ctx.signal }); await ctx.check(); + if (routeAttempt) { + await routeClaims?.assertHeld(routeAttempt.reference); + await binding.assertFresh({ projectRoot: ctx.root, signal: ctx.signal }); + await ctx.check(); + } + prepared = true; return { id, manifest: { ...fileIdentity(written), hash: hash(text) } }; } finally { - await held.file.close(); + try { + try { + if (!prepared && routeAttempt) { + await routeClaims?.rollback(routeAttempt); + } + } finally { + await routeClaims?.close(); + } + } finally { + await held.file.close(); + } } } @@ -1385,10 +1628,19 @@ async function completePublication( if (!publication || publication.phase !== "switching") { refuse(); } + if ( + state.adoption_receipt_version === 14 && + state.routingHandoff !== "held" + ) { + refuse(); + } + const routingDeadline = + Date.now() + Math.min(ctx.timeoutMs ?? 15_000, 60_000); const loaded = await readInputs(ctx, publication.generation); await requireFirstSliceLayout(ctx, loaded.inputs); await admitCandidate(ctx, loaded.inputs, binary); await requireStopped(ctx, loaded.inputs.binding); + await assertPublicationRoutingStopped(ctx, loaded, state, routingDeadline); const held = await holdDirectory( join(ctx.generationsRoot, publication.generation.id, "originals"), true @@ -1445,7 +1697,18 @@ async function completePublication( ...current, publication: { ...installed, phase: "active" }, }, - current + current, + loaded.inputs.retainedRouting + ? { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline + ), + } + : undefined ); } finally { await held.file.close(); @@ -1456,6 +1719,8 @@ async function completeRollback(ctx: Context, state: Receipt) { if (!publication || publication.phase !== "rolling-back") { refuse(); } + const routingDeadline = + Date.now() + Math.min(ctx.timeoutMs ?? 15_000, 60_000); const loaded = await readInputs(ctx, publication.generation); await requireStopped(ctx, loaded.inputs.binding); const held = await holdDirectory( @@ -1508,19 +1773,116 @@ async function completeRollback(ctx: Context, state: Receipt) { await readInputs(transaction, publication.generation); await requireStopped(transaction, loaded.inputs.binding); await recheckDirectories([held]); + if (loaded.inputs.retainedRouting) { + await assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ); + if (current.routingHandoff !== "releasing") { + current = await save( + transaction, + { ...current, routingHandoff: "releasing" }, + current, + { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ), + } + ); + } + const routingGeneration = publication.generation.id; + const claims = await openRetainedRoutingClaims( + transaction, + routingGeneration, + loaded.inputs.binding + ); + try { + await claims.releaseRetained({ + assertStoppedAndRestored: async (claim) => { + requireRetainedClaimContext({ + inputs: loaded.inputs, + generation: routingGeneration, + claim, + handoff: true, + }); + await assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ); + }, + }); + } finally { + await claims.close(); + } + } await save( transaction, { ...current, publication: { ...publication, phase: "rolled-back" }, }, - current + current, + loaded.inputs.retainedRouting + ? { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + loaded, + current, + routingDeadline, + true + ), + } + : undefined ); } finally { await held.file.close(); } } +/** Retained originals remain present. This proves their stopped state and no + * proxy route; the native ABSENT-only release boundary remains unchanged. */ +async function assertPublicationRoutingStopped( + ctx: Context, + loaded: Awaited>, + state: Receipt, + deadline: number, + restored = false +): Promise { + if (!loaded.inputs.retainedRouting) { + return; + } + if ( + state.pendingOperation !== null || + state.routingOperation?.disposition === "prospective" + ) { + refuse("E_LEGACY_ADOPTION_BUSY"); + } + await assertRetainedRouteState(ctx, loaded, "stopped", deadline, async () => { + await readInputs(ctx, state.prepared ?? refuse()); + await requireStopped(ctx, loaded.inputs.binding); + if (restored) { + await requireRestoredRoutingSourceInputs(ctx, loaded); + } + await requireReceiptSnapshot(ctx, state); + cancelled(ctx.signal); + if (Date.now() >= deadline) { + refuse(); + } + }); +} + type MutationOptions = Parameters< LegacyComposeAdoptedGenerationStore["withMutation"] >[0]; @@ -1530,7 +1892,8 @@ function requireMutationDeadline( ) { if ( (legacyComposeRetainedOrdered(plan) || - captured.generation.report.adoption_generation_version === 9) && + captured.generation.report.adoption_generation_version === 9 || + captured.generation.report.adoption_generation_version === 14) && (captured.deadline === undefined || !Number.isFinite(captured.deadline) || captured.deadline <= Date.now()) @@ -1590,7 +1953,8 @@ function preparedReceiptVersion( prior.adoption_receipt_version === 9 || prior.adoption_receipt_version === 7 || prior.adoption_receipt_version === 10 || - prior.adoption_receipt_version === 11 + prior.adoption_receipt_version === 11 || + prior.adoption_receipt_version === 14 ) { return "kind" in checkout.git ? 2 : 1; } @@ -1642,6 +2006,32 @@ async function requirePreparedSourceInputs( } await ctx.check(); } +/** Authenticated restoration preserves the original inode and bytes, while its + * link/unlink transaction changes ctime. Initial preparation remains strict. */ +async function requireRestoredRoutingSourceInputs( + ctx: Context, + loaded: Awaited> +) { + if ( + loaded.manifest.adoption_generation_version !== 14 || + loaded.inputs.retainedRouting !== true + ) { + refuse(); + } + await requireFirstSliceLayout(ctx, loaded.inputs); + if (!(await absent(join(ctx.root, ".hack/hack.project.json")))) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + for (const location of originalLocations( + ctx, + { id: loaded.manifest.id }, + loaded.manifest, + loaded.inputs + )) { + await requireOriginal(ctx, location.active, location); + } + await ctx.check(); +} async function requireMutationInputs( ctx: Context, active: Publication | null, @@ -1664,7 +2054,7 @@ async function mutateRetainedContainers( refuse(); } if ( - ![5, 7, 9, 10].includes( + ![5, 7, 9, 10, 14].includes( captured.generation.report.adoption_generation_version ) ) { @@ -1740,221 +2130,362 @@ async function mutateRetainedContainersWithinBudget( } const loaded = await readInputs(ctx, owned); await requireMutationInputs(ctx, activePublication, loaded); - await admitCandidate(ctx, loaded.inputs, captured.binary); - const services = loaded.inputs.binding.containers.map( - (container) => container.service - ); - const selectedServices = captured.services.length - ? captured.services - : services; - const retainedPlan = legacyComposeRetainedPlan( - JSON.parse(loaded.inputs.candidateText) - ); - if ( - (legacyComposeRetainedOrdered(retainedPlan) || - loaded.inputs.retainedBuild) && - JSON.stringify([...selectedServices].sort()) !== - JSON.stringify([...services].sort()) - ) { - refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); - } - validateMutationSelection( - state, - { ...captured, services: selectedServices }, - services - ); - const observed = await inspectLegacyComposeContainerStates({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - if ( - observed.some( - (value) => - value.paused || !["created", "running", "exited"].includes(value.status) - ) - ) { - refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + const routing = loaded.inputs.retainedRouting === true; + if (routing && state.routingHandoff !== "held") { + refuse("E_LEGACY_ADOPTION_BUSY"); } - if (!captured.recover) { - requireMutationDeadline(captured, retainedPlan); - state = await save( - ctx, - { - ...state, - pendingOperation: { - generation: owned, - operation: captured.operation, - services: selectedServices, - }, - }, - state + const priorUnknown = + routing && + captured.recover === true && + state.routingOperation?.disposition === "prospective"; + const routeClaims = routing + ? await openRetainedRoutingClaims(ctx, owned.id, loaded.inputs.binding) + : undefined; + let routeAttempt: NativeComposeRouteAttempt | undefined; + try { + await admitCandidate(ctx, loaded.inputs, captured.binary); + const services = loaded.inputs.binding.containers.map( + (container) => container.service ); - } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - requireMutationDeadline(captured, retainedPlan); - let callbackOpen = true; - const privateInput = privateResult({ - configText: loaded.inputs.configText, - composeText: loaded.inputs.composeText, - candidateText: loaded.inputs.candidateText, - binding: loaded.inputs.binding, - ...(loaded.inputs.projectionMetadata - ? { projectionMetadata: loaded.inputs.projectionMetadata } - : {}), - retainedPlan, - ...(loaded.inputs.retainedBuild ? { retainedBuild: true as const } : {}), - assertFresh: async () => { + const selectedServices = captured.services.length + ? captured.services + : services; + const retainedPlan = legacyComposeRetainedPlan( + JSON.parse(loaded.inputs.candidateText) + ); + if ( + (legacyComposeRetainedOrdered(retainedPlan) || + loaded.inputs.retainedBuild || + routing) && + JSON.stringify([...selectedServices].sort()) !== + JSON.stringify([...services].sort()) + ) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + validateMutationSelection( + state, + { ...captured, services: selectedServices }, + services + ); + const observed = await inspectLegacyComposeContainerStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); + if ( + observed.some( + (value) => + value.paused || + !["created", "running", "exited"].includes(value.status) + ) + ) { + refuse("E_LEGACY_ADOPTION_UNSUPPORTED"); + } + if (!captured.recover) { + requireMutationDeadline(captured, retainedPlan); + routeAttempt = routeClaims + ? await routeClaims.acquire({ + generationIdentity: owned.id, + hostnames: retainedRoutingIntent(loaded.inputs).routes.flatMap( + (route) => route.origins.map((origin) => new URL(origin).hostname) + ), + }) + : undefined; + state = await save( + ctx, + { + ...state, + pendingOperation: { + generation: owned, + operation: captured.operation, + services: selectedServices, + }, + ...(routeAttempt + ? { + routingOperation: { + generation: owned, + token: token(), + reference: routeAttempt.reference, + disposition: "prospective" as const, + code: null, + }, + } + : {}), + }, + state + ); + } else if (routing) { + const previous = state.routingOperation; + if ( + !previous || + JSON.stringify(previous.generation) !== JSON.stringify(owned) || + !routeClaims + ) { + refuse(); + } + routeAttempt = await routeClaims.reopen(previous.reference); + await routeClaims.assertHeld(previous.reference); + if (!priorUnknown) { + state = await save( + ctx, + { + ...state, + routingOperation: { + ...previous, + disposition: "prospective", + code: null, + }, + }, + state + ); + } + } + if (routeClaims && routeAttempt && !captured.recover) { + await routeClaims.markEffectsPossible(routeAttempt); + } + await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + requireMutationDeadline(captured, retainedPlan); + let callbackOpen = true; + const assertActive = () => { if (!callbackOpen) { refuse(); } - const current = await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, current); - await requireReceiptSnapshot(ctx, state); + cancelled(ctx.signal); requireMutationDeadline(captured, retainedPlan); - }, - }); - let outcome: LegacyComposeRetainedOutcome; - try { - outcome = await captured.run(privateInput); - } finally { - callbackOpen = false; - } - await ctx.check(); - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - const completed = await inspectLegacyComposeContainerStates({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - const ids = new Set( - loaded.inputs.binding.containers - .filter((container) => selectedServices.includes(container.service)) - .map((container) => container.id) - ); - if (typeof outcome === "number" && outcome !== 0) { - return outcome; - } - const jobAttempts = - retainedPlan.requiresV7 && captured.operation !== "stop" - ? consumeLegacyComposeJobCompletion({ - outcome, - plan: retainedPlan, - binding: privateInput.binding, - operation: captured.operation, - deadline: captured.deadline ?? 0, - assertFresh: privateInput.assertFresh, - }) - : undefined; - if (!jobAttempts && outcome !== 0) { - refuse("E_LEGACY_ADOPTION_CHANGED"); - } - const jobIds = new Set( - retainedPlan.ordered - .filter((item) => item.kind === "job") - .map( - (item) => - loaded.inputs.binding.containers.find( - (container) => container.service === item.service - )?.id - ) - ); - requireRetainedCompletionState({ - completed, - ids, - jobIds, - operation: captured.operation, - }); - const confirmV7Commit = async () => { + }; + const privateInput = privateResult({ + configText: loaded.inputs.configText, + composeText: loaded.inputs.composeText, + candidateText: loaded.inputs.candidateText, + binding: loaded.inputs.binding, + ...(loaded.inputs.projectionMetadata + ? { projectionMetadata: loaded.inputs.projectionMetadata } + : {}), + retainedPlan, + ...(loaded.inputs.retainedBuild ? { retainedBuild: true as const } : {}), + ...(routing ? { retainedRouting: true as const } : {}), + assertActive, + assertFresh: async () => { + assertActive(); + const current = await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, current); + await requireReceiptSnapshot(ctx, state); + assertActive(); + }, + }); + let outcome: LegacyComposeRetainedOutcome; + try { + outcome = await captured.run(privateInput); + } finally { + callbackOpen = false; + } + if (routing) { + const code = consumeLegacyComposeRoutingCompletion({ + outcome, + input: privateInput, + operation: captured.operation, + deadline: captured.deadline ?? 0, + }); + if (priorUnknown) { + // A fresh stop cannot establish the disposition of an earlier unknown child. + // No claim, receipt or original resource may be retired from engine absence. + refuse("E_LEGACY_ADOPTION_BUSY"); + } + const pendingRoute = state.routingOperation; + if (!pendingRoute) { + refuse(); + } + state = await save( + ctx, + { + ...state, + routingOperation: { ...pendingRoute, disposition: "settled", code }, + }, + state + ); + outcome = code; + } + await ctx.check(); await readInputs(ctx, owned); await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - if (jobAttempts) { - const finalRows = legacyComposeJobStates({ - binding: loaded.inputs.binding, - observed: await inspectLegacyComposeJobStates({ + const completed = await inspectLegacyComposeContainerStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); + const ids = new Set( + loaded.inputs.binding.containers + .filter((container) => selectedServices.includes(container.service)) + .map((container) => container.id) + ); + if (typeof outcome === "number" && outcome !== 0) { + return outcome; + } + const jobAttempts = + retainedPlan.requiresV7 && captured.operation !== "stop" + ? consumeLegacyComposeJobCompletion({ + outcome, + plan: retainedPlan, + binding: privateInput.binding, + operation: captured.operation, + deadline: captured.deadline ?? 0, + assertFresh: privateInput.assertFresh, + }) + : undefined; + if (!jobAttempts && outcome !== 0) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + const jobIds = new Set( + retainedPlan.ordered + .filter((item) => item.kind === "job") + .map( + (item) => + loaded.inputs.binding.containers.find( + (container) => container.service === item.service + )?.id + ) + ); + requireRetainedCompletionState({ + completed, + ids, + jobIds, + operation: captured.operation, + }); + const confirmV7Commit = async () => { + await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + if (jobAttempts) { + const finalRows = legacyComposeJobStates({ binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }), + observed: await inspectLegacyComposeJobStates({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }), + }); + const serviceRequired = retainedPlan.ordered + .filter((item) => item.kind !== "job") + .map((item) => ({ + service: item.service, + condition: item.healthy ? ("ready" as const) : ("started" as const), + })); + if ( + jobAttempts.length !== jobIds.size || + jobAttempts.some((attempt) => { + const row = finalRows.find((item) => item.id === attempt.id); + return ( + !(jobIds.has(attempt.id) && row) || + legacyComposeFreshJobResult({ attempt, observed: row }) !== + "ready" + ); + }) || + !legacyComposeRetainedReady({ + plan: retainedPlan, + ids: new Map( + loaded.inputs.binding.containers.map((item) => [ + item.service, + item.id, + ]) + ), + observed: finalRows, + required: serviceRequired, + }) + ) { + refuse("E_LEGACY_ADOPTION_CHANGED"); + } + } else { + await requireStopped(ctx, loaded.inputs.binding); + } + await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, loaded); + await requireReceiptSnapshot(ctx, state); + await ctx.check(); + cancelled(ctx.signal); + requireMutationDeadline(captured, retainedPlan); + }; + if ( + !retainedPlan.requiresV7 && + retainedPlan.requiresV5 && + captured.operation !== "stop" + ) { + const readiness = await inspectLegacyComposeReadiness({ + binding: loaded.inputs.binding, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, }); - const serviceRequired = retainedPlan.ordered - .filter((item) => item.kind !== "job") - .map((item) => ({ - service: item.service, - condition: item.healthy ? ("ready" as const) : ("started" as const), - })); if ( - jobAttempts.length !== jobIds.size || - jobAttempts.some((attempt) => { - const row = finalRows.find((item) => item.id === attempt.id); - return ( - !(jobIds.has(attempt.id) && row) || - legacyComposeFreshJobResult({ attempt, observed: row }) !== "ready" - ); - }) || !legacyComposeRetainedReady({ plan: retainedPlan, ids: new Map( - loaded.inputs.binding.containers.map((item) => [ - item.service, - item.id, + loaded.inputs.binding.containers.map((container) => [ + container.service, + container.id, ]) ), - observed: finalRows, - required: serviceRequired, + observed: readiness, }) ) { refuse("E_LEGACY_ADOPTION_CHANGED"); } - } else { - await requireStopped(ctx, loaded.inputs.binding); + await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, loaded); } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); - await requireReceiptSnapshot(ctx, state); - await ctx.check(); - cancelled(ctx.signal); requireMutationDeadline(captured, retainedPlan); - }; - if ( - !retainedPlan.requiresV7 && - retainedPlan.requiresV5 && - captured.operation !== "stop" - ) { - const readiness = await inspectLegacyComposeReadiness({ - binding: loaded.inputs.binding, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); - if ( - !legacyComposeRetainedReady({ - plan: retainedPlan, - ids: new Map( - loaded.inputs.binding.containers.map((container) => [ - container.service, - container.id, - ]) - ), - observed: readiness, - }) - ) { - refuse("E_LEGACY_ADOPTION_CHANGED"); + const confirmRoutingCommit = async () => { + await assertRetainedRouteState( + ctx, + loaded, + captured.operation === "stop" ? "stopped" : "active", + captured.deadline ?? 0, + async () => { + const fresh = await readInputs(ctx, owned); + await requireMutationInputs(ctx, activePublication, fresh); + await requireReceiptSnapshot(ctx, state); + requireMutationDeadline(captured, retainedPlan); + } + ); + await requireReceiptSnapshot(ctx, state); + requireMutationDeadline(captured, retainedPlan); + }; + if (routeClaims && routeAttempt) { + if (captured.recover) { + await routeClaims.recoverRetainedStopped({ + references: [routeAttempt.reference], + assertStopped: async (claim) => { + requireRetainedClaimContext({ + inputs: loaded.inputs, + generation: owned.id, + claim, + }); + await confirmRoutingCommit(); + }, + }); + } else { + await routeClaims.complete({ + attempt: routeAttempt, + assertTransition: confirmRoutingCommit, + }); + } } - await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, loaded); + let beforeCommit: (() => Promise) | undefined; + if (routing) { + beforeCommit = confirmRoutingCommit; + } else if (retainedPlan.requiresV7) { + beforeCommit = confirmV7Commit; + } + await save( + ctx, + { ...state, pendingOperation: null }, + state, + beforeCommit ? { beforeCommit } : undefined + ); + return 0; + } finally { + await routeClaims?.close(); } - requireMutationDeadline(captured, retainedPlan); - await save( - ctx, - { ...state, pendingOperation: null }, - state, - retainedPlan.requiresV7 ? { beforeCommit: confirmV7Commit } : undefined - ); - return 0; } /** @@ -1989,7 +2520,8 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { mode = input.mode ?? "prepare"; signal = input.signal; const timeoutMs = input.timeoutMs, - capturedRoute = route(); + capturedRoute = route(), + routingRoot = join(resolveGlobalHackDir(), "compose-routing"); cancelled(signal); for (const path of [root, join(root, ".hack")]) { directories.push(await holdDirectory(path, false)); @@ -2049,6 +2581,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { }; const ctx: Context = { root, + routingRoot, checkout, directories, stateRoot, @@ -2127,6 +2660,9 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { prepared: generated, publication: null, pendingOperation: null, + ...(loaded.manifest.adoption_generation_version === 14 + ? { routingOperation: null, routingHandoff: "held" as const } + : {}), }, prior ); @@ -2204,6 +2740,12 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { refuse(); } const loaded = await readInputs(ctx, owned); + if ( + loaded.inputs.retainedRouting && + state.routingHandoff !== "held" + ) { + refuse("E_LEGACY_ADOPTION_BUSY"); + } await requireFirstSliceLayout(ctx, loaded.inputs); await admitCandidate(ctx, loaded.inputs, binary); await requireStopped(ctx, loaded.inputs.binding); diff --git a/src/lib/native-compose-adoption-local.ts b/src/lib/native-compose-adoption-local.ts index 977b81f37..fc9a88118 100644 --- a/src/lib/native-compose-adoption-local.ts +++ b/src/lib/native-compose-adoption-local.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { lstat } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "./guards.ts"; +import { legacyComposeRoutingResolutionMatches } from "./native-compose-adoption-routing-resolution.ts"; import { hasCode } from "./native-compose-private-state.ts"; import { NativeConfigCompilerError, @@ -17,6 +18,8 @@ import { parseImportDocument, } from "./native-config-import-parser.ts"; import { freezeImportValue } from "./native-config-import-plan.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; type Source = Extract; type Role = "primary_local" | "checkout_local"; @@ -66,6 +69,7 @@ function check(signal?: AbortSignal) { export function mapLegacyAdoptionLocalInput(opts: { readonly text: string; readonly document: Role; + readonly retainedRouting?: boolean; }) { const parsed = parseImportDocument(opts); const value = parsed.value; @@ -77,14 +81,29 @@ export function mapLegacyAdoptionLocalInput(opts: { value !== undefined && value.schema_version === 1 && Object.keys(value).every( - (key) => key === "schema_version" || key === "environment" + (key) => + key === "schema_version" || + key === "environment" || + (opts.retainedRouting === true && (key === "routes" || key === "open")) ) && (environment === undefined || (isRecord(environment) && Object.keys(environment).every((key) => key === "default_overlay") && (!Object.hasOwn(environment, "default_overlay") || environment.default_overlay === null || - typeof environment.default_overlay === "string"))); + typeof environment.default_overlay === "string"))) && + (!Object.hasOwn(value, "routes") || + (opts.retainedRouting === true && + isRecord(value.routes) && + Object.keys(value.routes).every((key) => key === "domain") && + (!Object.hasOwn(value.routes, "domain") || + typeof value.routes.domain === "string"))) && + (!Object.hasOwn(value, "open") || + (opts.retainedRouting === true && + isRecord(value.open) && + Object.keys(value.open).every((key) => key === "prefer") && + (!Object.hasOwn(value.open, "prefer") || + ["auto", "alias", "dev"].includes(String(value.open.prefer))))); const fields: readonly ImportField[] = parsed.fields.map((field) => ({ ...field, status: valid ? "exact" : "refused", @@ -116,7 +135,9 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { readonly overlay: string | null; readonly binary?: string; readonly signal?: AbortSignal; + readonly routing?: LegacyComposeRoutingIntent; }) { + const routing = opts.routing; check(opts.signal); const checkout = privateNativeConfigImportLocalInput(opts.source); const primary = opts.primary @@ -131,6 +152,7 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { const mapped = mapLegacyAdoptionLocalInput({ text: input.text, document, + retainedRouting: routing !== undefined, }); if (!mapped.complete) { refuseFields(mapped.fields); @@ -139,15 +161,25 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { } } const present = checkout?.text != null || primary?.text != null; - if (present) { + let routingResolution: NativeRoutingResolution | undefined; + if (present || routing) { const resolved = await resolveNativeConfig({ input: new TextEncoder().encode(JSON.stringify(opts.candidate)), primaryLocal: encodedLocal(primary?.text), checkoutLocal: encodedLocal(checkout?.text), binary: opts.binary, signal: opts.signal, + requireRoutingPlanning: routing !== undefined, }); - if (!resolved.ok || resolved.local_resolution.overlay !== opts.overlay) { + if ( + !resolved.ok || + resolved.local_resolution.overlay !== opts.overlay || + (routing && + !legacyComposeRoutingResolutionMatches({ + routing, + resolution: resolved.routing_resolution, + })) + ) { refuseFields( fields.map((field) => ({ ...field, @@ -156,12 +188,16 @@ export async function resolveLegacyAdoptionLocalInputs(opts: { })) ); } + if (resolved.ok) { + routingResolution = resolved.routing_resolution; + } } await opts.source.assertFresh({ signal: opts.signal }); await opts.primary?.assertFresh({ signal: opts.signal }); check(opts.signal); const result = { fields, + routingResolution, proof: present ? { checkout: checkout?.proof ?? null, primary: primary?.proof ?? null } : undefined, @@ -178,6 +214,7 @@ async function assertCheckoutLocal(opts: { readonly projectRoot: string; readonly proof: unknown; readonly signal?: AbortSignal; + readonly retainedRouting?: boolean; }) { const proof = opts.proof; const path = join(opts.projectRoot, ".hack/hack.local.json"); @@ -223,6 +260,7 @@ async function assertCheckoutLocal(opts: { !mapLegacyAdoptionLocalInput({ text: new TextDecoder("utf-8", { fatal: true }).decode(current.bytes), document: "checkout_local", + retainedRouting: opts.retainedRouting, }).complete ) { refuse(); @@ -237,6 +275,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { readonly proof: unknown; readonly signal?: AbortSignal; readonly checkOwner: () => Promise; + readonly retainedRouting?: boolean; }) { check(opts.signal); const proof = opts.proof; @@ -252,6 +291,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { projectRoot: opts.projectRoot, proof: proof.checkout, signal: opts.signal, + retainedRouting: opts.retainedRouting, }); const primary = opts.primary ? privateNativeConfigImportLocalInput(opts.primary) @@ -261,6 +301,7 @@ export async function assertSavedLegacyAdoptionLocalInputs(opts: { !mapLegacyAdoptionLocalInput({ text: primary.text, document: "primary_local", + retainedRouting: opts.retainedRouting, }).complete ) { refuse(); diff --git a/src/lib/native-compose-adoption-projection.ts b/src/lib/native-compose-adoption-projection.ts index 3c683ad68..94a60acd5 100644 --- a/src/lib/native-compose-adoption-projection.ts +++ b/src/lib/native-compose-adoption-projection.ts @@ -17,6 +17,7 @@ import { assertSavedLegacyAdoptionLocalInputs, retainLegacyAdoptionLocalRefusal, } from "./native-compose-adoption-local.ts"; +import { legacyComposeRoutingResolutionMatches } from "./native-compose-adoption-routing-resolution.ts"; import { hasCode, holdDirectory, @@ -37,9 +38,12 @@ import { import { parseImportDocument } from "./native-config-import-parser.ts"; import { freezeImportValue, + mapLegacyNativeRetainedRouting, mapLegacyNativeStorageAdoption, } from "./native-config-import-plan.ts"; +import { mapLegacyComposeRouting } from "./native-config-import-routing.ts"; import { acquireManagedProjectEnvFile } from "./native-project-inputs.ts"; +import { parseNativeRoutingResolution } from "./native-routing-plan-protocol.ts"; import { defaultProjectSlugFromPath } from "./project.ts"; import { acquireProjectEnvForLegacyAdoption, @@ -163,6 +167,23 @@ function primarySourceFactory(localInputs: unknown) { : acquireLegacyAdoptionSourceInputs; } +function projectionKeys(version: unknown): string { + if (version === 3) { + return "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version,routingResolution"; + } + if (version === 2) { + return "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version"; + } + return "generated,inheritPrimaryLocal,managedRevision,primary,projection_version"; +} +function projectionVersion( + admission: LegacyAdoptionManagedEnvAdmission +): number { + if (admission.routingResolution) { + return 3; + } + return admission.localFields.length ? 2 : 1; +} /** Validate and snapshot the bounded private envelope before any async recheck. */ function savedProjectionEnvelope(value: unknown) { const text = JSON.stringify(value); @@ -174,13 +195,11 @@ function savedProjectionEnvelope(value: unknown) { if ( !( isRecord(proof) && - keys( - proof, - proof.projection_version === 2 - ? "generated,inheritPrimaryLocal,localInputs,managedRevision,primary,projection_version" - : "generated,inheritPrimaryLocal,managedRevision,primary,projection_version" - ) && + keys(proof, projectionKeys(proof.projection_version)) && (proof.projection_version === 1 || + (proof.projection_version === 3 && + (proof.localInputs === null || isRecord(proof.localInputs)) && + parseNativeRoutingResolution(proof.routingResolution) !== null) || (proof.projection_version === 2 && isRecord(proof.localInputs))) && typeof proof.managedRevision === "string" && typeof proof.inheritPrimaryLocal === "boolean" && @@ -206,7 +225,15 @@ function savedProjectionEnvelope(value: unknown) { inheritPrimaryLocal: proof.inheritPrimaryLocal, generated: proof.generated, primary, - localInputs: proof.projection_version === 2 ? proof.localInputs : undefined, + localInputs: + proof.projection_version === 2 || + (proof.projection_version === 3 && proof.localInputs !== null) + ? proof.localInputs + : undefined, + routingResolution: + proof.projection_version === 3 + ? parseNativeRoutingResolution(proof.routingResolution) + : undefined, }; } @@ -240,7 +267,11 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { } const proof = savedProjectionEnvelope(opts.proof); check(signal); - const mapped = mapLegacyNativeStorageAdoption({ configText, composeText }); + const mapped = ( + proof.routingResolution + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )({ configText, composeText }); const candidate = mapped.candidate; if ( !( @@ -251,6 +282,28 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { ) { refuse(); } + const routing = proof.routingResolution + ? mapLegacyComposeRouting({ + config: parseImportDocument({ text: configText, document: "config" }) + .value, + compose: parseImportDocument({ + text: composeText, + document: "compose", + }).value, + })?.intent + : undefined; + if ( + proof.routingResolution && + !( + routing && + legacyComposeRoutingResolutionMatches({ + routing, + resolution: proof.routingResolution, + }) + ) + ) { + refuse(); + } const selection = { projectRoot, overlay: @@ -317,6 +370,7 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { proof: proof.localInputs, signal: current.signal ?? signal, checkOwner, + retainedRouting: routing !== undefined, }); } if ( @@ -365,8 +419,10 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { const runtimeText = buildRuntimeHostMetadataOverride({ composeYamls: [composeText], branch: null, - devHost: `${defaultProjectSlugFromPath(projectRoot)}.${DEFAULT_PROJECT_TLD}`, - aliasHost: null, + devHost: + routing?.devHost ?? + `${defaultProjectSlugFromPath(projectRoot)}.${DEFAULT_PROJECT_TLD}`, + aliasHost: routing?.aliasHost ?? null, composeProject: String(candidate.name), }); if (runtime && runtime.text !== runtimeText) { @@ -374,6 +430,9 @@ export async function readSavedLegacyComposeAdoptionProjection(opts: { } const result = { candidate: projectRuntimeFallbacks(candidate, runtime), + ...(proof.routingResolution + ? { routingResolution: proof.routingResolution } + : {}), metadata, composeFiles: [ join(projectRoot, ".hack/docker-compose.yml"), @@ -575,18 +634,25 @@ export class LegacyComposeAdoptionProjection { readonly source: NativeConfigImportInputs; readonly signal?: AbortSignal; readonly binary?: string; + readonly retainedRouting?: boolean; }): Promise { try { const { source, signal } = opts; + const retainedRouting = opts.retainedRouting === true; const admission = await LegacyAdoptionManagedEnvAdmission.acquire({ source, signal, binary: opts.binary, + retainedRouting, }); if (!source.ok) { refuse(); } - const mapped = mapLegacyNativeStorageAdoption(source); + const mapped = ( + retainedRouting + ? mapLegacyNativeRetainedRouting + : mapLegacyNativeStorageAdoption + )(source); const candidate = mapped.candidate; if (!(candidate && legacyComposeAdoptionCandidateSupported(candidate))) { refuse(); @@ -598,6 +664,18 @@ export class LegacyComposeAdoptionProjection { if (!(compose && isRecord(compose.services))) { refuse(); } + const routing = retainedRouting + ? mapLegacyComposeRouting({ + config: parseImportDocument({ + text: source.configText, + document: "config", + }).value, + compose, + })?.intent + : undefined; + if (retainedRouting && !(routing && admission.routingResolution)) { + refuse(); + } const env = await acquireProjectEnvForLegacyAdoption({ admission }); const generated = await acquireGenerated({ projectRoot: source.projectRoot, @@ -617,8 +695,10 @@ export class LegacyComposeAdoptionProjection { runtimeText: buildRuntimeHostMetadataOverride({ composeYamls: [source.composeText], branch: null, - devHost: `${defaultProjectSlugFromPath(source.projectRoot)}.${DEFAULT_PROJECT_TLD}`, - aliasHost: null, + devHost: + routing?.devHost ?? + `${defaultProjectSlugFromPath(source.projectRoot)}.${DEFAULT_PROJECT_TLD}`, + aliasHost: routing?.aliasHost ?? null, composeProject: String(candidate.name), }), signal, @@ -638,7 +718,7 @@ export class LegacyComposeAdoptionProjection { } const context = this.#context; const result = { - projection_version: context.admission.localFields.length ? 2 : 1, + projection_version: projectionVersion(context.admission), status: "acquired", admission: "not_performed", files: [ @@ -715,6 +795,9 @@ export class LegacyComposeAdoptionProjection { refuse(); } const candidate = projectRuntimeFallbacks(context.candidate, runtime); + const primary = privatePrimaryProof( + await context.admission.resolvePrivatePrimaryProof() + ); await this.assertFresh({ signal }); const result = { candidate, @@ -728,12 +811,16 @@ export class LegacyComposeAdoptionProjection { metadata: context.env.metadata, localFields: context.admission.localFields, projectionProof: { - projection_version: context.admission.localFields.length ? 2 : 1, + projection_version: projectionVersion(context.admission), managedRevision: privateLegacyAdoptionEnvRevision(context.env), - ...privatePrimaryProof( - await context.admission.resolvePrivatePrimaryProof() - ), + ...primary, generated: generatedProof(context.generated), + ...(context.admission.routingResolution + ? { + routingResolution: context.admission.routingResolution, + localInputs: primary.localInputs ?? null, + } + : {}), }, }; for (const [key, value] of Object.entries(result)) { diff --git a/src/lib/native-compose-adoption-receipt.ts b/src/lib/native-compose-adoption-receipt.ts index 8a8b75e1b..c69df8f12 100644 --- a/src/lib/native-compose-adoption-receipt.ts +++ b/src/lib/native-compose-adoption-receipt.ts @@ -1,6 +1,10 @@ import { isRecord } from "./guards.ts"; import type { LinkedAdoptionGitIdentity } from "./native-compose-adoption-checkout.ts"; import { keys } from "./native-compose-private-state.ts"; +import { + type NativeComposeRouteReference, + parseNativeComposeRouteReference, +} from "./native-compose-route-claims.ts"; const KIND = "legacy-compose-adopted"; const TOKEN = /^[a-f0-9]{32}$/; @@ -17,12 +21,35 @@ export type Checkout = { }; export type Anchor = { readonly id: string; readonly manifest: Artifact }; export type Receipt = { - readonly adoption_receipt_version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11; + readonly adoption_receipt_version: + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 9 + | 10 + | 11 + | 14; readonly kind: typeof KIND; readonly checkout: Checkout; readonly prepared: Anchor | null; readonly publication: Publication | null; readonly pendingOperation: PendingOperation | null; + /** Required only by v14. Unknown child disposition survives stop containment. */ + readonly routingOperation?: RetainedRoutingOperation | null; + /** Required only by v14; releasing is an interrupted rollback handoff, + * never active workload or publisher admission. */ + readonly routingHandoff?: "held" | "releasing"; +}; +export type RetainedRoutingOperation = { + readonly generation: Anchor; + readonly token: string; + readonly reference: NativeComposeRouteReference; + readonly disposition: "prospective" | "settled"; + readonly code: number | null; }; export type AdoptionOperation = "start" | "restart" | "stop"; export type PendingOperation = { @@ -75,9 +102,12 @@ export function parseLegacyComposeAdoptionReceipt( isRecord(value) && keys( value, - "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication" + value.adoption_receipt_version === 14 + ? "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication,routingHandoff,routingOperation" + : "adoption_receipt_version,checkout,kind,pendingOperation,prepared,publication" ) && - (value.adoption_receipt_version === 11 || + (value.adoption_receipt_version === 14 || + value.adoption_receipt_version === 11 || value.adoption_receipt_version === 10 || value.adoption_receipt_version === 9 || value.adoption_receipt_version === 7 || @@ -96,6 +126,67 @@ export function parseLegacyComposeAdoptionReceipt( ) { refuse(); } + let routingOperation: RetainedRoutingOperation | null | undefined; + if (value.adoption_receipt_version === 14) { + if ( + value.prepared === null || + (value.routingHandoff !== "held" && value.routingHandoff !== "releasing") + ) { + refuse(); + } + if ( + value.routingHandoff === "releasing" && + (value.pendingOperation !== null || + !value.publication || + !["rolling-back", "rolled-back"].includes(value.publication.phase)) + ) { + refuse(); + } + const operation = value.routingOperation; + if (operation === null) { + routingOperation = null; + } else { + if ( + !( + isRecord(operation) && + keys(operation, "code,disposition,generation,reference,token") && + anchor(operation.generation) && + typeof operation.token === "string" && + TOKEN.test(operation.token) && + (operation.disposition === "prospective" + ? operation.code === null + : operation.disposition === "settled" && + Number.isSafeInteger(operation.code) && + typeof operation.code === "number" && + operation.code >= 0 && + operation.code <= 255) && + JSON.stringify(operation.generation) === + JSON.stringify(value.prepared) + ) + ) { + refuse(); + } + routingOperation = { + generation: operation.generation, + token: operation.token, + reference: parseNativeComposeRouteReference(operation.reference), + disposition: + operation.disposition === "prospective" ? "prospective" : "settled", + code: typeof operation.code === "number" ? operation.code : null, + }; + if ( + routingOperation.reference.generationIdentity !== + operation.generation.id || + (routingOperation.disposition === "prospective" && + value.pendingOperation === null) + ) { + refuse(); + } + } + if (value.pendingOperation !== null && routingOperation === null) { + refuse(); + } + } // The distinct job family is issued with a prepared generation, never a bare version upgrade. if (value.adoption_receipt_version === 7 && value.prepared === null) { refuse(); @@ -121,6 +212,7 @@ export function parseLegacyComposeAdoptionReceipt( const version = value.adoption_receipt_version; return { adoption_receipt_version: + version === 14 || version === 11 || version === 10 || version === 9 || @@ -136,6 +228,15 @@ export function parseLegacyComposeAdoptionReceipt( prepared: value.prepared, publication: value.publication, pendingOperation: value.pendingOperation, + ...(version === 14 + ? { + routingOperation: routingOperation ?? null, + routingHandoff: + value.routingHandoff === "held" + ? ("held" as const) + : ("releasing" as const), + } + : {}), }; } function pendingSelectionMatches( diff --git a/src/lib/native-compose-adoption-routing-execution.ts b/src/lib/native-compose-adoption-routing-execution.ts new file mode 100644 index 000000000..8c9c181ca --- /dev/null +++ b/src/lib/native-compose-adoption-routing-execution.ts @@ -0,0 +1,146 @@ +import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; +import type { AdoptionOperation } from "./native-compose-adoption-receipt.ts"; +import { run } from "./shell.ts"; + +const COMPLETE = Symbol("legacy-retained-routing-effect"); +export type LegacyComposeRoutingCompletion = { readonly [COMPLETE]: true }; +type Input = { + readonly binding: LegacyComposeVerifiedBinding; + readonly assertFresh: () => Promise; + readonly assertActive: () => void; +}; +type Witness = { + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; + readonly code: number; +}; +const completions = new WeakMap(); +function refuse(): never { + throw new Error( + "Retained routing child disposition is uncertain; values omitted." + ); +} +function check(signal: AbortSignal | undefined, deadline: number): void { + if (signal?.aborted || !Number.isFinite(deadline) || deadline <= Date.now()) { + refuse(); + } +} +function absent(pid: number): boolean { + try { + process.kill(-pid, 0); + return false; + } catch (error: unknown) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ) { + return true; + } + return refuse(); + } +} + +/** One fixed original-ID child. Completion means known return and fresh group + * absence; it does not infer container readiness or terminate an unknown peer. */ +export async function runLegacyComposeRetainedRoutingOperation(opts: { + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; + readonly signal?: AbortSignal; +}): Promise { + const input = opts.input; + const binding = input.binding; + const projectRoot = binding.projectRoot; + const assertFresh = input.assertFresh; + const assertActive = input.assertActive; + const operation = opts.operation; + const deadline = opts.deadline; + const signal = opts.signal; + const ids = binding.containers.map((row) => row.id); + if ( + binding.binding_version !== 14 || + typeof assertActive !== "function" || + !ids.length || + new Set(ids).size !== ids.length || + !["start", "restart", "stop"].includes(operation) + ) { + refuse(); + } + check(signal, deadline); + assertActive(); + await assertFresh(); + assertActive(); + check(signal, deadline); + let group: number | undefined; + let observationFailed = false; + const code = await run(["docker", "container", operation, ...ids], { + cwd: projectRoot, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + // This finite data-free child never needs a controlling-terminal handoff. + signal, + timeoutMs: Math.max(1, deadline - Date.now()), + beforeSpawn: () => { + assertActive(); + check(signal, deadline); + }, + onSpawn: (event) => { + if ( + !(event.ownsProcessGroup && Number.isSafeInteger(event.pid)) || + event.pid <= 1 + ) { + observationFailed = true; + } else { + group = event.pid; + } + return Promise.resolve(); + }, + }); + if ( + observationFailed || + group === undefined || + !Number.isSafeInteger(code) || + code < 0 || + code > 255 + ) { + refuse(); + } + const drainDeadline = Math.min(deadline, Date.now() + 3000); + while (!absent(group)) { + if (Date.now() >= drainDeadline) { + refuse(); + } + await Bun.sleep(Math.min(25, drainDeadline - Date.now())); + } + const completion = Object.freeze({ [COMPLETE]: true as const }); + completions.set(completion, { input, operation, deadline, code }); + return completion; +} + +/** Consumed by the same held generation callback; numeric/structural/replayed + * results cannot clear its durable prospective child disposition. */ +export function consumeLegacyComposeRoutingCompletion(opts: { + readonly outcome: unknown; + readonly input: Input; + readonly operation: AdoptionOperation; + readonly deadline: number; +}): number { + if (typeof opts.outcome !== "object" || opts.outcome === null) { + refuse(); + } + const witness = completions.get(opts.outcome); + if ( + !witness || + witness.input !== opts.input || + witness.operation !== opts.operation || + witness.deadline !== opts.deadline + ) { + refuse(); + } + completions.delete(opts.outcome); + return witness.code; +} diff --git a/src/lib/native-compose-adoption-routing-resolution.ts b/src/lib/native-compose-adoption-routing-resolution.ts new file mode 100644 index 000000000..5c9a197de --- /dev/null +++ b/src/lib/native-compose-adoption-routing-resolution.ts @@ -0,0 +1,51 @@ +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; + +/** Effective typed-local precedence may qualify only the already served origins. */ +export function legacyComposeRoutingResolutionMatches(opts: { + readonly routing: LegacyComposeRoutingIntent; + readonly resolution: NativeRoutingResolution | null | undefined; +}): boolean { + const { routing, resolution } = opts; + if ( + !resolution || + resolution.branch !== undefined || + resolution.project_origin !== `https://${routing.devHost}` || + resolution.open_origin !== routing.openOrigin + ) { + return false; + } + const aliases = routing.aliasHost + ? { oauth: `https://${routing.aliasHost}` } + : {}; + if ( + JSON.stringify(resolution.aliases) !== JSON.stringify(aliases) || + resolution.oauth_alias !== (routing.aliasHost ? "oauth" : null) + ) { + return false; + } + if ( + Object.keys(resolution.routes).sort().join(",") !== + routing.routes + .map((route) => route.service) + .sort() + .join(",") + ) { + return false; + } + return routing.routes.every((route) => { + const found = resolution.routes[route.service]; + const primary = `https://${route.hostname === "project" ? routing.devHost : `${route.hostname}.${routing.devHost}`}`; + const alias = routing.aliasHost + ? `https://${route.hostname === "project" ? routing.aliasHost : `${route.hostname}.${routing.aliasHost}`}` + : null; + return ( + found?.service === route.service && + found.port === route.port && + found.protocol === "http" && + found.origin === primary && + JSON.stringify(found.aliases) === + JSON.stringify(alias ? { oauth: alias } : {}) + ); + }); +} diff --git a/src/lib/native-compose-adoption-routing.ts b/src/lib/native-compose-adoption-routing.ts new file mode 100644 index 000000000..cbd89d0f8 --- /dev/null +++ b/src/lib/native-compose-adoption-routing.ts @@ -0,0 +1,490 @@ +import { isIP } from "node:net"; +import { DEFAULT_INGRESS_NETWORK } from "../constants.ts"; +import { isRecord } from "./guards.ts"; +import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; +import { + type NativeComposeIngressBinding, + observeNativeComposeIngress, +} from "./native-compose-ingress.ts"; +import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import { + assertBoundNativeComposeProxyRoutes, + assertNativeComposeProxyAccess, + type BoundNativeComposeProxyRoute, +} from "./native-compose-proxy-routes.ts"; +import { NativeComposeRoutingError } from "./native-compose-routing.ts"; +import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; + +const ID = /^[a-f0-9]{64}$/; +const SITE_SEPARATOR = /[\s,]+/; +const CREATED = /^\d{4}-\d\d-\d\dT/; +const CONTAINER = + '{"id":{{json .Id}},"created":{{json .Created}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"native":{{json (index .Config.Labels "io.hack.native-config.owner")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"number":{{json (index .Config.Labels "com.docker.compose.container-number")}},"oneoff":{{json (index .Config.Labels "com.docker.compose.oneoff")}},"running":{{json .State.Running}},"paused":{{json .State.Paused}},"sites":[{{range $key,$value := .Config.Labels}}{{if or (eq $key "caddy") (and (ge (len $key) 6) (eq (slice $key 0 6) "caddy_")) (and (ge (len $key) 6) (eq (slice $key 0 6) "caddy."))}}{"key":{{json $key}},"value":{{json $value}}},{{end}}{{end}}null],"networks":[{{range $key,$value := .NetworkSettings.Networks}}{"name":{{json $key}},"id":{{json $value.NetworkID}},"ip":{{json $value.IPAddress}}},{{end}}null]}'; +const BIRTH = '{"id":{{json .Id}},"created":{{json .Created}}}'; +const NETWORK_BIRTH = '{"id":{{json .Id}},"created":{{json .Created}}}'; +const SITES = + '{"id":{{json .Id}},"sites":[{{range $key,$value := .Config.Labels}}{{if or (eq $key "caddy") (and (ge (len $key) 7) (eq (slice $key 0 6) "caddy_") (eq (len (split $key ".")) 1) (ne $key "caddy_ingress_network"))}}{{json $value}},{{end}}{{end}}null]}'; + +export type LegacyComposeRetainedRoutingProof = { + readonly routing_version: 14; + readonly ingress: NativeComposeIngressBinding; + readonly proxyCreatedAt: string; + readonly networkCreatedAt: string; + readonly originals: readonly { + readonly id: string; + readonly service: string; + readonly createdAt: string; + readonly labels: Readonly>; + }[]; +}; +type Binding = Pick< + LegacyComposeVerifiedBinding, + "engineId" | "composeProject" | "containers" +>; +type Observed = { + readonly proof: LegacyComposeRetainedRoutingProof; + readonly expected: readonly BoundNativeComposeProxyRoute[]; + readonly stopped: boolean; +}; +function refuse(): never { + throw new NativeComposeRoutingError(); +} +function keys(value: Record, wanted: string) { + return Object.keys(value).sort().join(",") === wanted; +} +function rows(text: string): Record[] { + const values: unknown[] = text.trim() + ? text + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + if (!values.every(isRecord)) { + return refuse(); + } + return values; +} +function birth(text: string, id: string): string { + const values = rows(text), + value = values[0]; + if ( + !( + values.length === 1 && + value && + keys(value, "created,id") && + value.id === id && + typeof value.created === "string" && + CREATED.test(value.created) && + Number.isFinite(Date.parse(value.created)) + ) + ) { + return refuse(); + } + return value.created; +} +function labels(value: unknown): Readonly> { + if (!(Array.isArray(value) && value.at(-1) === null)) { + return refuse(); + } + const result: Record = {}; + for (const pair of value.slice(0, -1)) { + if ( + !( + isRecord(pair) && + keys(pair, "key,value") && + typeof pair.key === "string" && + typeof pair.value === "string" && + !Object.hasOwn(result, pair.key) + ) + ) { + return refuse(); + } + result[pair.key] = pair.value; + } + return Object.freeze( + Object.fromEntries( + Object.entries(result).sort(([a], [b]) => a.localeCompare(b)) + ) + ); +} +function equal(left: unknown, right: unknown) { + return JSON.stringify(left) === JSON.stringify(right); +} +function covers(site: string, hostname: string): boolean { + if (site.startsWith(":")) { + return true; + } + const url = new URL(site.includes("://") ? site : `https://${site}`); + if ( + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + !["http:", "https:"].includes(url.protocol) + ) { + return refuse(); + } + return ( + url.hostname === "*" || + url.hostname === hostname || + (url.hostname.startsWith("*.") && hostname.endsWith(url.hostname.slice(1))) + ); +} +/** Foreign existing site writers, including stopped containers, cannot inherit a retained ID exception. */ +async function inventory( + probe: ReturnType, + binding: Binding, + routing: LegacyComposeRoutingIntent +) { + const hosts = routing.routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ); + const owned = new Set( + binding.containers + .filter((item) => + routing.routes.some((route) => route.service === item.service) + ) + .map((item) => item.id) + ); + for (let pass = 0; pass < 2; pass++) { + const selected = rows( + await probe([ + "container", + "ls", + "--all", + "--no-trunc", + "--format", + '{"id":{{json .ID}}}', + ]) + ); + const ids = selected.map((row) => { + if (!(keys(row, "id") && typeof row.id === "string" && ID.test(row.id))) { + return refuse(); + } + return row.id; + }); + if ( + new Set(ids).size !== ids.length || + [...owned].some((id) => !ids.includes(id)) + ) { + return refuse(); + } + for (let offset = 0; offset < ids.length; offset += 64) { + const batch = ids.slice(offset, offset + 64); + const observed = rows( + await probe(["container", "inspect", "--format", SITES, ...batch]) + ); + const seen = new Set(); + if (observed.length !== batch.length) { + return refuse(); + } + for (const row of observed) { + if ( + !( + keys(row, "id,sites") && + typeof row.id === "string" && + batch.includes(row.id) && + !seen.has(row.id) && + Array.isArray(row.sites) && + row.sites.at(-1) === null && + row.sites.slice(0, -1).every((site) => typeof site === "string") + ) + ) { + return refuse(); + } + seen.add(row.id); + if ( + !owned.has(row.id) && + row.sites.slice(0, -1).some((site: string) => + site + .split(SITE_SEPARATOR) + .filter(Boolean) + .some((value) => + hosts.some((hostname) => covers(value, hostname)) + ) + ) + ) { + return refuse(); + } + } + } + } +} +function snapshot(binding: Binding, routing: LegacyComposeRoutingIntent) { + if ( + routing.version !== 14 || + !binding.containers.length || + new Set(binding.containers.map((row) => row.id)).size !== + binding.containers.length || + binding.containers.some((row) => !ID.test(row.id)) + ) { + return refuse(); + } + const selectedBinding = Object.freeze({ + ...binding, + containers: Object.freeze( + binding.containers.map((item) => Object.freeze({ ...item })) + ), + }); + const selectedRouting = Object.freeze({ + ...routing, + routes: Object.freeze( + routing.routes.map((route) => + Object.freeze({ + ...route, + origins: Object.freeze([...route.origins]), + labels: Object.freeze({ ...route.labels }), + }) + ) + ), + }); + return { binding: selectedBinding, routing: selectedRouting }; +} +async function observe(opts: { + readonly binding: Binding; + readonly routing: LegacyComposeRoutingIntent; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +}): Promise { + const { binding, routing } = snapshot(opts.binding, opts.routing); + const signal = opts.signal; + const probe = createNativeComposeProbe({ signal, timeoutMs: opts.timeoutMs }); + const ingress = await observeNativeComposeIngress({ signal }); + if (ingress.engineId !== binding.engineId) { + return refuse(); + } + const proxyCreatedAt = birth( + await probe(["container", "inspect", "--format", BIRTH, ingress.proxyId]), + ingress.proxyId + ); + const networkCreatedAt = birth( + await probe([ + "network", + "inspect", + "--format", + NETWORK_BIRTH, + ingress.networkId, + ]), + ingress.networkId + ); + const originals: LegacyComposeRetainedRoutingProof["originals"][number][] = + []; + const expected: BoundNativeComposeProxyRoute[] = []; + let stopped = true; + for (const original of binding.containers) { + const found = rows( + await probe(["container", "inspect", "--format", CONTAINER, original.id]) + ); + const row = found[0]; + if ( + !( + found.length === 1 && + row && + keys( + row, + "created,id,native,networks,number,oneoff,paused,project,running,service,sites" + ) && + row.id === original.id && + row.project === binding.composeProject && + (row.native === null || row.native === "") && + row.service === original.service && + row.number === "1" && + (row.oneoff === "False" || row.oneoff === "false") && + typeof row.running === "boolean" && + row.paused === false && + typeof row.created === "string" && + CREATED.test(row.created) && + Number.isFinite(Date.parse(row.created)) && + Array.isArray(row.networks) && + row.networks.at(-1) === null + ) + ) { + return refuse(); + } + const route = routing.routes.find( + (item) => item.service === original.service + ); + const observedLabels = labels(row.sites); + const wantedLabels = Object.fromEntries( + Object.entries(route?.labels ?? {}).sort(([a], [b]) => a.localeCompare(b)) + ); + if (!equal(observedLabels, wantedLabels)) { + return refuse(); + } + const configured = row.networks.slice(0, -1); + const names = new Set(); + for (const item of configured) { + if ( + !( + isRecord(item) && + keys(item, "id,ip,name") && + typeof item.name === "string" && + !names.has(item.name) && + typeof item.id === "string" && + ID.test(item.id) && + typeof item.ip === "string" + ) + ) { + return refuse(); + } + names.add(item.name); + } + if ( + !( + configured.length === (route ? 2 : 1) && + names.has(`${binding.composeProject}_default`) && + names.has(DEFAULT_INGRESS_NETWORK) === Boolean(route) + ) + ) { + return refuse(); + } + const attached = configured.find( + (item) => isRecord(item) && item.name === DEFAULT_INGRESS_NETWORK + ); + if (route && !(isRecord(attached) && attached.id === ingress.networkId)) { + return refuse(); + } + originals.push({ + id: original.id, + service: original.service, + createdAt: row.created, + labels: observedLabels, + }); + stopped &&= !row.running; + if (route && row.running) { + if ( + !( + isRecord(attached) && + typeof attached.ip === "string" && + isIP(attached.ip) === 4 + ) + ) { + return refuse(); + } + expected.push({ + service: route.service, + port: route.port, + protocol: "http", + origins: route.origins, + hostnames: route.origins.map((origin) => new URL(origin).hostname), + dials: [`${attached.ip}:${route.port}`], + }); + } + } + await inventory(probe, binding, routing); + await observeNativeComposeIngress({ expected: ingress, signal }); + if ( + birth( + await probe(["container", "inspect", "--format", BIRTH, ingress.proxyId]), + ingress.proxyId + ) !== proxyCreatedAt || + birth( + await probe([ + "network", + "inspect", + "--format", + NETWORK_BIRTH, + ingress.networkId, + ]), + ingress.networkId + ) !== networkCreatedAt + ) { + return refuse(); + } + return { + proof: Object.freeze({ + routing_version: 14, + ingress, + proxyCreatedAt, + networkCreatedAt, + originals: Object.freeze( + originals.sort((a, b) => a.service.localeCompare(b.service)) + ), + }), + expected, + stopped, + }; +} +/** Snapshot exact original labels/births and shared ingress. This performs no reservation, effect or repair. */ +export async function inspectLegacyComposeRetainedRouting( + opts: Parameters[0] +): Promise { + const selected = { + ...snapshot(opts.binding, opts.routing), + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }; + const first = await observe(selected); + await assertNativeComposeProxyAccess({ + binding: first.proof.ingress, + signal: selected.signal, + }); + const second = await observe(selected); + if (!equal(first.proof, second.proof)) { + return refuse(); + } + return first.proof; +} +/** Exact live/stopped dispatch under the saved owner. No hostname, ID or upstream is fabricated. */ +export async function assertLegacyComposeRetainedRoutingState( + opts: Parameters[0] & { + readonly proof: LegacyComposeRetainedRoutingProof; + readonly phase: "active" | "stopped"; + readonly deadline: number; + readonly assertOwner: () => Promise; + } +): Promise { + const selected = { + ...snapshot(opts.binding, opts.routing), + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }; + const expectedProof = structuredClone(opts.proof), + phase = opts.phase, + deadline = opts.deadline, + assertOwner = opts.assertOwner; + if ( + !["active", "stopped"].includes(phase) || + typeof assertOwner !== "function" || + !Number.isFinite(deadline) || + deadline <= Date.now() + ) { + return refuse(); + } + const remaining = AbortSignal.timeout(Math.ceil(deadline - Date.now())); + const signal = selected.signal + ? AbortSignal.any([selected.signal, remaining]) + : remaining; + const observeExpected = async () => { + await assertOwner(); + const current = await observe({ ...selected, signal }); + if ( + !equal(current.proof, expectedProof) || + (phase === "active" + ? current.expected.length !== selected.routing.routes.length + : !current.stopped) + ) { + return refuse(); + } + await assertOwner(); + return phase === "active" ? current.expected : []; + }; + await observeExpected(); + await assertBoundNativeComposeProxyRoutes({ + binding: expectedProof.ingress, + observeExpected, + absentHostnames: + phase === "stopped" + ? selected.routing.routes.flatMap((route) => + route.origins.map((origin) => new URL(origin).hostname) + ) + : [], + signal, + deadline, + }); + await observeExpected(); + if (signal.aborted || Date.now() >= deadline) { + return refuse(); + } +} diff --git a/src/lib/native-compose-adoption-runtime.ts b/src/lib/native-compose-adoption-runtime.ts index 54cc585c5..31f287ab5 100644 --- a/src/lib/native-compose-adoption-runtime.ts +++ b/src/lib/native-compose-adoption-runtime.ts @@ -66,7 +66,8 @@ export async function inspectLegacyComposeRuntimeConfig(opts: { opts.composeFile, ...(opts.binding.binding_version === 2 || opts.binding.binding_version === 4 || - opts.binding.binding_version === 6 + opts.binding.binding_version === 6 || + opts.binding.binding_version === 14 ? opts.binding.composeFiles.slice(1) : []), ]; diff --git a/src/lib/native-compose-open.ts b/src/lib/native-compose-open.ts index 1d49de7d8..025dd44ba 100644 --- a/src/lib/native-compose-open.ts +++ b/src/lib/native-compose-open.ts @@ -1,5 +1,7 @@ import { CliUsageError } from "../cli/command.ts"; import { HackCliError } from "./cli-result.ts"; +import { selectLegacyComposeAdoptedRoot } from "./native-compose-adoption-command.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "./native-compose-adoption-generation.ts"; import { openNativeComposeGenerationStore } from "./native-compose-generation.ts"; import { readNativeComposeRouteMetadata } from "./native-compose-route-owner.ts"; import { @@ -25,6 +27,16 @@ function unsupported(): never { }); } +function invalidSavedRouting(error: unknown): never { + if (error instanceof HackCliError || error instanceof CliUsageError) { + throw error; + } + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Saved native routing selection is invalid; values omitted.", + }); +} + function preferredOrigin(opts: { readonly origin: string; readonly alias?: string; @@ -90,12 +102,51 @@ export function resolveNativeComposeOpenOrigin(opts: { /** * Select native inputs before legacy context/registration. Read the immutable saved - * routing report under its generation lease; never compile, decrypt, observe Docker, - * or invent a host from current authored input merely to answer `open`. + * routing report under its generation lease. Retained routing also rechecks its + * original resource owner; neither path compiles, decrypts or invents a host. */ export async function tryNativeComposeOpen( - opts: NativeComposeOpenOptions + input: NativeComposeOpenOptions ): Promise { + const opts = { ...input }; + const adopted = await selectLegacyComposeAdoptedRoot(opts); + if (adopted) { + requireNativeComposeBackend({ backend: process.env.HACK_RUNTIME_BACKEND }); + if (opts.instance !== undefined) { + return unsupported(); + } + try { + const store = await openLegacyComposeAdoptedGenerationStore({ + projectRoot: adopted, + mode: "saved", + }); + try { + const generation = await store.loadActive(); + if (!generation) { + return unsupported(); + } + return await store.withLease({ + generation, + run: (input) => { + if (!(input.retainedRouting && input.routingResolution)) { + return unsupported(); + } + return Promise.resolve( + resolveNativeComposeOpenOrigin({ + resolution: input.routingResolution, + target: opts.target, + prefer: opts.prefer, + }) + ); + }, + }); + } finally { + await store.close(); + } + } catch (error: unknown) { + return invalidSavedRouting(error); + } + } const selected = await selectNativeComposeProject(opts); if (!selected) { return null; @@ -142,12 +193,6 @@ export async function tryNativeComposeOpen( await store.close(); } } catch (error: unknown) { - if (error instanceof HackCliError || error instanceof CliUsageError) { - throw error; - } - throw new HackCliError({ - code: "E_CONFIG_INVALID", - message: "Saved native routing selection is invalid; values omitted.", - }); + return invalidSavedRouting(error); } } diff --git a/src/lib/native-compose-proxy-routes.ts b/src/lib/native-compose-proxy-routes.ts index cac5a1c51..7633b531c 100644 --- a/src/lib/native-compose-proxy-routes.ts +++ b/src/lib/native-compose-proxy-routes.ts @@ -39,6 +39,9 @@ type HostScope = { type ExpectedRoute = NativeComposeProxyRoute & { readonly dials: readonly string[]; }; + +/** Read-only route projection type; supplying it grants no resource or effect authority. */ +export type BoundNativeComposeProxyRoute = ExpectedRoute; const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; async function readActiveProxy(opts: { readonly binding: NativeComposeIngressBinding; @@ -712,3 +715,92 @@ export async function assertNativeComposeProxyRoutes(opts: { refused(); } } + +/** + * Read-only dispatch/TLS check for a separately issued retained-resource owner. + * The callback must obtain current exact original IDs, labels and ingress IPs + * under its source/receipt lease. It is reread after each successful proxy proof; + * no native owner label or caller-provided old IP substitutes for that owner. + */ +export async function assertBoundNativeComposeProxyRoutes(opts: { + readonly binding: NativeComposeIngressBinding; + readonly observeExpected: () => Promise< + readonly BoundNativeComposeProxyRoute[] + >; + readonly absentHostnames: readonly string[]; + readonly signal?: AbortSignal; + readonly deadline: number; +}): Promise { + try { + const binding = Object.freeze({ ...opts.binding }); + const observeExpected = opts.observeExpected; + const absentHostnames = Object.freeze([...opts.absentHostnames]); + const deadline = opts.deadline; + const remaining = deadline - Date.now(); + if ( + !Number.isFinite(remaining) || + remaining <= 0 || + typeof observeExpected !== "function" + ) { + return refused(); + } + const bounded = AbortSignal.timeout(Math.ceil(remaining)); + const signal = opts.signal + ? AbortSignal.any([opts.signal, bounded]) + : bounded; + const readExpected = async () => { + const value = await observeExpected(); + return value.map((route) => + Object.freeze({ + ...route, + hostnames: Object.freeze([...route.hostnames]), + origins: Object.freeze([...route.origins]), + dials: Object.freeze([...route.dials]), + }) + ); + }; + const readActive = async ( + expected: readonly BoundNativeComposeProxyRoute[] + ) => { + const selected = { binding, signal }; + const servers = await readActiveProxy(selected); + const projection = { servers, expected, absentHostnames }; + if (!projectedRoutesMatch(projection, true)) { + return false; + } + const origins = automaticHttpsOrigins(servers, expected); + if (origins.length) { + try { + await verifyAutomaticHttps({ ...selected, origins }); + } catch { + return false; + } + } + return projectedRoutesMatch( + { ...projection, servers: await readActiveProxy(selected) }, + true + ); + }; + while (!signal.aborted && Date.now() < deadline) { + await observeNativeComposeIngress({ expected: binding, signal }); + const expected = await readExpected(); + if (await readActive(expected)) { + const after = await readExpected(); + if (JSON.stringify(expected) !== JSON.stringify(after)) { + return refused(); + } + await observeNativeComposeIngress({ expected: binding, signal }); + if (await readActive(after)) { + if (signal.aborted || Date.now() >= deadline) { + return refused(); + } + return; + } + } + await Bun.sleep(Math.min(500, Math.max(0, deadline - Date.now()))); + } + refused(); + } catch { + refused(); + } +} diff --git a/src/lib/native-compose-route-claims.ts b/src/lib/native-compose-route-claims.ts index b7c1448dc..0c8cbd6ad 100644 --- a/src/lib/native-compose-route-claims.ts +++ b/src/lib/native-compose-route-claims.ts @@ -257,6 +257,16 @@ function snapshotReference( }), }); } + +/** Strict private reference decoder shared with retained-generation receipts. */ +export function parseNativeComposeRouteReference( + value: unknown +): NativeComposeRouteReference { + if (!referenceValid(value)) { + refuse(); + } + return snapshotReference(value); +} function freezeAttempt( attempt: NativeComposeRouteAttempt ): NativeComposeRouteAttempt { @@ -448,6 +458,8 @@ export type NativeComposeRouteClaims = { reopen( reference: NativeComposeRouteReference ): Promise; + /** Read-only: the referenced original claim tokens and inodes remain active. */ + assertHeld(reference: NativeComposeRouteReference): Promise; /** Synchronize uncertain intent before invoking any engine child/effect. */ markEffectsPossible(attempt: NativeComposeRouteAttempt): Promise; /** Only the live armed attempt can complete; reopened uncertainty cannot. */ @@ -490,6 +502,22 @@ export type NativeComposeRouteClaims = { NativeComposeRouteClaims["release"] >[0]["assertAbsent"]; }): Promise; + /** Retained legacy owner only: exact original containers remain stopped. The + * saved generation must prove known child settlement and route absence. This + * marks referenced uncertainty stopped while keeping every claim held. */ + recoverRetainedStopped(opts: { + readonly references: readonly NativeComposeRouteReference[]; + readonly assertStopped: Parameters< + NativeComposeRouteClaims["release"] + >[0]["assertAbsent"]; + }): Promise; + /** Retained rollback handoff only, after exact stopped-original, known-child, + * route-absence and restored-source proofs. Uncertain journals still veto. */ + releaseRetained(opts: { + readonly assertStoppedAndRestored: Parameters< + NativeComposeRouteClaims["release"] + >[0]["assertAbsent"]; + }): Promise; close(): Promise; }; @@ -1058,6 +1086,7 @@ export async function openNativeComposeRouteClaims(opts: { }; const retire = async (options: { readonly verifyOnly?: boolean; + readonly keepClaims?: boolean; readonly keepHostnames?: readonly string[]; readonly references?: readonly NativeComposeRouteReference[]; readonly assertAbsent: Parameters< @@ -1068,6 +1097,7 @@ export async function openNativeComposeRouteClaims(opts: { const assertAbsent = options.assertAbsent; const references = options.references?.map(snapshotReference); const verifyOnly = options.verifyOnly === true; + const keepClaims = options.keepClaims === true; if (typeof assertAbsent !== "function") { refuse(); } @@ -1112,6 +1142,9 @@ export async function openNativeComposeRouteClaims(opts: { ); } } + if (keepClaims) { + return; + } await publish(join(releasesRoot, `${hash(token())}.json`), { version: 1, binding, @@ -1191,6 +1224,28 @@ export async function openNativeComposeRouteClaims(opts: { await activeEntries(records); return result; }), + assertHeld: (reference) => { + const snapshot = snapshotReference(reference); + return guard(async () => { + const records = await journals(); + const record = find(records, snapshot); + const current = await activeEntries(records); + if ( + record.aborted || + record.intent.claims.some((claim) => { + const expected = record.entries?.find((entry) => + equal(entry.claim, claim) + ); + return !( + expected && equal(current.entries.get(claim.hostname), expected) + ); + }) + ) { + refuse(); + } + await check(); + }); + }, markEffectsPossible: (attempt) => guard(async () => { const { reference } = capability(attempt); @@ -1278,6 +1333,17 @@ export async function openNativeComposeRouteClaims(opts: { assertAbsent: options.assertAbsent, }) ), + recoverRetainedStopped: (options) => { + const references = options.references.map(snapshotReference); + const assertAbsent = options.assertStopped; + return guard(() => + retire({ references, assertAbsent, keepClaims: true }) + ); + }, + releaseRetained: (options) => { + const assertAbsent = options.assertStoppedAndRestored; + return guard(() => retire({ assertAbsent })); + }, close: async () => { if (!closed) { closed = true; diff --git a/tests/helpers/retained-routing-adoption.ts b/tests/helpers/retained-routing-adoption.ts new file mode 100644 index 000000000..34c375733 --- /dev/null +++ b/tests/helpers/retained-routing-adoption.ts @@ -0,0 +1,615 @@ +import { spyOn } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; +import { openLegacyComposeAdoptedGenerationStore } from "../../src/lib/native-compose-adoption-generation.ts"; +import { parseLegacyComposeAdoptionReceipt } from "../../src/lib/native-compose-adoption-receipt.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../../src/lib/native-compose-adoption-routing-execution.ts"; +import * as engine from "../../src/lib/native-compose-engine-identity.ts"; +import * as ownership from "../../src/lib/native-compose-ownership.ts"; +import { mapLegacyNativeRetainedRouting } from "../../src/lib/native-config-import-plan.ts"; +import type { NativeRoutingResolution } from "../../src/lib/native-routing-plan-protocol.ts"; +import * as shell from "../../src/lib/shell.ts"; +import { restoreEnv } from "./env.ts"; + +export const ROUTING_CANARY = "synthetic-retained-sql-row"; +export const ROUTING_IDS = { + db: "a".repeat(64), + web: "b".repeat(64), + network: "c".repeat(64), + ingress: "d".repeat(64), + proxy: "e".repeat(64), + foreign: "f".repeat(64), +}; +const CREATED = "2026-01-01T01:02:03Z", + HASH = "1".repeat(64), + GROUP = 987_654; +const LABELS = { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", +}; +const LIST_FORMATS: Readonly> = { + container: + '{"id":{{json .ID}},"name":{{json .Names}},"project":{{json (.Label "com.docker.compose.project")}}}', + network: + '{"id":{{json .ID}},"name":{{json .Name}},"project":{{json (.Label "com.docker.compose.project")}}}', + volume: + '{"id":{{json .Name}},"name":{{json .Name}},"project":{{json (.Label "com.docker.compose.project")}}}', +}; +function refuse(): never { + throw new Error( + "Synthetic retained route transport refused; values omitted." + ); +} +function identity(value: unknown) { + if ( + !( + isRecord(value) && + typeof value.dev === "number" && + typeof value.ino === "number" + ) + ) { + return refuse(); + } + return { dev: value.dev, ino: value.ino }; +} + +/** A timeout or unfinished continuation permanently retains the fixture's global + * doubles and private environment. Late settlement cannot grant teardown. */ +export function retainedRoutingFixtureLifetime(deadline: number) { + let unknown = false; + const pending = new Set>(); + const canRestore = () => { + if (Date.now() >= deadline || pending.size !== 0) { + unknown = true; + } + return !unknown; + }; + return { + retain: () => { + unknown = true; + }, + canRestore, + track: (work: Promise): Promise => { + pending.add(work); + work.then( + () => pending.delete(work), + () => pending.delete(work) + ); + return work; + }, + }; +} + +/** Private-store model only. Docker metadata and child effects are injected; + * this does not qualify Go formatting, compiler semantics, TLS, SQL or a daemon. */ +export async function retainedRoutingFixture( + lifetime: ReturnType +) { + const outer = await realpath( + await mkdtemp(join(tmpdir(), "retained-routing-owner-")) + ); + const root = join(outer, "checkout"), + home = join(outer, "hack-home"); + await chmod(outer, 0o700); + await mkdir(join(root, ".hack"), { recursive: true, mode: 0o700 }); + await mkdir(join(root, ".git"), { mode: 0o700 }); + await mkdir(home, { mode: 0o700 }); + const config = JSON.stringify({ + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true }, + open: { prefer: "alias" }, + worktree: { auto_branch: false, inherit_local: false }, + }); + const compose = JSON.stringify({ + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + db: { image: "synthetic/db:1", volumes: ["data:/data"] }, + web: { + image: "synthetic/web:1", + networks: ["default", "hack-dev"], + labels: LABELS, + }, + }, + volumes: { data: {} }, + }); + await writeFile(join(root, ".hack/hack.config.json"), config, { + mode: 0o600, + }); + await writeFile(join(root, ".hack/docker-compose.yml"), compose, { + mode: 0o600, + }); + const candidate = mapLegacyNativeRetainedRouting({ + configText: config, + composeText: compose, + }).candidate; + if (!candidate) { + return refuse(); + } + const compiler = join(outer, "compiler"); + const resolution: NativeRoutingResolution = { + domain: "hack.local", + domain_origin: "default", + project_origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + oauth_alias: "oauth", + open_preference: "alias", + open_preference_origin: "project", + open_origin: "https://original.hack.gy", + routes: { + web: { + service: "web", + port: 3000, + protocol: "http", + origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + }, + }, + }; + const plan = { + plan_version: 1, + name: "fixture", + services: { db: {}, web: {} }, + jobs: {}, + routes: { + origin: resolution.project_origin, + aliases: { oauth: { origin: resolution.aliases.oauth } }, + oauth_alias: "oauth", + http: { + web: { + service: "web", + port: 3000, + protocol: "http", + hostname: "project", + }, + }, + }, + open: { prefer: "alias" }, + worktree: { auto_branch: false, inherit_local: false }, + }; + await writeFile( + compiler, + `#!${process.execPath} +const operation=process.argv[2]; +if(operation==='--protocol') console.log(JSON.stringify({transport_version:1,authored_version:1,plan_version:1,resolve_version:1,local_version:1,env_plan_version:1,routing_plan_version:1})); +else { + const raw=JSON.parse(await Bun.stdin.text()), request=operation==='compile'?{}:raw, project=operation==='compile'?raw:JSON.parse(request.project); + if(JSON.stringify(project)!==${JSON.stringify(JSON.stringify(candidate))})process.exit(97); + const result={transport_version:1,ok:true,semantic_hash:'a'.repeat(64),declared_workloads:{db:'service',web:'service'},plan:${JSON.stringify(plan)}}; + if(operation!=='compile') {result.local_resolution={overlay:null,origin:'project',auto_branch:false,inherit_local:false,resolution_hash:'b'.repeat(64)};if(request.routing_probe===true)result.routing_inputs_required=true;else result.routing_resolution=${JSON.stringify(resolution)};} + if(operation==='plan')result.environment_plan={plan_version:1,overlay:null,overlay_exists:false,complete:true,workloads:{db:{},web:{}},warnings:[],diagnostics:[]}; + console.log(JSON.stringify(result)); +} +`, + { mode: 0o700 } + ); + const previousHome = process.env.HACK_HOME; + process.env.HACK_HOME = home; + const model = { + running: false, + foreign: false, + wrongDial: false, + partial: false, + proxyAccess: true, + volumeBirth: CREATED, + webBirth: CREATED, + engine: "synthetic-retained-routing", + sqlRow: ROUTING_CANARY, + }; + const commands: string[][] = [], + effects: string[][] = []; + const hooks: { + afterProbe?: (args: readonly string[]) => Promise; + afterEffect?: () => Promise; + } = {}; + const json = (value: unknown) => JSON.stringify(value); + const names = ["db", "web"] as const; + const service = (id: string | undefined) => + names.find((name) => ROUTING_IDS[name] === id) ?? refuse(); + const probe: ReturnType = async ( + input + ) => { + const args = [...input]; + commands.push(args); + const [kind, action] = args, + format = args[args.indexOf("--format") + 1] ?? "", + id = args.at(-1); + let result: string; + if (kind === "info" && action === "--format") { + result = + format === "{{json .ID}}" + ? json(model.engine) + : json({ id: model.engine, os: "linux" }); + } else if ( + kind === "exec" && + args[1] === ROUTING_IDS.proxy && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" + ) { + if (!model.proxyAccess) { + return refuse(); + } + const routes = model.running + ? [ + { + match: [{ host: LABELS.caddy.split(",") }], + handle: [ + { + handler: "reverse_proxy", + upstreams: [ + { + dial: `${model.wrongDial ? "172.28.0.99" : "172.28.0.3"}:3000`, + }, + ], + }, + ], + terminal: true, + }, + ] + : []; + result = `${json({ + srv0: { listen: [":443"], tls_connection_policies: [{}], routes }, + })}\n200`; + } else if (kind === "compose" && args.at(-2) === "--hash" && id === "*") { + const file = args[args.indexOf("--file") + 1]; + if (!file || (await readFile(file, "utf8")) !== compose) { + return refuse(); + } + result = `db ${HASH}\nweb ${HASH}`; + } else if ( + action === "ls" && + kind === "container" && + format === "{{json .ID}}" && + args.includes("label=com.docker.compose.project=hack-dev-proxy") + ) { + result = json(ROUTING_IDS.proxy); + } else if ( + action === "ls" && + ["container", "network", "volume"].includes(kind ?? "") + ) { + if (args.some((arg) => arg.startsWith("label=io.hack.native-config."))) { + result = ""; + } else if (kind === "container" && format === '{"id":{{json .ID}}}') { + result = [ + ROUTING_IDS.db, + ROUTING_IDS.web, + ROUTING_IDS.proxy, + ...(model.foreign ? [ROUTING_IDS.foreign] : []), + ] + .map((value) => json({ id: value })) + .join("\n"); + } else if ( + kind !== undefined && + format === LIST_FORMATS[kind] && + json(args) === + json([ + kind, + "ls", + ...(kind === "container" ? ["--all"] : []), + ...(kind === "volume" ? [] : ["--no-trunc"]), + "--format", + format, + ]) + ) { + result = ( + kind === "container" + ? names.map((name) => ({ + id: ROUTING_IDS[name], + name: `fixture-${name}-1`, + project: "fixture", + })) + : kind === "network" + ? [ + { + id: ROUTING_IDS.network, + name: "fixture_default", + project: "fixture", + }, + ] + : [ + { + id: "fixture_data", + name: "fixture_data", + project: "fixture", + }, + ] + ) + .map(json) + .join("\n"); + } else { + return refuse(); + } + } else if (kind === "network" && action === "inspect") { + if (id === "hack-dev") { + result = json({ id: ROUTING_IDS.ingress, name: "hack-dev" }); + } else if (id === ROUTING_IDS.ingress && format.includes("created")) { + result = json({ id, created: CREATED }); + } else if (id === ROUTING_IDS.network) { + result = json({ + id, + name: "fixture_default", + project: "fixture", + native: "", + logical: "default", + createdAt: CREATED, + driver: "bridge", + scope: "local", + internal: false, + containers: model.running ? [ROUTING_IDS.db, ROUTING_IDS.web] : [], + }); + } else { + return refuse(); + } + } else if ( + kind === "volume" && + action === "inspect" && + id === "fixture_data" + ) { + result = json({ + id, + name: id, + project: "fixture", + native: "", + storage: "data", + createdAt: model.volumeBirth, + driver: "local", + scope: "local", + mountpoint: "/var/lib/docker/volumes/fixture_data/_data", + options: null, + }); + } else if (kind === "container" && action === "inspect") { + if (format.includes('"sites"') && !format.includes('"created"')) { + result = args + .slice(4) + .map((selected) => + json({ + id: selected, + sites: + selected === ROUTING_IDS.web || selected === ROUTING_IDS.foreign + ? [LABELS.caddy, null] + : [null], + }) + ) + .join("\n"); + } else if (id === ROUTING_IDS.proxy) { + result = format.includes('"created"') + ? json({ id, created: CREATED }) + : json({ + id, + project: "hack-dev-proxy", + service: "caddy", + running: true, + network: ROUTING_IDS.ingress, + ip: "172.28.0.2", + }); + } else { + const name = service(id); + if (format.includes('"sites"')) { + result = json({ + id, + created: name === "web" ? model.webBirth : CREATED, + project: "fixture", + native: "", + service: name, + number: "1", + oneoff: "False", + running: model.running, + paused: false, + sites: + name === "web" + ? [ + ...Object.entries(LABELS).map(([key, value]) => ({ + key, + value, + })), + null, + ] + : [null], + networks: [ + { + name: "fixture_default", + id: ROUTING_IDS.network, + ip: model.running ? "172.27.0.3" : "", + }, + ...(name === "web" + ? [ + { + name: "hack-dev", + id: ROUTING_IDS.ingress, + ip: model.running ? "172.28.0.3" : "", + }, + ] + : []), + null, + ], + }); + } else if (format.includes("config-hash")) { + result = json({ id, hash: HASH }); + } else if (format.includes(".Mounts")) { + result = json({ + id, + name: `/fixture-${name}-1`, + project: "fixture", + native: "", + service: name, + number: "1", + oneoff: "False", + running: model.running, + workingDir: join(root, ".hack"), + configFiles: join(root, ".hack/docker-compose.yml"), + mounts: + name === "db" + ? [ + { + type: "volume", + name: "fixture_data", + source: "/var/lib/docker/volumes/fixture_data/_data", + target: "/data", + rw: true, + }, + ] + : [], + networks: [ + { name: "fixture_default", id: ROUTING_IDS.network }, + ...(name === "web" + ? [{ name: "hack-dev", id: ROUTING_IDS.ingress }] + : []), + ], + }); + } else if (format.includes(".State.Running")) { + result = json({ + id, + running: model.running, + paused: false, + status: model.running ? "running" : "exited", + ...(format.includes("Health") ? { health: "" } : {}), + }); + } else { + return refuse(); + } + } + } else { + return refuse(); + } + await hooks.afterProbe?.(args); + return result; + }; + const originalKill = process.kill; + const spies = [ + spyOn(ownership, "createNativeComposeProbe").mockImplementation( + () => probe + ), + spyOn(engine, "createNativeComposeEngineIdentityObserver").mockReturnValue( + null + ), + spyOn(shell, "run").mockImplementation(async (args, opts = {}) => { + opts.beforeSpawn?.(); + if ( + JSON.stringify(args) !== + json([ + "docker", + "container", + args[2], + ROUTING_IDS.db, + ROUTING_IDS.web, + ]) || + !["start", "restart", "stop"].includes(args[2] ?? "") + ) { + return refuse(); + } + await opts.onSpawn?.({ + pid: GROUP, + ownsProcessGroup: true, + processGroupId: GROUP, + }); + effects.push([...args]); + model.running = args[2] !== "stop"; + await hooks.afterEffect?.(); + return model.partial ? 7 : 0; + }), + spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === -GROUP && signal === 0) { + throw Object.assign(new Error("synthetic absent"), { code: "ESRCH" }); + } + return originalKill.call(process, pid, signal); + }), + ]; + const receiptPath = join( + root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + return { + root, + outer, + home, + compiler, + config, + compose, + model, + resolution, + hooks, + commands, + effects, + receiptPath, + canRestore: lifetime.canRestore, + track: lifetime.track, + receipt: async () => { + const value: unknown = JSON.parse(await readFile(receiptPath, "utf8")); + if (!(isRecord(value) && isRecord(value.checkout))) { + return refuse(); + } + return parseLegacyComposeAdoptionReceipt(value, { + root: identity(value.checkout.root), + project: identity(value.checkout.project), + git: identity(value.checkout.git), + }); + }, + store: () => + openLegacyComposeAdoptedGenerationStore({ + projectRoot: root, + timeoutMs: 15_000, + }), + operation: async ( + store: Awaited< + ReturnType + >, + generation: Parameters[0]["generation"], + operation: "start" | "stop", + opts: { + readonly recover?: boolean; + readonly numeric?: boolean; + readonly preparation?: boolean; + } = {} + ) => { + const deadline = Date.now() + 15_000; + const run = async ( + input: Parameters[0]["run"]>[0] + ) => + opts.numeric + ? 0 + : await runLegacyComposeRetainedRoutingOperation({ + input, + operation, + deadline, + }); + return opts.preparation + ? await store.withPreparationStop({ + generation, + binary: compiler, + deadline, + recover: opts.recover, + run, + }) + : await store.withMutation({ + generation, + operation, + services: [], + binary: compiler, + deadline, + recover: opts.recover, + run, + }); + }, + cleanup: async () => { + if (!lifetime.canRestore()) { + return refuse(); + } + for (const spy of spies) { + spy.mockRestore(); + } + restoreEnv("HACK_HOME", previousHome); + await rm(outer, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/native-compose-adoption-routing-execution.test.ts b/tests/native-compose-adoption-routing-execution.test.ts new file mode 100644 index 000000000..e0ab0fbe7 --- /dev/null +++ b/tests/native-compose-adoption-routing-execution.test.ts @@ -0,0 +1,242 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import type { LegacyComposeVerifiedBinding } from "../src/lib/native-compose-adoption-binding.ts"; +import { + consumeLegacyComposeRoutingCompletion, + runLegacyComposeRetainedRoutingOperation, +} from "../src/lib/native-compose-adoption-routing-execution.ts"; +import * as shell from "../src/lib/shell.ts"; + +const ID = "a".repeat(64), + OTHER = "b".repeat(64), + GROUP = 987_654; +const spies: { mockRestore(): void }[] = []; +afterEach(() => { + for (const spy of spies.splice(0)) { + spy.mockRestore(); + } +}); +function input(assertFresh: () => Promise = async () => {}) { + // Transport-only boundary: the generation tests separately issue this binding. + const binding = { + binding_version: 14, + projectRoot: "/synthetic/retained-routing", + containers: [{ id: ID }, { id: OTHER }], + } as unknown as LegacyComposeVerifiedBinding; + return { binding, assertFresh, assertActive: () => {} }; +} +function missing(): never { + throw Object.assign(new Error("absent"), { code: "ESRCH" }); +} +function owner(code = 0, afterAdmission?: () => void) { + const calls: { argv: readonly string[]; options: shell.RunOptions }[] = []; + spies.push( + spyOn(shell, "run").mockImplementation(async (argv, options = {}) => { + options.beforeSpawn?.(); + afterAdmission?.(); + calls.push({ argv: [...argv], options }); + await options.onSpawn?.({ + pid: GROUP, + ownsProcessGroup: true, + processGroupId: GROUP, + }); + return code; + }) + ); + return calls; +} +test("known child return plus ESRCH issues only a one-use completion for the original callback", async () => { + const calls = owner(17), + probes: number[] = []; + spies.push( + spyOn(process, "kill").mockImplementation((pid, signal) => { + probes.push(pid); + expect(signal).toBe(0); + return missing(); + }) + ); + const selected = input(), + deadline = Date.now() + 1000; + const outcome = await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline, + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.argv).toEqual(["docker", "container", "start", ID, OTHER]); + expect(calls[0]?.options).toMatchObject({ + cwd: selected.binding.projectRoot, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + expect(calls[0]?.options.forwardSignals).toBeUndefined(); + expect(probes).toEqual([-GROUP]); + for (const invalid of [17, {}, { ...outcome }]) { + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome: invalid, + input: selected, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); + } + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: { ...selected }, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "stop", + deadline, + }) + ).toThrow(/uncertain/); + expect( + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "start", + deadline, + }) + ).toBe(17); + expect(() => + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "start", + deadline, + }) + ).toThrow(/uncertain/); +}); +test("wrapper return does not prove peer absence; bounded observation accepts only later ESRCH", async () => { + owner(); + let reads = 0; + spies.push( + spyOn(process, "kill").mockImplementation(() => { + if (++reads < 3) { + return true; + } + return missing(); + }) + ); + const selected = input(), + deadline = Date.now() + 1000; + const outcome = await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "stop", + deadline, + }); + expect(reads).toBe(3); + expect( + consumeLegacyComposeRoutingCompletion({ + outcome, + input: selected, + operation: "stop", + deadline, + }) + ).toBe(0); +}); +for (const code of ["EPERM", "EIO"]) { + test(`group ${code} remains unknown and cannot issue a completion`, async () => { + owner(); + spies.push( + spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("private"), { code }); + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(), + operation: "stop", + deadline: Date.now() + 1000, + }) + ).rejects.toThrow(/uncertain/); + }); +} +test("retained peer at the captured deadline refuses without sending any signal", async () => { + const calls = owner(); + const probes: unknown[] = []; + spies.push( + spyOn(process, "kill").mockImplementation((pid, signal) => { + probes.push([pid, signal]); + return true; + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(), + operation: "stop", + deadline: Date.now() + 80, + }) + ).rejects.toThrow(/uncertain/); + expect(calls).toHaveLength(1); + expect(probes.length).toBeGreaterThan(0); + expect( + probes.every( + (value) => JSON.stringify(value) === JSON.stringify([-GROUP, 0]) + ) + ).toBe(true); +}); +test("cancellation during final source admission has zero child effects", async () => { + const calls = owner(), + controller = new AbortController(); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: input(async () => { + controller.abort(); + }), + operation: "start", + deadline: Date.now() + 1000, + signal: controller.signal, + }) + ).rejects.toThrow(/uncertain/); + expect(calls).toEqual([]); +}); +test("child IDs and working directory are captured before the final source await", async () => { + const selected = input(async () => { + Reflect.set(selected.binding, "projectRoot", "/foreign"); + Reflect.set(selected.binding, "containers", [{ id: "c".repeat(64) }]); + }); + const calls = owner(); + spies.push(spyOn(process, "kill").mockImplementation(missing)); + await runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline: Date.now() + 1000, + }); + expect(calls[0]?.argv).toEqual(["docker", "container", "start", ID, OTHER]); + expect(calls[0]?.options.cwd).toBe("/synthetic/retained-routing"); +}); +test("the synchronous spawn fence refuses a callback revoked after fresh admission", async () => { + const selected = input(); + let active = true; + let child = 0; + selected.assertActive = () => { + if (!active) { + throw new Error("revoked; values omitted"); + } + }; + spies.push( + spyOn(shell, "run").mockImplementation(async (_argv, options = {}) => { + await Promise.resolve(); + active = false; + options.beforeSpawn?.(); + child++; + return 0; + }) + ); + await expect( + runLegacyComposeRetainedRoutingOperation({ + input: selected, + operation: "start", + deadline: Date.now() + 1000, + }) + ).rejects.toThrow("revoked"); + expect(child).toBe(0); +}); diff --git a/tests/native-compose-adoption-routing-generation.test.ts b/tests/native-compose-adoption-routing-generation.test.ts new file mode 100644 index 000000000..cf25b52c7 --- /dev/null +++ b/tests/native-compose-adoption-routing-generation.test.ts @@ -0,0 +1,488 @@ +import { test as boundedTest, expect, spyOn } from "bun:test"; +import * as fs from "node:fs/promises"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; +import { runLegacyComposeRetainedRoutingOperation } from "../src/lib/native-compose-adoption-routing-execution.ts"; +import * as privateState from "../src/lib/native-compose-private-state.ts"; +import { + ROUTING_CANARY, + ROUTING_IDS, + retainedRoutingFixture, + retainedRoutingFixtureLifetime, +} from "./helpers/retained-routing-adoption.ts"; + +let h: Awaited>; +let fixtureActive = false; +let fixtureUncertain = false; +const test = (name: string, run: () => Promise) => + boundedTest( + name, + async () => { + if (fixtureActive || fixtureUncertain) { + fixtureUncertain = true; + throw new Error( + "Prior retained routing fixture lifetime is unknown; values omitted." + ); + } + fixtureActive = true; + const lifetime = retainedRoutingFixtureLifetime(Date.now() + 30_000); + let issued = false; + let failed = false; + let failure: unknown; + try { + h = await retainedRoutingFixture(lifetime); + issued = true; + await run(); + } catch (error: unknown) { + failed = true; + failure = error; + } finally { + if (!lifetime.canRestore()) { + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = new Error( + "Retained routing fixture lifetime is unknown; values omitted." + ); + } + } else if (issued) { + try { + await h.cleanup(); + fixtureActive = false; + } catch (error: unknown) { + lifetime.retain(); + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = error; + } + } + } else { + // Setup did not issue a complete fixture; do not reuse its global context. + lifetime.retain(); + fixtureUncertain = true; + if (!failed) { + failed = true; + failure = new Error( + "Retained routing fixture setup is incomplete; values omitted." + ); + } + } + } + if (failed) { + throw failure; + } + }, + 30_000 + ); +async function prepare() { + const store = await h.store(); + try { + return { store, generation: await store.prepare({ binary: h.compiler }) }; + } catch (error: unknown) { + await store.close(); + throw error; + } +} +async function red(value: Promise) { + try { + await value; + throw new Error("unexpected synthetic success"); + } catch (error: unknown) { + expect(String(error)).toMatch(/values omitted/i); + expect(String(error)).not.toContain(ROUTING_CANARY); + expect(String(error)).not.toContain(h.root); + } +} +test("v14 keeps original resources and data through stop/publication/up/down/up/rollback and saved open", async () => { + const { store, generation } = await prepare(); + try { + expect(generation.report.adoption_generation_version).toBe(14); + expect((await h.receipt()).routingHandoff).toBe("held"); + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(0); + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + for (const operation of ["start", "stop", "start", "stop"] as const) { + expect(await h.operation(store, active, operation)).toBe(0); + } + await store.withLease({ + generation: active, + run: async (input) => { + expect(input.retainedRouting).toBe(true); + expect(input.routingResolution).toEqual(h.resolution); + expect(JSON.stringify(input)).not.toContain("original.hack"); + }, + }); + await store.rollback(); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + expect((await h.receipt()).publication?.phase).toBe("rolled-back"); + expect((await h.receipt()).routingHandoff).toBe("releasing"); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + expect( + h.effects.every( + (args) => + args.length === 5 && + args[3] === ROUTING_IDS.db && + args[4] === ROUTING_IDS.web + ) + ).toBe(true); + expect( + h.commands.some( + (args) => + args.includes("pull") || + args.includes("build") || + args.includes("create") || + args.includes("rm") + ) + ).toBe(false); + } finally { + await store.close(); + } +}); +test("numeric callback cannot settle a prospective child; explicit stop containment retains original uncertainty", async () => { + const { store, generation } = await prepare(); + try { + await red( + h.operation(store, generation, "stop", { + preparation: true, + numeric: true, + }) + ); + const before = await readFile(h.receiptPath, "utf8"); + expect((await h.receipt()).routingOperation?.disposition).toBe( + "prospective" + ); + expect(h.effects).toEqual([]); + const pending = await store.loadPrepared({ recoverOperation: true }); + if (!pending) { + throw new Error("Synthetic prepared generation missing"); + } + await red( + h.operation(store, pending, "stop", { preparation: true, recover: true }) + ); + expect(await readFile(h.receiptPath, "utf8")).toBe(before); + expect(h.effects).toHaveLength(1); + await red(store.publish({ generation: pending, binary: h.compiler })); + } finally { + await store.close(); + } +}); +test("a source read resumed after callback return cannot spawn an original-ID child", async () => { + const { store, generation } = await prepare(); + const entered = Promise.withResolvers(); + const released = Promise.withResolvers(); + let armed = false; + let paused = false; + let late: Promise | undefined; + let restoreRead: (() => void) | undefined; + try { + const originalRead = privateState.readPrivate; + const readSpy = spyOn(privateState, "readPrivate").mockImplementation( + async (...args) => { + const value = await originalRead(...args); + if (armed && !paused && args[0].includes("/generations/")) { + paused = true; + entered.resolve(); + await released.promise; + } + return value; + } + ); + restoreRead = () => readSpy.mockRestore(); + const deadline = Date.now() + 15_000; + await red( + store.withPreparationStop({ + generation, + binary: h.compiler, + deadline, + run: async (input) => { + armed = true; + late = h.track( + runLegacyComposeRetainedRoutingOperation({ + input, + operation: "stop", + deadline, + }) + ); + await entered.promise; + return 0; + }, + }) + ); + expect(paused).toBe(true); + expect((await h.receipt()).routingOperation?.disposition).toBe( + "prospective" + ); + const retained = await readFile(h.receiptPath, "utf8"); + released.resolve(); + if (!late) { + throw new Error("Synthetic delayed operation was not reached"); + } + await red(late); + expect(h.effects).toEqual([]); + expect(await readFile(h.receiptPath, "utf8")).toBe(retained); + } finally { + released.resolve(); + await late?.catch(() => undefined); + if (h.canRestore()) { + restoreRead?.(); + } + await store.close(); + } +}); +test("known nonzero child retains a settled journal and exact explicit stop recovery can clear it", async () => { + const { store, generation } = await prepare(); + try { + h.model.partial = true; + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(7); + expect((await h.receipt()).routingOperation).toMatchObject({ + disposition: "settled", + code: 7, + }); + const pending = await store.loadPrepared({ recoverOperation: true }); + if (!pending) { + throw new Error("Synthetic prepared generation missing"); + } + h.model.partial = false; + expect( + await h.operation(store, pending, "stop", { + preparation: true, + recover: true, + }) + ).toBe(0); + expect((await h.receipt()).pendingOperation).toBeNull(); + expect((await h.receipt()).routingOperation).toMatchObject({ + disposition: "settled", + code: 0, + }); + await store.publish({ generation: pending, binary: h.compiler }); + } finally { + await store.close(); + } +}); + +boundedTest( + "fixture teardown stays refused after an unfinished continuation later settles", + async () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() + 1000); + const pending = Promise.withResolvers(); + const work = lifetime.track(pending.promise); + expect(lifetime.canRestore()).toBe(false); + pending.resolve(); + await work; + expect(lifetime.canRestore()).toBe(false); + }, + 1000 +); +boundedTest( + "expired fixture lifetime never restores even without pending work", + () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() - 1); + expect(lifetime.canRestore()).toBe(false); + expect(lifetime.canRestore()).toBe(false); + }, + 1000 +); +boundedTest( + "known callback settlement permits fixture teardown before its unchanged deadline", + async () => { + const lifetime = retainedRoutingFixtureLifetime(Date.now() + 1000); + await lifetime.track(Promise.resolve()); + expect(lifetime.canRestore()).toBe(true); + }, + 1000 +); +test("foreign route writer and missing proxy reader refuse preparation before claims or effects", async () => { + for (const kind of ["foreign", "proxy"]) { + const before = h.commands.length; + h.model.foreign = kind === "foreign"; + h.model.proxyAccess = kind !== "proxy"; + const store = await h.store(); + try { + await red(store.prepare({ binary: h.compiler })); + } finally { + await store.close(); + } + expect( + h.commands + .slice(before) + .some((args) => + kind === "foreign" + ? args[0] === "container" && + args[1] === "inspect" && + args[3]?.includes('"sites"') && + args.includes(ROUTING_IDS.foreign) + : args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" + ) + ).toBe(true); + expect(h.effects).toEqual([]); + } +}); +test("late original birth or upstream drift never clears pending or grants rollback", async () => { + const { store, generation } = await prepare(); + try { + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + h.hooks.afterEffect = async () => { + h.model.webBirth = "2026-02-01T01:02:03Z"; + }; + await red(h.operation(store, active, "start")); + expect((await h.receipt()).pendingOperation?.operation).toBe("start"); + await red(store.rollback()); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + expect( + await readFile(join(h.root, ".hack/hack.project.json"), "utf8") + ).not.toBe(""); + } finally { + await store.close(); + } +}); +test("same-byte receipt substitution during final routing admission cannot clear pending", async () => { + const { store, generation } = await prepare(); + const saved = `${h.receiptPath}.original`; + let substituted = false; + let finalSave = false; + let restoreRead: (() => void) | undefined; + try { + await store.publish({ generation, binary: h.compiler }); + const active = await store.loadActive(); + if (!active) { + throw new Error("Synthetic active generation missing"); + } + const originalRead = privateState.readPrivate; + const readSpy = spyOn(privateState, "readPrivate").mockImplementation( + async (...args) => { + const value = await originalRead(...args); + if (args[0].endsWith(".receipt")) { + const staged: unknown = JSON.parse(value.text); + if ( + isRecord(staged) && + staged.pendingOperation === null && + isRecord(staged.routingOperation) && + staged.routingOperation.disposition === "settled" + ) { + finalSave = true; + } + } + return value; + } + ); + restoreRead = () => readSpy.mockRestore(); + h.hooks.afterProbe = async (args) => { + if (!(finalSave && !substituted && args[0] === "exec")) { + return; + } + substituted = true; + const bytes = await readFile(h.receiptPath); + await fs.rename(h.receiptPath, saved); + await fs.writeFile(h.receiptPath, bytes, { mode: 0o600, flag: "wx" }); + }; + await red(h.operation(store, active, "start")); + expect(substituted).toBe(true); + expect((await h.receipt()).pendingOperation?.operation).toBe("start"); + expect(await readFile(h.receiptPath)).toEqual(await readFile(saved)); + expect(h.effects).toHaveLength(1); + } finally { + if (h.canRestore()) { + restoreRead?.(); + h.hooks.afterProbe = undefined; + if (substituted) { + await fs.unlink(h.receiptPath); + await fs.rename(saved, h.receiptPath); + } + } + await store.close(); + } +}); +test("restored routing proof refuses same-byte original inode replacement before claim handoff", async () => { + const { store, generation } = await prepare(); + try { + await store.publish({ generation, binary: h.compiler }); + const configPath = join(h.root, ".hack/hack.config.json"); + const nativePath = join(h.root, ".hack/hack.project.json"); + let replaced = false; + h.hooks.afterProbe = async (args) => { + if ( + !replaced && + args[0] === "info" && + (await h.receipt()).publication?.phase === "rolling-back" && + !(await Bun.file(nativePath).exists()) && + (await Bun.file(configPath).exists()) + ) { + const bytes = await readFile(configPath); + await fs.rename(configPath, join(h.outer, "original-config-test")); + await fs.writeFile(configPath, bytes, { mode: 0o600 }); + replaced = true; + } + }; + await red(store.rollback()); + expect(replaced).toBe(true); + expect((await h.receipt()).publication?.phase).toBe("rolling-back"); + expect((await h.receipt()).routingHandoff).toBe("held"); + expect(h.effects).toEqual([]); + } finally { + await store.close(); + } +}); +test("interrupted claim handoff retries from durable releasing state after exact source restore", async () => { + const { store, generation } = await prepare(); + let restoreUnlink: (() => void) | undefined; + try { + await store.publish({ generation, binary: h.compiler }); + const originalUnlink = fs.unlink; + let interrupted = false; + const unlinkSpy = spyOn(fs, "unlink").mockImplementation(async (path) => { + await originalUnlink(path); + if ( + !interrupted && + String(path).includes("/compose-routing/") && + String(path).includes("/claims/") + ) { + interrupted = true; + throw new Error("Synthetic handoff interruption; values omitted."); + } + }); + restoreUnlink = () => unlinkSpy.mockRestore(); + await red(store.rollback()); + expect(interrupted).toBe(true); + expect((await h.receipt()).routingHandoff).toBe("releasing"); + expect((await h.receipt()).publication?.phase).toBe("rolling-back"); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect( + await readFile(join(h.root, ".hack/docker-compose.yml"), "utf8") + ).toBe(h.compose); + if (!h.canRestore()) { + throw new Error("Synthetic handoff lifetime is unknown; values omitted."); + } + restoreUnlink(); + restoreUnlink = undefined; + await store.repairPublication({ action: "rollback" }); + expect((await h.receipt()).publication?.phase).toBe("rolled-back"); + expect(h.effects).toEqual([]); + expect(h.model.sqlRow).toBe(ROUTING_CANARY); + } finally { + if (h.canRestore()) { + restoreUnlink?.(); + } + await store.close(); + } +}); diff --git a/tests/native-compose-adoption-routing-receipt.test.ts b/tests/native-compose-adoption-routing-receipt.test.ts new file mode 100644 index 000000000..ee0752eb4 --- /dev/null +++ b/tests/native-compose-adoption-routing-receipt.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import { parseLegacyComposeAdoptionReceipt } from "../src/lib/native-compose-adoption-receipt.ts"; + +const checkout = { + root: { dev: 1, ino: 2 }, + project: { dev: 1, ino: 3 }, + git: { dev: 1, ino: 4 }, +}; +const generation = { + id: "a".repeat(32), + manifest: { dev: 1, ino: 5, hash: "b".repeat(64) }, +}; +const reference = { + attemptId: "c".repeat(32), + generationIdentity: generation.id, + intent: { dev: 1, ino: 6, hash: "d".repeat(64) }, + reservation: { dev: 1, ino: 7, hash: "e".repeat(64) }, +}; +function fixture() { + return { + adoption_receipt_version: 14, + kind: "legacy-compose-adopted", + checkout, + prepared: generation, + publication: { + generation, + phase: "active", + native: { dev: 1, ino: 8, hash: "f".repeat(64) }, + }, + pendingOperation: { + generation, + operation: "start", + services: ["db", "web"], + }, + routingOperation: { + generation, + token: "1".repeat(32), + reference, + disposition: "prospective" as const, + code: null, + }, + routingHandoff: "held", + }; +} +test("required v14 child disposition binds the exact generation, token and route reference", () => { + const value = fixture(), + parsed = parseLegacyComposeAdoptionReceipt(value, checkout); + expect(parsed.adoption_receipt_version).toBe(14); + expect(parsed.routingOperation).toEqual(value.routingOperation); + expect(parsed.routingHandoff).toBe("held"); + expect(Object.isFrozen(parsed.routingOperation?.reference)).toBe(true); + expect( + parseLegacyComposeAdoptionReceipt( + { + ...value, + routingOperation: { + ...value.routingOperation, + disposition: "settled", + code: 17, + }, + }, + checkout + ).routingOperation?.code + ).toBe(17); +}); +for (const change of [ + { routingOperation: undefined }, + { routingHandoff: undefined }, + { prepared: null }, + { pendingOperation: null }, + { routingOperation: null }, + { routingHandoff: "releasing" }, +]) { + test(`v14 cannot discard required pending authority ${Object.keys(change).join()}`, () => { + expect(() => + parseLegacyComposeAdoptionReceipt({ ...fixture(), ...change }, checkout) + ).toThrow(); + }); +} +test("foreign reference, malformed disposition and uncertain code cannot become settlement", () => { + const value = fixture(); + for (const change of [ + { reference: { ...reference, generationIdentity: "0".repeat(32) } }, + { reference: { ...reference, reservation: undefined } }, + { token: "private" }, + { token: 1 }, + { code: 0 }, + { disposition: "settled", code: null }, + { disposition: "settled", code: 256 }, + { disposition: "settled", code: -1 }, + { disposition: "other" }, + ]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { + ...value, + routingOperation: { ...value.routingOperation, ...change }, + }, + checkout + ) + ).toThrow(); + } +}); +test("rollback handoff is allowed only after pending clearance in rollback phases", () => { + const value = fixture(); + for (const phase of ["rolling-back", "rolled-back"]) { + const parsed = parseLegacyComposeAdoptionReceipt( + { + ...value, + pendingOperation: null, + routingOperation: { + ...value.routingOperation, + disposition: "settled", + code: 0, + }, + routingHandoff: "releasing", + publication: { ...value.publication, phase }, + }, + checkout + ); + expect(parsed.routingHandoff).toBe("releasing"); + } + for (const phase of ["switching", "active"]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { + ...value, + pendingOperation: null, + routingOperation: null, + routingHandoff: "releasing", + publication: { ...value.publication, phase }, + }, + checkout + ) + ).toThrow(); + } +}); +test("older receipts do not silently ignore required v14 routing state", () => { + const value = fixture(); + for (const version of [1, 2, 3, 4, 5, 6, 7, 9, 10, 11]) { + expect(() => + parseLegacyComposeAdoptionReceipt( + { ...value, adoption_receipt_version: version }, + checkout + ) + ).toThrow(); + } + const { + routingOperation: _operation, + routingHandoff: _handoff, + ...older + } = value; + expect( + parseLegacyComposeAdoptionReceipt( + { ...older, adoption_receipt_version: 1 }, + checkout + ).adoption_receipt_version + ).toBe(1); +}); diff --git a/tests/native-compose-adoption-routing-resolution.test.ts b/tests/native-compose-adoption-routing-resolution.test.ts new file mode 100644 index 000000000..1a3e48604 --- /dev/null +++ b/tests/native-compose-adoption-routing-resolution.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "bun:test"; +import { legacyComposeRoutingResolutionMatches } from "../src/lib/native-compose-adoption-routing-resolution.ts"; +import { resolveNativeComposeOpenOrigin } from "../src/lib/native-compose-open.ts"; +import { mapLegacyComposeRouting } from "../src/lib/native-config-import-routing.ts"; +import type { NativeRoutingResolution } from "../src/lib/native-routing-plan-protocol.ts"; + +function fixture() { + const routing = mapLegacyComposeRouting({ + config: { + name: "fixture", + dev_host: "original.hack.local", + oauth: { enabled: true }, + open: { prefer: "alias" }, + }, + compose: { + name: "fixture", + networks: { "hack-dev": { external: true } }, + services: { + web: { + image: "static:1", + networks: ["hack-dev", "default"], + labels: { + caddy: "original.hack.local,original.hack.gy", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + }, + }, + }, + })?.intent; + if (!routing) { + throw new Error("Synthetic route missing"); + } + const resolution: NativeRoutingResolution = { + domain: "local.test", + domain_origin: "checkout_local", + project_origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + oauth_alias: "oauth", + open_preference: "alias", + open_preference_origin: "primary_local", + open_origin: "https://original.hack.gy", + routes: { + web: { + service: "web", + port: 3000, + protocol: "http", + origin: "https://original.hack.local", + aliases: { oauth: "https://original.hack.gy" }, + }, + }, + }; + return { routing, resolution }; +} +test("typed-local domain precedence may retain exact served origins and saved open preference", () => { + const selected = fixture(); + expect(legacyComposeRoutingResolutionMatches(selected)).toBe(true); + expect( + resolveNativeComposeOpenOrigin({ resolution: selected.resolution }) + ).toBe("https://original.hack.gy"); + expect( + resolveNativeComposeOpenOrigin({ + resolution: selected.resolution, + prefer: "dev", + }) + ).toBe("https://original.hack.local"); + expect( + resolveNativeComposeOpenOrigin({ + resolution: selected.resolution, + target: "web", + }) + ).toBe("https://original.hack.gy"); +}); +for (const change of [ + { branch: "other" }, + { project_origin: "https://renamed.test" }, + { open_origin: "https://renamed.test" }, + { aliases: {} }, + { oauth_alias: null }, + { routes: {} }, +]) { + test(`effective routing refuses changed served selection ${JSON.stringify(change)}`, () => { + const { routing, resolution } = fixture(); + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, ...change }, + }) + ).toBe(false); + }); +} +test("route target, port, transport and aliases cannot drift behind matching apex metadata", () => { + const { routing, resolution } = fixture(), + web = resolution.routes.web; + if (!web) { + throw new Error("Synthetic route missing"); + } + for (const change of [ + { service: "other" }, + { port: 3001 }, + { protocol: "https" as const }, + { origin: "https://renamed.test" }, + { aliases: {} }, + ]) { + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, routes: { web: { ...web, ...change } } }, + }) + ).toBe(false); + } +}); diff --git a/tests/native-compose-route-claims.test.ts b/tests/native-compose-route-claims.test.ts index f2fde4194..3ed37da2d 100644 --- a/tests/native-compose-route-claims.test.ts +++ b/tests/native-compose-route-claims.test.ts @@ -180,6 +180,111 @@ test("foreign same-origin collision refuses before caller effect and preserves o expect(await winner.reopen(attempt.reference)).toEqual(attempt); }); +test("retained stopped recovery marks only exact referenced uncertainty and keeps its hostname claimed", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + const original = await Bun.file(claimPath(root)).text(); + await result.markEffectsPossible(attempt); + await result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async (selection) => { + expect(selection).toEqual({ + hostnames: [HOST], + binding: BINDING, + owner: OWNER, + }); + }, + }); + expect((await result.reopen(attempt.reference)).phase).toBe("stopped"); + expect(await Bun.file(claimPath(root)).text()).toBe(original); + await result.assertHeld(attempt.reference); + await expect(acquire(await store(root, OTHER))).rejects.toMatchObject({ + code: "E_NATIVE_COMPOSE_ROUTE_CONFLICT", + }); + await expect( + result.complete({ attempt, assertTransition: async () => {} }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); +}); +test("retained handoff refuses unknown children and failed restored-source proof without releasing a claim", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + await result.markEffectsPossible(attempt); + let callbacks = 0; + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + callbacks++; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); + expect(callbacks).toBe(0); + const bytes = await Bun.file(claimPath(root)).text(); + await expect( + result.recoverRetainedStopped({ + references: [], + assertStopped: async () => {}, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_ROUTE_RETAINED" }); + await expect( + result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async () => { + throw new Error("synthetic stopped proof refused"); + }, + }) + ).rejects.toThrow(); + expect((await result.reopen(attempt.reference)).phase).toBe("armed"); + expect(await Bun.file(claimPath(root)).text()).toBe(bytes); + await result.recoverRetainedStopped({ + references: [attempt.reference], + assertStopped: async () => {}, + }); + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + throw new Error("synthetic restored source refused"); + }, + }) + ).rejects.toThrow(); + expect(await Bun.file(claimPath(root)).text()).toBe(bytes); +}); +test("retained rollback handoff is exact, revalidates claim incarnation and is resumable after removal", async () => { + const root = await fixture(), + result = await store(root), + attempt = await acquire(result); + await complete(result, attempt); + const bytes = await fs.readFile(claimPath(root)); + await expect( + result.releaseRetained({ + assertStoppedAndRestored: async () => { + await fs.rename(claimPath(root), `${claimPath(root)}.original`); + await fs.writeFile(claimPath(root), bytes, { mode: 0o600 }); + }, + }) + ).rejects.toThrow(); + await fs.unlink(claimPath(root)); + await fs.rename(`${claimPath(root)}.original`, claimPath(root)); + const callbacks: (readonly string[])[] = []; + await result.releaseRetained({ + assertStoppedAndRestored: async (selection) => { + callbacks.push(selection.hostnames); + }, + }); + expect(await Bun.file(claimPath(root)).exists()).toBe(false); + await result.releaseRetained({ + assertStoppedAndRestored: async (selection) => { + callbacks.push(selection.hostnames); + }, + }); + expect(callbacks).toEqual([[HOST], []]); + await expect(result.assertHeld(attempt.reference)).rejects.toThrow(); + expect((await result.reopen(attempt.reference)).reference).toEqual( + attempt.reference + ); +}); + test("real simultaneous independent processes have exactly one hostname winner and effect", async () => { const root = await fixture(); const module = new URL( From 1984eae6c2e356147e669c573480fcbff085e0d4 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 01:51:40 -0400 Subject: [PATCH 3/9] test(adoption): verify retained routing lifecycle with isolated ingress --- docs/reference/native-compose-adoption.md | 17 + tests/e2e/run.ts | 2 + .../native-compose-adoption-job-worktrees.ts | 2 +- .../native-compose-adoption-routing-inputs.ts | 132 ++++++ ...tive-compose-adoption-routing-worktrees.ts | 388 +++++++++++++++++ .../native-compose-adoption-worktrees.ts | 144 ++++++- tests/e2e/scenarios/native-config-build.ts | 2 +- tests/e2e/scenarios/native-config-routing.ts | 359 ++-------------- .../native-routing-fixture-ingress.ts | 397 ++++++++++++++++++ ...e-compose-adoption-routing-fixture.test.ts | 334 +++++++++++++++ tests/native-routing-fixture.test.ts | 2 +- 11 files changed, 1427 insertions(+), 352 deletions(-) create mode 100644 tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts create mode 100644 tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts create mode 100644 tests/e2e/scenarios/native-routing-fixture-ingress.ts create mode 100644 tests/native-compose-adoption-routing-fixture.test.ts diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 8b03b2ce7..6a1ee7097 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -523,3 +523,20 @@ readiness, source binds, files, branch overrides and custom bridges remain outsi this initial routing family. The owner and private-store model do not prove live TLS, SQL fidelity, OAuth login or application acceptance. The maintained isolated ingress lifecycle fixture remains required; global DNS and trust are unchanged. + +The explicitly selected `native-compose-adoption-routing-worktrees` scenario +adds an HTTP service to the original two-worktree PostgreSQL fixture. It requires +both literal HTTPS origins and the existing OAuth alias to serve each checkout's +marker, with no published proxy ports. Saved `open` must select alpha's +checkout-local alias over the authored and primary-local dev preference. It also +checks original SQL/IDs/births, a known partial-stop journal and explicit +recovery, two retained up/down cycles, exact source rollback and claim handoff. +Unknown child or cleanup disposition retains the fixture. The temporary Caddy +owner is shared with `native-config-routing`; stopped user proxies and the +existing `hack-dev` network stay intact. This scenario does not perform OAuth +login, change host DNS/trust, or qualify combined unsupported families. + +With current compiled artifacts, cached fixture images and an exclusively +coordinated Docker lane, select it using the same prerequisites above and +`--only=native-compose-adoption-routing-worktrees`. Its source and pure controls +are separate from a completed live TLS/SQL run. diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 8d72de307..c22879c99 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -15,6 +15,7 @@ import { initScenario } from "./scenarios/init.ts"; import { lifecycleHostProcessScenario } from "./scenarios/lifecycle-host-process.ts"; import { lifecycleSessionRecoveryScenario } from "./scenarios/lifecycle-session-recovery.ts"; import { nativeComposeAdoptionJobWorktreesScenario } from "./scenarios/native-compose-adoption-job-worktrees.ts"; +import { nativeComposeAdoptionRoutingWorktreesScenario } from "./scenarios/native-compose-adoption-routing-worktrees.ts"; import { nativeComposeAdoptionBuildWorktreesScenario, nativeComposeAdoptionDependencyWorktreesScenario, @@ -97,6 +98,7 @@ const ALL_SCENARIOS: readonly Scenario[] = [ nativeComposeAdoptionDependencyWorktreesScenario, nativeComposeAdoptionBuildWorktreesScenario, nativeComposeAdoptionJobWorktreesScenario, + nativeComposeAdoptionRoutingWorktreesScenario, lifecycleHostProcessScenario, worktreeParallelUpScenario, ]; diff --git a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts index 0a70476d8..ec0fd4181 100644 --- a/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-job-worktrees.ts @@ -22,7 +22,7 @@ import { createAdoptionFixtureProbe, waitForAdoptionFixtureSql, } from "./native-compose-adoption-worktrees.ts"; -import { proxyHasNoPublishedPorts } from "./native-config-routing.ts"; +import { proxyHasNoPublishedPorts } from "./native-routing-fixture-ingress.ts"; const TIMEOUT = 180_000; const FULL_ID = /^[a-f0-9]{64}$/; diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts new file mode 100644 index 000000000..5d41dddaf --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts @@ -0,0 +1,132 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { readPrivate } from "../../../src/lib/native-compose-private-state.ts"; +import { resolveProjectOauthAliasHost } from "../../../src/lib/project.ts"; + +export type RetainedRoutingFixtureSelection = { + readonly image: string; + readonly devHost: string; + readonly aliasHost: string; + readonly prefer: "alias" | "dev"; + readonly marker: string; +}; +const IMAGE = /^sha256:[a-f0-9]{64}$/; +const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/; +const APP = + "Bun.serve({hostname:'0.0.0.0',port:3000,fetch(){return new Response(process.env.RETAINED_ROUTE_MARKER)}})"; +function refuse(): never { + throw new Error("Retained routing fixture input refused; values omitted."); +} + +/** Explicit legacy hosts are authored before the original Compose bootstrap. */ +export function retainedRoutingFixtureSelection(opts: { + readonly image: string; + readonly name: string; + readonly marker: string; + readonly prefer: "alias" | "dev"; +}): RetainedRoutingFixtureSelection { + if (!(IMAGE.test(opts.image) && NAME.test(opts.name) && opts.marker)) { + return refuse(); + } + const devHost = `${opts.name}.hack.local`; + const aliasHost = resolveProjectOauthAliasHost({ + devHost, + oauth: { enabled: true }, + }); + if (!aliasHost || aliasHost === devHost) { + return refuse(); + } + return Object.freeze({ + image: opts.image, + devHost, + aliasHost, + prefer: opts.prefer, + marker: opts.marker, + }); +} + +export function retainedRoutingFixtureConfig( + selection: RetainedRoutingFixtureSelection +) { + return { + dev_host: selection.devHost, + oauth: { enabled: true }, + // Alpha's authored dev preference must lose to its checkout-local alias. + open: { prefer: "dev" as const }, + }; +} +export function retainedRoutingFixtureService( + selection: RetainedRoutingFixtureSelection +) { + return { + image: selection.image, + pull_policy: "never", + entrypoint: ["bun", "-e"], + command: [APP], + environment: { RETAINED_ROUTE_MARKER: selection.marker }, + networks: ["default", "hack-dev"], + labels: { + caddy: `${selection.devHost},${selection.aliasHost}`, + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + caddy_ingress_network: "hack-dev", + }, + }; +} +export function retainedRoutingFixtureOrigins( + selection: RetainedRoutingFixtureSelection +): readonly string[] { + return [`https://${selection.devHost}`, `https://${selection.aliasHost}`]; +} + +/** The checkout layer wins without changing either already served origin. */ +export async function prepareRetainedRoutingFixtureLocals(opts: { + readonly primary: { readonly root: string }; + readonly instances: readonly { + readonly root: string; + readonly routing?: RetainedRoutingFixtureSelection; + }[]; +}) { + await writeFile( + join(opts.primary.root, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + routes: { domain: "primary-shadowed.test" }, + open: { prefer: "dev" }, + }), + { mode: 0o600 } + ); + for (const instance of opts.instances) { + if (!instance.routing) { + return refuse(); + } + await writeFile( + join(instance.root, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + routes: { domain: "checkout-selected.test" }, + open: { prefer: instance.routing.prefer }, + }), + { mode: 0o600 } + ); + } +} + +/** Private exact sidecar identities and bytes; no digests enter scenario output. */ +export async function retainedRoutingFixtureLocalSnapshot(opts: { + readonly primary: { readonly root: string }; + readonly instance: { readonly root: string }; +}) { + const rows: unknown[] = []; + for (const checkout of [opts.primary, opts.instance]) { + const path = join(checkout.root, ".hack/hack.local.json"); + const { info, text } = await readPrivate(path, 4096); + rows.push({ + dev: info.dev, + ino: info.ino, + mode: info.mode, + hash: new Bun.CryptoHasher("sha256").update(text).digest("hex"), + }); + } + return JSON.stringify(rows); +} diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts new file mode 100644 index 000000000..b72f209e4 --- /dev/null +++ b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts @@ -0,0 +1,388 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { readPrivate } from "../../../src/lib/native-compose-private-state.ts"; +import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; +import type { CliResult, Scenario } from "../harness.ts"; +import { + retainedRoutingFixtureLocalSnapshot, + retainedRoutingFixtureOrigins, +} from "./native-compose-adoption-routing-inputs.ts"; +import { + bootstrapOriginal, + cleanupOwnedAdoptionFixture, + createFixtureRuntime, + prepareFixtureInputs, + runWithFixtureCleanup, +} from "./native-compose-adoption-worktrees.ts"; +import { nativeRoutedDownClaimSnapshot } from "./native-config-routed-down-hooks.ts"; +import { prepareNativeRoutingFixtureIngress } from "./native-routing-fixture-ingress.ts"; + +type Runtime = ReturnType; +type Instance = Runtime["first"]; +type Ingress = Awaited>; +const SERVICES = ["db", "web", "worker"]; +function refuse(): never { + throw new Error("Retained routing lifecycle check refused; values omitted."); +} +function passed(result: CliResult) { + if (result.timedOut || result.exitCode !== 0) { + return refuse(); + } + return result; +} +function object(text: string) { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return refuse(); + } + if (!isRecord(value)) { + return refuse(); + } + return value; +} +function receiptPath(instance: Instance) { + return join( + instance.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); +} +async function receipt(instance: Instance) { + const value = object( + (await readPrivate(receiptPath(instance), 128 * 1024)).text + ); + if (!value || value.adoption_receipt_version !== 14) { + return refuse(); + } + return value; +} +function originalIds(h: Runtime, instance: Instance): readonly string[] { + return SERVICES.map((service) => h.container(instance, service)); +} + +/** Closed forwarding control. It performs exactly one known partial stop, then + * the real CLI's routing process owner records the known nonzero disposition. */ +export function retainedRoutingPartialStopScript(opts: { + readonly engine: string; + readonly engineId: string; + readonly receipt: string; + readonly ids: readonly string[]; + readonly stopId: string; + readonly marker: string; +}): string { + if ( + opts.ids.length !== 3 || + new Set(opts.ids).size !== 3 || + !opts.ids.includes(opts.stopId) || + opts.ids.some((id) => !/^[a-f0-9]{64}$/.test(id)) + ) { + return refuse(); + } + return `#!${process.execPath} +import { readPrivate } from ${JSON.stringify(new URL("../../../src/lib/native-compose-private-state.ts", import.meta.url).href)}; +import { writeFile } from 'node:fs/promises'; +const args=process.argv.slice(2), engine=${JSON.stringify(opts.engine)}; +if(args[0]==='container' && args[1]==='stop') { + if(JSON.stringify(args)!==JSON.stringify(['container','stop',...${JSON.stringify(opts.ids)}])) process.exit(99); + let value;try{value=JSON.parse((await readPrivate(${JSON.stringify(opts.receipt)},131072)).text)}catch{process.exit(98)} + if(value.adoption_receipt_version!==14 || value.pendingOperation?.operation!=='stop' || JSON.stringify([...value.pendingOperation.services].sort())!==JSON.stringify(${JSON.stringify(SERVICES)}) || value.routingOperation?.disposition!=='prospective' || value.routingOperation?.code!==null || value.routingHandoff!=='held') process.exit(98); + const observed=Bun.spawn([engine,'info','--format','{{json .ID}}'],{stdin:'ignore',stdout:'pipe',stderr:'ignore'}); + const observedText=await new Response(observed.stdout).text(); + if(await observed.exited!==0 || observedText.trim()!==${JSON.stringify(opts.engineId)}) process.exit(97); + const child=Bun.spawn([engine,'container','stop',${JSON.stringify(opts.stopId)}],{stdin:'ignore',stdout:'ignore',stderr:'ignore'}); + if(await child.exited!==0) process.exit(96); + await writeFile(${JSON.stringify(opts.marker)},'known-routing-partial-stop',{flag:'wx',mode:0o600}); + process.exit(71); +} +const child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr:'inherit'});process.exit(await child.exited); +`; +} + +async function partialStop(h: Runtime) { + const root = join(h.ctx.tempRoot, "retained-routing-partial-stop"); + await mkdir(root, { mode: 0o700 }); + const marker = join(root, "known-stop"); + const shim = join(root, "docker"); + await writeFile( + shim, + retainedRoutingPartialStopScript({ + engine: h.engine, + engineId: h.engineId, + receipt: receiptPath(h.first), + ids: originalIds(h, h.first), + stopId: h.container(h.first, "db"), + marker, + }), + { mode: 0o700 } + ); + await chmod(shim, 0o700); + const result = await h.cli(h.first, ["config", "adopt", "--stop", "--json"], { + PATH: `${root}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }); + if ( + result.timedOut || + result.exitCode !== 71 || + (await readPrivate(marker, 128)).text !== "known-routing-partial-stop" + ) { + return refuse(); + } + const saved = await receipt(h.first); + if ( + !isRecord(saved.pendingOperation) || + saved.pendingOperation.operation !== "stop" || + !isRecord(saved.routingOperation) || + saved.routingOperation.disposition !== "settled" || + saved.routingOperation.code !== 71 || + saved.routingHandoff !== "held" + ) { + return refuse(); + } + const pendingBytes = (await readPrivate(receiptPath(h.first), 128 * 1024)) + .text; + const states = async () => + JSON.stringify( + await Promise.all( + originalIds(h, h.first).map(async (id) => ({ + id, + running: await h.probe([ + "container", + "inspect", + "--format", + "{{.State.Running}}", + id, + ]), + })) + ) + ); + const before = await states(); + const blocked = await h.cli(h.first, ["up", "--detach", "--json"]); + if ( + blocked.timedOut || + blocked.exitCode !== 1 || + !blocked.combined.includes("E_CONFIG_INVALID") || + (await readPrivate(receiptPath(h.first), 128 * 1024)).text !== + pendingBytes || + (await states()) !== before + ) { + return refuse(); + } + passed( + await h.cli(h.first, ["config", "adopt", "--recover", "--stop", "--json"]) + ); + await h.assertStopped(h.first); +} + +async function savedOpen(h: Runtime, instance: Instance) { + const selected = instance.routing; + if (!selected) { + return refuse(); + } + const automatic = + selected.prefer === "alias" ? selected.aliasHost : selected.devHost; + if ( + object(passed(await h.cli(instance, ["open", "--json"])).stdout).url !== + `https://${automatic}` || + object( + passed(await h.cli(instance, ["open", "--prefer", "dev", "--json"])) + .stdout + ).url !== `https://${selected.devHost}` + ) { + return refuse(); + } + // Alpha's authored and primary-local dev selections differ from its saved + // checkout-local alias, so this public open result proves that precedence. +} +async function routes(ingress: Ingress, instance: Instance) { + if (!instance.routing) { + return refuse(); + } + for (const origin of retainedRoutingFixtureOrigins(instance.routing)) { + await ingress.tls(origin, instance.routing.marker); + } + await ingress.tls(`https://${ingress.canaryHost}`, ingress.canaryMarker); + await ingress.preservedUnchanged(); +} +async function absent(ingress: Ingress, instance: Instance) { + if (!instance.routing) { + return refuse(); + } + const hosts = retainedRoutingFixtureOrigins(instance.routing).map( + (origin) => new URL(origin).hostname + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const matched = nativeComposeProxyRoutesMatch({ + servers: await ingress.admin(), + expected: [], + absentHostnames: hosts, + }); + if (matched && Date.now() < deadline) { + return; + } + await Bun.sleep(250); + } + return refuse(); +} +async function originalBaseline( + h: Runtime, + ingress: Ingress, + instance: Instance, + source = true +) { + await h.waitReady(instance); + await h.check(instance, source); + await routes(ingress, instance); +} +async function roundTrip( + h: Runtime, + ingress: Ingress, + instance: Instance, + sibling: Instance, + prepared: boolean +) { + if (!prepared) { + passed(await h.cli(instance, ["config", "adopt", "--stop", "--json"])); + await h.assertStopped(instance); + } + await absent(ingress, instance); + await originalBaseline(h, ingress, sibling); + for (let index = 0; index < 2; index++) { + passed(await h.cli(instance, ["up", "--detach", "--json"])); + await originalBaseline(h, ingress, instance, false); + await savedOpen(h, instance); + if ( + passed( + await h.cli(instance, [ + "exec", + "db", + "--", + "psql", + "-U", + "postgres", + "-d", + "fixture", + "-At", + "-c", + "SELECT value FROM marker WHERE id=1", + ]) + ).stdout.trim() !== instance.marker + ) { + return refuse(); + } + await originalBaseline(h, ingress, sibling); + passed(await h.cli(instance, ["down", "--json"])); + await h.assertStopped(instance); + await absent(ingress, instance); + await originalBaseline(h, ingress, sibling); + } + passed(await h.cli(instance, ["config", "adopt", "--rollback", "--json"])); + const rolledBack = await receipt(instance); + if ( + !isRecord(rolledBack.publication) || + rolledBack.publication.phase !== "rolled-back" + ) { + return refuse(); + } + await h.effect(["container", "start", ...originalIds(h, instance)]); + await originalBaseline(h, ingress, instance); + await originalBaseline(h, ingress, sibling); +} + +/** Same old instances keep literal browser origins, SQL, resources and local + * precedence. Only the existing temporary ingress owner may serve this fixture. */ +export const nativeComposeAdoptionRoutingWorktreesScenario: Scenario = { + name: "native-compose-adoption-routing-worktrees", + tier: "docker", + requiresExplicitSelection: true, + preserveFixtureOnFailure: true, + summary: + "original routed linked checkouts retain TLS aliases, saved open, SQL and recovery/rollback", + run: async (ctx) => { + const h = createFixtureRuntime( + await prepareFixtureInputs(ctx, { routing: true }) + ); + const ingress = await prepareNativeRoutingFixtureIngress({ + ctx, + docker: h.probe, + }); + if ( + h.first.routing?.image !== ingress.bunImage || + h.second.routing?.image !== ingress.bunImage + ) { + return refuse(); + } + const localPins = new Map(); + let claimsRoot: string | null = null; + let complete = false; + await runWithFixtureCleanup({ + run: async () => { + const binding = await ingress.start(); + claimsRoot = join( + ctx.hackHome, + "compose-routing", + createHash("sha256").update(binding.engineId).digest("hex"), + "claims" + ); + for (const instance of [h.first, h.second]) { + localPins.set( + instance, + await retainedRoutingFixtureLocalSnapshot({ + primary: h.primary, + instance, + }) + ); + await bootstrapOriginal(h, instance); + await originalBaseline(h, ingress, instance); + } + await partialStop(h); + await roundTrip(h, ingress, h.first, h.second, true); + await roundTrip(h, ingress, h.second, h.first, false); + for (const instance of [h.first, h.second]) { + if ( + (await retainedRoutingFixtureLocalSnapshot({ + primary: h.primary, + instance, + })) !== localPins.get(instance) + ) { + return refuse(); + } + } + if ((await nativeRoutedDownClaimSnapshot(claimsRoot)) !== "") { + return refuse(); + } + complete = true; + ctx.log( + "original literal TLS/OAuth origins, checkout-local saved open, SQL/IDs and known partial-stop recovery/rollback verified" + ); + }, + cleanup: async () => { + if ( + !(complete && claimsRoot) || + (await nativeRoutedDownClaimSnapshot(claimsRoot)) !== "" + ) { + return refuse(); + } + // The rolled-back original workloads are stopped and their exact routes + // disappear before any project cleanup or temporary ingress retirement. + for (const instance of [h.first, h.second]) { + await h.check(instance); + await h.effect(["container", "stop", ...originalIds(h, instance)]); + await h.assertStopped(instance); + await absent(ingress, instance); + } + await cleanupOwnedAdoptionFixture({ + ...h, + instances: [h.first, h.second], + }); + await ingress.cleanup(); + }, + secondaryFailure: () => + ctx.retainFixtures( + "Retained routing fixture ownership or cleanup is uncertain" + ), + }); + }, +}; diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index bdc73146d..e67a8cb77 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -72,6 +72,14 @@ import { prepareManagedAdoptionFixtureSources, } from "./native-compose-adoption-managed-inputs.ts"; +import { + prepareRetainedRoutingFixtureLocals, + type RetainedRoutingFixtureSelection, + retainedRoutingFixtureConfig, + retainedRoutingFixtureSelection, + retainedRoutingFixtureService, +} from "./native-compose-adoption-routing-inputs.ts"; + const TIMEOUT = 180_000; const PROJECT_LABEL = "com.docker.compose.project"; const NATIVE_PREFIX = "io.hack.native-config."; @@ -125,6 +133,7 @@ type Instance = { readonly ownedNetworks?: true; readonly dependency?: "service_started" | "service_healthy"; readonly basicBuild?: RetainedBuildFixtureMode; + readonly routing?: RetainedRoutingFixtureSelection; }; type Observation = { readonly id: string; @@ -471,23 +480,43 @@ function validateOwnedObservation(opts: { typeof row.id !== "string" || !ID.test(row.id) || typeof row.service !== "string" || - !["db", "worker"].includes(row.service) || + !( + opts.instance.routing ? ["db", "worker", "web"] : ["db", "worker"] + ).includes(row.service) || row.name !== `/${opts.instance.name}-${row.service}-1` || row.workingDir !== join(opts.instance.root, ".hack") || row.configFiles !== fixtureComposeFiles(opts.instance).join(",") || JSON.stringify(row.mounts) !== - JSON.stringify([ - { - type: "volume", - name: `${opts.instance.name}_data`, - target: "/var/lib/postgresql/data", - rw: row.service === "db", - }, - ]) + JSON.stringify( + row.service === "web" && opts.instance.routing + ? [] + : [ + { + type: "volume", + name: `${opts.instance.name}_data`, + target: "/var/lib/postgresql/data", + rw: row.service === "db", + }, + ] + ) ) { refused(); } - return { id: row.id, service: row.service }; + if ( + opts.instance.routing && + !( + typeof row.createdAt === "string" && + CREATED.test(row.createdAt) && + Number.isFinite(Date.parse(row.createdAt)) + ) + ) { + refused(); + } + return { + id: row.id, + service: row.service, + ...(opts.instance.routing ? { createdAt: String(row.createdAt) } : {}), + }; } if ( typeof row.createdAt !== "string" || @@ -559,6 +588,9 @@ async function writeLegacy(instance: Instance, image: string) { join(instance.root, ".hack/hack.config.json"), JSON.stringify({ name: instance.name, + ...(instance.routing + ? retainedRoutingFixtureConfig(instance.routing) + : {}), worktree: { auto_branch: false, inherit_local: true }, ...(instance.sourceMode ? { env: { default_overlay: "qa" } } : {}), }) @@ -568,6 +600,9 @@ async function writeLegacy(instance: Instance, image: string) { JSON.stringify({ name: instance.name, services: { + ...(instance.routing + ? { web: retainedRoutingFixtureService(instance.routing) } + : {}), db: { ...(instance.basicBuild ? { build: retainedBuildFixtureDefinition(instance.basicBuild) } @@ -625,6 +660,9 @@ async function writeLegacy(instance: Instance, image: string) { }, }, volumes: { data: { name: `${instance.name}_data` } }, + ...(instance.routing + ? { networks: { "hack-dev": { external: true } } } + : {}), ...(instance.ownedNetworks ? { networks: { @@ -638,9 +676,9 @@ async function writeLegacy(instance: Instance, image: string) { }) ); } -function formats(kind: Kind): string { +function formats(kind: Kind, routing = false): string { if (kind === "container") { - return `{"id":{{json .Id}},"name":{{json .Name}},"project":{{json (index .Config.Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"service":{{json (index .Config.Labels "com.docker.compose.service")}},"workingDir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"configFiles":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"mounts":[{{range $i,$m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{json $m.Name}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}]}`; + return `{"id":{{json .Id}},${routing ? '"createdAt":{{json .Created}},' : ""}"name":{{json .Name}},"project":{{json (index .Config.Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Config.Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"service":{{json (index .Config.Labels "com.docker.compose.service")}},"workingDir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"configFiles":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"mounts":[{{range $i,$m := .Mounts}}{{if $i}},{{end}}{"type":{{json $m.Type}},"name":{{json $m.Name}},"target":{{json $m.Destination}},"rw":{{json $m.RW}}}{{end}}]}`; } if (kind === "network") { return `{"id":{{json .Id}},"name":{{json .Name}},"createdAt":{{json .Created}},"project":{{json (index .Labels "${PROJECT_LABEL}")}},"nativeNames":[{{$first := true}}{{range $name,$value := .Labels}}{{if not $first}},{{end}}{{$first = false}}{{json $name}}{{end}}],"logical":{{json (index .Labels "com.docker.compose.network")}},"driver":{{json .Driver}},"scope":{{json .Scope}},"internal":{{json .Internal}}}`; @@ -691,7 +729,7 @@ function authoredFixtureFeatures(opts: { }; } -async function prepareFixtureInputs( +export async function prepareFixtureInputs( ctx: ScenarioContext, options: { readonly generated?: boolean; @@ -701,6 +739,7 @@ async function prepareFixtureInputs( readonly ownedNetworks?: boolean; readonly dependencies?: boolean; readonly basicBuild?: boolean; + readonly routing?: boolean; } = {} ) { const { @@ -711,7 +750,20 @@ async function prepareFixtureInputs( ownedNetworks = false, dependencies = false, basicBuild = false, + routing = false, } = options; + if ( + routing && + (generated || + typedLocal || + stringArgv || + ownedNetwork || + ownedNetworks || + dependencies || + basicBuild) + ) { + refused(); + } if ( basicBuild && (generated || @@ -749,6 +801,18 @@ async function prepareFixtureInputs( ctx.skip("Docker executable unavailable"); } const engineId = await probe(["info", "--format", "{{json .ID}}"]); + const routingImage = routing + ? await probe([ + "image", + "inspect", + "oven/bun:1.4.2-slim", + "--format", + "{{.Id}}", + ]) + : null; + if (routingImage !== null && !IMAGE.test(routingImage)) { + refused(); + } const fixture = await createMonorepoFixture({ parentDir: ctx.tempRoot, withHackConfig: false, @@ -757,6 +821,16 @@ async function prepareFixtureInputs( root: fixture.root, name: `${fixture.name}-main`, marker: "unused-primary", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-main`, + marker: "unused-primary", + prefer: "alias", + }), + } + : {}), ...authoredFixtureFeatures({ generated, stringArgv, @@ -798,6 +872,16 @@ async function prepareFixtureInputs( root: await addLinkedWorktree({ fixture, branch: "adoption-alpha" }), name: `${fixture.name}-alpha`, marker: "alpha-existing-sql-row", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-alpha`, + marker: "alpha-existing-http-marker", + prefer: "alias", + }), + } + : {}), ...authoredFixtureFeatures({ generated, stringArgv, @@ -811,6 +895,16 @@ async function prepareFixtureInputs( root: await addLinkedWorktree({ fixture, branch: "adoption-beta" }), name: `${fixture.name}-beta`, marker: "beta-existing-sql-row", + ...(routingImage + ? { + routing: retainedRoutingFixtureSelection({ + image: routingImage, + name: `${fixture.name}-beta`, + marker: "beta-existing-http-marker", + prefer: "dev", + }), + } + : {}), ...authoredFixtureFeatures({ generated, stringArgv, @@ -849,6 +943,13 @@ async function prepareFixtureInputs( }); } + if (routing) { + await prepareRetainedRoutingFixtureLocals({ + primary, + instances: [first, second], + }); + } + return { ctx, engine, @@ -863,7 +964,7 @@ async function prepareFixtureInputs( }; } -function createFixtureRuntime( +export function createFixtureRuntime( opts: Awaited> ) { const { @@ -902,7 +1003,13 @@ function createFixtureRuntime( instance, kind, row: object( - await probe([kind, "inspect", "--format", formats(kind), id]) + await probe([ + kind, + "inspect", + "--format", + formats(kind, instance.routing !== undefined), + id, + ]) ), }); const list = async (instance: Instance, kind: Kind) => @@ -1789,6 +1896,9 @@ async function requireFixtureNamesAbsent( ...fixtureNetworkNames(instance).map((name) => ["network", name] as const), ["container", `${instance.name}-db-1`], ["container", `${instance.name}-worker-1`], + ...(instance.routing + ? [["container", `${instance.name}-web-1`] as const] + : []), ] as const) { if ( ( @@ -1808,7 +1918,7 @@ async function requireFixtureNamesAbsent( } } } -async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { +export async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { const { engine, fixtureRoot, @@ -1881,7 +1991,7 @@ async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { }); successful(started); if ( - captured.container.length !== 2 || + captured.container.length !== (instance.routing ? 3 : 2) || captured.volume.length !== 1 || captured.network.length !== (instance.ownedNetworks ? 2 : 1) ) { diff --git a/tests/e2e/scenarios/native-config-build.ts b/tests/e2e/scenarios/native-config-build.ts index 45269491f..ef16cf21c 100644 --- a/tests/e2e/scenarios/native-config-build.ts +++ b/tests/e2e/scenarios/native-config-build.ts @@ -14,7 +14,7 @@ import { type Scenario, } from "../harness.ts"; import { prepareNativeEngineTripwire } from "../native-engine-tripwire.ts"; -import { proxyHasNoPublishedPorts } from "./native-config-routing.ts"; +import { proxyHasNoPublishedPorts } from "./native-routing-fixture-ingress.ts"; const TIMEOUT = 120_000; const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; diff --git a/tests/e2e/scenarios/native-config-routing.ts b/tests/e2e/scenarios/native-config-routing.ts index 089415317..bf21fcbfc 100644 --- a/tests/e2e/scenarios/native-config-routing.ts +++ b/tests/e2e/scenarios/native-config-routing.ts @@ -1,11 +1,9 @@ -import { createHash, randomBytes, X509Certificate } from "node:crypto"; +import { createHash } from "node:crypto"; import { chmod, lstat, mkdir, realpath } from "node:fs/promises"; import { join } from "node:path"; import type { Project } from "../../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "../../../src/lib/guards.ts"; import { openNativeComposeGenerationStore } from "../../../src/lib/native-compose-generation.ts"; -import { observeNativeComposeIngress } from "../../../src/lib/native-compose-ingress.ts"; -import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; import { type NativeRoutingResolution, parseNativeRoutingResolution, @@ -33,45 +31,20 @@ import { ROUTED_RUN_LITERAL, } from "./native-config-routed-run.ts"; +import { + NATIVE_ROUTING_FIXTURE_TMPFS as PRIVATE_TMPFS, + prepareNativeRoutingFixtureIngress, +} from "./native-routing-fixture-ingress.ts"; + const TIMEOUT = 180_000; -const OBSERVATION_WINDOW = 30_000; const OBJECT_ID = /^[a-f0-9]{64}$/; -const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; const TOKEN = /^[a-f0-9]{32}$/; const PROJECT_LABEL = "com.docker.compose.project"; -const SERVICE_LABEL = "com.docker.compose.service"; const OWNER_LABEL = "io.hack.native-config.owner"; const INSTANCE_LABEL = "io.hack.native-config.instance"; const STORAGE_LABEL = "io.hack.native-config.storage"; -const FIXTURE_LABEL = "hack.e2e.native-config-routing-owner"; -const ROOT_CA = "/data/caddy/pki/authorities/local/root.crt"; -const PROXY_PROJECT = "hack-dev-proxy"; -const PROXY_SERVICE = "caddy"; -const NETWORK = "hack-dev"; -const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; -const CADDY_IMAGE = "lucaslorentz/caddy-docker-proxy:2.10.0-alpine"; -const PRIVATE_TMPFS = "rw,noexec,nosuid,nodev,mode=700"; const APP = "Bun.serve({hostname:'0.0.0.0',port:3000,fetch(){return new Response(process.env.BRANCH_MARKER)}})"; -const PRESERVED_FORMAT = - '{"id":{{json .Id}},"name":{{json .Name}},"running":{{json .State.Running}},"status":{{json .State.Status}},"started":{{json .State.StartedAt}},"finished":{{json .State.FinishedAt}}}'; -const PROXY_FORMAT = - '{"id":{{json .Id}},"name":{{json .Name}},"owner":{{json (index .Config.Labels "hack.e2e.native-config-routing-owner")}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"network":{{with (index .NetworkSettings.Networks "hack-dev")}}{{json .NetworkID}}{{else}}null{{end}},"networkMode":{{json .HostConfig.NetworkMode}},"running":{{json .State.Running}},"ports":{{json .HostConfig.PortBindings}},"publishAll":{{json .HostConfig.PublishAllPorts}},"runtimePorts":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"tmpfs":{{json .HostConfig.Tmpfs}}}'; - -/** Explicit bindings alone miss Docker's dynamically published `-P` ports. */ -export function proxyHasNoPublishedPorts( - info: Readonly> -): boolean { - return ( - info.publishAll === false && - (info.ports === null || - (isRecord(info.ports) && Object.keys(info.ports).length === 0)) && - (info.runtimePorts === null || - (isRecord(info.runtimePorts) && - Object.values(info.runtimePorts).every((value) => value === null))) - ); -} - type Docker = (args: readonly string[]) => Promise; type Runtime = { readonly composeProject: string; @@ -362,58 +335,19 @@ export const nativeConfigRoutingScenario: Scenario = { }); return result.stdout.trim(); }; - const selectors = [ - "--filter", - `label=${PROJECT_LABEL}=${PROXY_PROJECT}`, - "--filter", - `label=${SERVICE_LABEL}=${PROXY_SERVICE}`, - ]; - // Stopped user selectors are not ingress candidates. Snapshot them; never adopt, - // start, rename or remove them merely to make this scenario runnable. - expect({ - that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", - message: - "Refuse native routing fixture while any global Caddy selector is running", - }); - const preserved = ids( - await docker(["ps", "--no-trunc", "-aq", ...selectors]) - ); - const preservedSnapshots = new Map(); - for (const id of preserved) { - const text = await docker(["inspect", "--format", PRESERVED_FORMAT, id]); - expect({ - that: object(text).running === false, - message: "Pre-existing proxy must be stopped", - }); - preservedSnapshots.set(id, text); - } - expect({ - that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", - message: "Native routing fixture requires a Linux Docker daemon", - }); - await docker(["compose", "version"]); - const networkId = await docker([ - "network", - "inspect", - NETWORK, - "--format", - "{{.Id}}", - ]); - expect({ - that: OBJECT_ID.test(networkId), - message: "Existing hack-dev network identity is required", - }); - const image = async (tag: string): Promise => { - const id = await docker(["image", "inspect", tag, "--format", "{{.Id}}"]); - expect({ - that: IMAGE_ID.test(id), - message: - "Fixture images must already be cached; never pull during acceptance", - }); - return id; - }; - const bunImage = await image("oven/bun:1.4.2-slim"); - const caddyImage = await image(CADDY_IMAGE); + const ingress = await prepareNativeRoutingFixtureIngress({ ctx, docker }); + const { + token, + proxyName, + canaryHost, + canaryMarker, + networkId, + bunImage, + admin, + tls, + absent, + preservedUnchanged, + } = ingress; const nativeInventory = async (): Promise => ids( await docker([ @@ -425,12 +359,7 @@ export const nativeConfigRoutingScenario: Scenario = { ]) ).join("\n"); const nativeBefore = await nativeInventory(); - const token = randomBytes(16).toString("hex"); - const proxyName = `e2e-native-routing-proxy-${token}`; - const canaryHost = `canary-${token}.test`; - const canaryMarker = `proxy-canary-${token}`; const privateRoot = await realpath(ctx.tempRoot); - let proxyId: string | null = null; let claimsRoot: string | null = null; const attempted = new Set(); const successfulStarts = new Set(); @@ -469,163 +398,6 @@ export const nativeConfigRoutingScenario: Scenario = { }); return result; }; - const currentProxy = (): string => { - if (!(proxyId && OBJECT_ID.test(proxyId))) { - throw new Error("Exact owned fixture proxy ID is unavailable"); - } - return proxyId; - }; - const preservedUnchanged = async (): Promise => { - expect({ - that: - (await docker([ - "network", - "inspect", - NETWORK, - "--format", - "{{.Id}}", - ])) === networkId, - message: "External hack-dev network must retain its exact identity", - }); - for (const [id, before] of preservedSnapshots) { - expect({ - that: - (await docker(["inspect", "--format", PRESERVED_FORMAT, id])) === - before, - message: "Stopped user Caddy selectors must remain unchanged", - }); - } - }; - const proxyOwned = async (): Promise => { - const info = object( - await docker(["inspect", "--format", PROXY_FORMAT, currentProxy()]) - ); - expect({ - that: - info.id === currentProxy() && - info.name === `/${proxyName}` && - info.owner === token && - info.project === PROXY_PROJECT && - info.service === PROXY_SERVICE && - info.networkMode === networkId && - (info.running === false || info.network === networkId) && - proxyHasNoPublishedPorts(info) && - Array.isArray(info.mounts) && - info.mounts.length === 1 && - info.mounts.every( - (mount: unknown) => - isRecord(mount) && - mount.Type === "bind" && - mount.Destination === "/var/run/docker.sock" && - mount.Source === "/var/run/docker.sock" && - mount.RW === false - ) && - isRecord(info.tmpfs) && - Object.keys(info.tmpfs).length === 2 && - info.tmpfs["/data"] === PRIVATE_TMPFS && - info.tmpfs["/config"] === PRIVATE_TMPFS, - message: - "Proxy effects require exact fixture ownership/network, no published ports or anonymous volumes", - }); - }; - const admin = async (): Promise => { - await proxyOwned(); - const text = await docker([ - "exec", - currentProxy(), - "curl", - "--disable", - "--silent", - "--show-error", - "--fail", - "--proxy", - "", - "--noproxy", - "*", - "--proto", - "=http", - "--max-time", - "10", - "--max-redirs", - "0", - "--write-out", - "\n%{http_code}", - "--url", - ADMIN_URL, - ]); - expect({ - that: text.endsWith("\n200"), - message: - "Read-only live Caddy configuration probe must return HTTP 200", - }); - return JSON.parse(text.slice(0, -4)); - }; - const absent = async (hosts: readonly string[]): Promise => { - expect({ - that: nativeComposeProxyRoutesMatch({ - servers: await admin(), - expected: [], - absentHostnames: hosts, - }), - message: - "Retired exact fixture origins must be absent from active Caddy routing", - }); - }; - const tls = async (origin: string, marker: string): Promise => { - const url = new URL(origin); - expect({ - that: - url.protocol === "https:" && url.port === "" && url.pathname === "/", - message: "Fixture TLS probes require exact standard HTTPS origins", - }); - const deadline = Date.now() + OBSERVATION_WINDOW; - while (Date.now() < deadline) { - await proxyOwned(); - const result = await runCommand({ - argv: [ - "docker", - "exec", - currentProxy(), - "curl", - "--disable", - "--silent", - "--show-error", - "--fail", - "--proxy", - "", - "--noproxy", - "*", - "--proto", - "=https", - "--max-redirs", - "0", - "--connect-timeout", - "2", - "--max-time", - "5", - "--cacert", - ROOT_CA, - "--resolve", - `${url.hostname}:443:127.0.0.1`, - "--url", - `${origin}/`, - ], - cwd: ctx.tempRoot, - timeoutMs: TIMEOUT, - }); - if ( - result.exitCode === 0 && - !result.timedOut && - result.stdout === marker - ) { - return; - } - await Bun.sleep(250); - } - throw new Error( - "Exact routed TLS marker was not observed; no insecure or app-local fallback permitted" - ); - }; const plan = async (root: string): Promise => { const payload = object( (await cli(root, ["config", "plan", "--json"])).stdout @@ -801,99 +573,17 @@ export const nativeConfigRoutingScenario: Scenario = { "Native hostname claims must be absent before fixture ingress removal", }); } - if (proxyId) { - await proxyOwned(); - await docker(["container", "stop", currentProxy()]); - await proxyOwned(); - await docker(["container", "rm", currentProxy()]); - proxyId = null; - } - expect({ - that: - (await docker([ - "ps", - "--no-trunc", - "-aq", - "--filter", - `label=${FIXTURE_LABEL}=${token}`, - ])) === "", - message: - "Exact proxy fixture and its ephemeral filesystem must be absent after cleanup", - }); - await preservedUnchanged(); + await ingress.cleanup(); }; await runWithOwnedCleanup({ run: async () => { - // Repeat ingress absence at the only fixture-global creation boundary. - expect({ - that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", - message: - "Refuse a newly appeared running global Caddy before fixture creation", - }); - proxyId = await docker([ - "create", - "--pull=never", - "--name", - proxyName, - "--network", - networkId, - "--label", - `${FIXTURE_LABEL}=${token}`, - "--label", - `${PROJECT_LABEL}=${PROXY_PROJECT}`, - "--label", - `${SERVICE_LABEL}=${PROXY_SERVICE}`, - "--label", - `caddy=https://${canaryHost}`, - "--label", - `caddy.respond=${canaryMarker} 200`, - "--label", - "caddy.tls=internal", - "--env", - `CADDY_INGRESS_NETWORKS=${NETWORK}`, - "--mount", - "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly", - // Caddy writes private root-owned files. Keep these in the disposable - // container so Linux cleanup never needs host chown or sudo. - "--tmpfs", - `/data:${PRIVATE_TMPFS}`, - "--tmpfs", - `/config:${PRIVATE_TMPFS}`, - caddyImage, - "docker-proxy", - "--polling-interval", - "1s", - ]); - await proxyOwned(); - await docker(["container", "start", currentProxy()]); - await tls(`https://${canaryHost}`, canaryMarker); - const binding = await observeNativeComposeIngress(); - expect({ - that: - binding.proxyId === currentProxy() && - binding.networkId === networkId, - message: - "Product ingress observer must select exactly the new fixture proxy/network", - }); + const binding = await ingress.start(); claimsRoot = join( ctx.hackHome, "compose-routing", createHash("sha256").update(binding.engineId).digest("hex"), "claims" ); - await admin(); - const certificate = new X509Certificate( - await docker(["exec", currentProxy(), "cat", ROOT_CA]) - ); - expect({ - that: - certificate.ca && - certificate.verify(certificate.publicKey) && - Date.parse(certificate.validFrom) <= Date.now() && - Date.parse(certificate.validTo) > Date.now(), - message: - "Only the current valid self-signed fixture CA may validate routed HTTPS", - }); stage( "isolated Caddy has no host ports and serves a verified TLS canary" ); @@ -1135,7 +825,12 @@ export const nativeConfigRoutingScenario: Scenario = { JSON.stringify( { version: 1, - proxy: { id: proxyId, name: proxyName, token, networkId }, + proxy: { + id: ingress.proxyId, + name: proxyName, + token, + networkId, + }, privateTmpfs: { "/data": PRIVATE_TMPFS, "/config": PRIVATE_TMPFS, diff --git a/tests/e2e/scenarios/native-routing-fixture-ingress.ts b/tests/e2e/scenarios/native-routing-fixture-ingress.ts new file mode 100644 index 000000000..9da078c47 --- /dev/null +++ b/tests/e2e/scenarios/native-routing-fixture-ingress.ts @@ -0,0 +1,397 @@ +import { randomBytes, X509Certificate } from "node:crypto"; +import { isRecord } from "../../../src/lib/guards.ts"; +import { observeNativeComposeIngress } from "../../../src/lib/native-compose-ingress.ts"; +import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; +import { expect, runCommand, type ScenarioContext } from "../harness.ts"; + +const TIMEOUT = 180_000; +const OBSERVATION_WINDOW = 30_000; +const OBJECT_ID = /^[a-f0-9]{64}$/; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const PROJECT_LABEL = "com.docker.compose.project"; +const SERVICE_LABEL = "com.docker.compose.service"; +const FIXTURE_LABEL = "hack.e2e.native-config-routing-owner"; +const ROOT_CA = "/data/caddy/pki/authorities/local/root.crt"; +const PROXY_PROJECT = "hack-dev-proxy"; +const PROXY_SERVICE = "caddy"; +const NETWORK = "hack-dev"; +const ADMIN_URL = "http://127.0.0.1:2019/config/apps/http/servers"; +const CADDY_IMAGE = "lucaslorentz/caddy-docker-proxy:2.10.0-alpine"; +export const NATIVE_ROUTING_FIXTURE_TMPFS = "rw,noexec,nosuid,nodev,mode=700"; +const PRIVATE_TMPFS = NATIVE_ROUTING_FIXTURE_TMPFS; +const PRESERVED_FORMAT = + '{"id":{{json .Id}},"name":{{json .Name}},"running":{{json .State.Running}},"status":{{json .State.Status}},"started":{{json .State.StartedAt}},"finished":{{json .State.FinishedAt}}}'; +const PROXY_FORMAT = + '{"id":{{json .Id}},"name":{{json .Name}},"owner":{{json (index .Config.Labels "hack.e2e.native-config-routing-owner")}},"project":{{json (index .Config.Labels "com.docker.compose.project")}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"network":{{with (index .NetworkSettings.Networks "hack-dev")}}{{json .NetworkID}}{{else}}null{{end}},"networkMode":{{json .HostConfig.NetworkMode}},"running":{{json .State.Running}},"ports":{{json .HostConfig.PortBindings}},"publishAll":{{json .HostConfig.PublishAllPorts}},"runtimePorts":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"tmpfs":{{json .HostConfig.Tmpfs}}}'; + +/** Explicit bindings alone miss Docker's dynamically published `-P` ports. */ +export function proxyHasNoPublishedPorts( + info: Readonly> +): boolean { + return ( + info.publishAll === false && + (info.ports === null || + (isRecord(info.ports) && Object.keys(info.ports).length === 0)) && + (info.runtimePorts === null || + (isRecord(info.runtimePorts) && + Object.values(info.runtimePorts).every((value) => value === null))) + ); +} + +function parsed(text: string): unknown { + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid fixture JSON response; values omitted"); + } +} + +function object(text: string): Record { + const value = parsed(text); + if (!isRecord(value)) { + throw new Error("Expected a complete JSON object; values omitted"); + } + return value; +} + +function ids(text: string): readonly string[] { + const found = text.split(/\s+/).filter(Boolean).sort(); + expect({ + that: + found.every((id) => OBJECT_ID.test(id)) && + new Set(found).size === found.length, + message: "Docker must return unique complete fixture resource IDs", + }); + return found; +} + +/** Shared same-engine fixture ingress: no host ports, DNS or trust writes. */ +export async function prepareNativeRoutingFixtureIngress(opts: { + readonly ctx: ScenarioContext; + readonly docker: (args: readonly string[]) => Promise; +}) { + const { ctx, docker } = opts; + const selectors = [ + "--filter", + `label=${PROJECT_LABEL}=${PROXY_PROJECT}`, + "--filter", + `label=${SERVICE_LABEL}=${PROXY_SERVICE}`, + ]; + // Stopped user selectors are not ingress candidates. Snapshot them; never adopt, + // start, rename or remove them merely to make this scenario runnable. + expect({ + that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", + message: + "Refuse native routing fixture while any global Caddy selector is running", + }); + const preserved = ids( + await docker(["ps", "--no-trunc", "-aq", ...selectors]) + ); + const preservedSnapshots = new Map(); + for (const id of preserved) { + const text = await docker(["inspect", "--format", PRESERVED_FORMAT, id]); + expect({ + that: object(text).running === false, + message: "Pre-existing proxy must be stopped", + }); + preservedSnapshots.set(id, text); + } + expect({ + that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", + message: "Native routing fixture requires a Linux Docker daemon", + }); + await docker(["compose", "version"]); + const networkId = await docker([ + "network", + "inspect", + NETWORK, + "--format", + "{{.Id}}", + ]); + expect({ + that: OBJECT_ID.test(networkId), + message: "Existing hack-dev network identity is required", + }); + const image = async (tag: string): Promise => { + const id = await docker(["image", "inspect", tag, "--format", "{{.Id}}"]); + expect({ + that: IMAGE_ID.test(id), + message: + "Fixture images must already be cached; never pull during acceptance", + }); + return id; + }; + const bunImage = await image("oven/bun:1.4.2-slim"); + const caddyImage = await image(CADDY_IMAGE); + const token = randomBytes(16).toString("hex"); + const proxyName = `e2e-native-routing-proxy-${token}`; + const canaryHost = `canary-${token}.test`; + const canaryMarker = `proxy-canary-${token}`; + let proxyId: string | null = null; + let creationAttempted = false; + const currentProxy = (): string => { + if (!(proxyId && OBJECT_ID.test(proxyId))) { + throw new Error("Exact owned fixture proxy ID is unavailable"); + } + return proxyId; + }; + const preservedUnchanged = async (): Promise => { + expect({ + that: + (await docker([ + "network", + "inspect", + NETWORK, + "--format", + "{{.Id}}", + ])) === networkId, + message: "External hack-dev network must retain its exact identity", + }); + for (const [id, before] of preservedSnapshots) { + expect({ + that: + (await docker(["inspect", "--format", PRESERVED_FORMAT, id])) === + before, + message: "Stopped user Caddy selectors must remain unchanged", + }); + } + }; + const proxyOwned = async (): Promise => { + const info = object( + await docker(["inspect", "--format", PROXY_FORMAT, currentProxy()]) + ); + expect({ + that: + info.id === currentProxy() && + info.name === `/${proxyName}` && + info.owner === token && + info.project === PROXY_PROJECT && + info.service === PROXY_SERVICE && + info.networkMode === networkId && + (info.running === false || info.network === networkId) && + proxyHasNoPublishedPorts(info) && + Array.isArray(info.mounts) && + info.mounts.length === 1 && + info.mounts.every( + (mount: unknown) => + isRecord(mount) && + mount.Type === "bind" && + mount.Destination === "/var/run/docker.sock" && + mount.Source === "/var/run/docker.sock" && + mount.RW === false + ) && + isRecord(info.tmpfs) && + Object.keys(info.tmpfs).length === 2 && + info.tmpfs["/data"] === PRIVATE_TMPFS && + info.tmpfs["/config"] === PRIVATE_TMPFS, + message: + "Proxy effects require exact fixture ownership/network, no published ports or anonymous volumes", + }); + }; + const admin = async (): Promise => { + await proxyOwned(); + const text = await docker([ + "exec", + currentProxy(), + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=http", + "--max-time", + "10", + "--max-redirs", + "0", + "--write-out", + "\n%{http_code}", + "--url", + ADMIN_URL, + ]); + expect({ + that: text.endsWith("\n200"), + message: "Read-only live Caddy configuration probe must return HTTP 200", + }); + return parsed(text.slice(0, -4)); + }; + const absent = async (hosts: readonly string[]): Promise => { + expect({ + that: nativeComposeProxyRoutesMatch({ + servers: await admin(), + expected: [], + absentHostnames: hosts, + }), + message: + "Retired exact fixture origins must be absent from active Caddy routing", + }); + }; + const tls = async (origin: string, marker: string): Promise => { + const url = new URL(origin); + expect({ + that: + url.protocol === "https:" && url.port === "" && url.pathname === "/", + message: "Fixture TLS probes require exact standard HTTPS origins", + }); + const deadline = Date.now() + OBSERVATION_WINDOW; + while (Date.now() < deadline) { + await proxyOwned(); + const result = await runCommand({ + argv: [ + "docker", + "exec", + currentProxy(), + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=https", + "--max-redirs", + "0", + "--connect-timeout", + "2", + "--max-time", + "5", + "--cacert", + ROOT_CA, + "--resolve", + `${url.hostname}:443:127.0.0.1`, + "--url", + `${origin}/`, + ], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + if ( + result.exitCode === 0 && + !result.timedOut && + result.stdout === marker + ) { + return; + } + await Bun.sleep(250); + } + throw new Error( + "Exact routed TLS marker was not observed; no insecure or app-local fallback permitted" + ); + }; + const start = async () => { + if (creationAttempted) { + throw new Error("Fixture ingress may be created only once"); + } + creationAttempted = true; + // Repeat ingress absence at the only fixture-global creation boundary. + expect({ + that: (await docker(["ps", "--no-trunc", "-q", ...selectors])) === "", + message: + "Refuse a newly appeared running global Caddy before fixture creation", + }); + proxyId = await docker([ + "create", + "--pull=never", + "--name", + proxyName, + "--network", + networkId, + "--label", + `${FIXTURE_LABEL}=${token}`, + "--label", + `${PROJECT_LABEL}=${PROXY_PROJECT}`, + "--label", + `${SERVICE_LABEL}=${PROXY_SERVICE}`, + "--label", + `caddy=https://${canaryHost}`, + "--label", + `caddy.respond=${canaryMarker} 200`, + "--label", + "caddy.tls=internal", + "--env", + `CADDY_INGRESS_NETWORKS=${NETWORK}`, + "--mount", + "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly", + // Caddy writes private root-owned files. Keep these in the disposable + // container so Linux cleanup never needs host chown or sudo. + "--tmpfs", + `/data:${PRIVATE_TMPFS}`, + "--tmpfs", + `/config:${PRIVATE_TMPFS}`, + caddyImage, + "docker-proxy", + "--polling-interval", + "1s", + ]); + await proxyOwned(); + await docker(["container", "start", currentProxy()]); + await tls(`https://${canaryHost}`, canaryMarker); + const binding = await observeNativeComposeIngress(); + expect({ + that: + binding.proxyId === currentProxy() && binding.networkId === networkId, + message: + "Product ingress observer must select exactly the new fixture proxy/network", + }); + await admin(); + const certificate = new X509Certificate( + await docker(["exec", currentProxy(), "cat", ROOT_CA]) + ); + expect({ + that: + certificate.ca && + certificate.verify(certificate.publicKey) && + Date.parse(certificate.validFrom) <= Date.now() && + Date.parse(certificate.validTo) > Date.now(), + message: + "Only the current valid self-signed fixture CA may validate routed HTTPS", + }); + return binding; + }; + const cleanup = async () => { + if (proxyId) { + await proxyOwned(); + await docker(["container", "stop", currentProxy()]); + await proxyOwned(); + await docker(["container", "rm", currentProxy()]); + proxyId = null; + } + expect({ + that: + (await docker([ + "ps", + "--no-trunc", + "-aq", + "--filter", + `label=${FIXTURE_LABEL}=${token}`, + ])) === "", + message: + "Exact proxy fixture and its ephemeral filesystem must be absent after cleanup", + }); + await preservedUnchanged(); + }; + return { + token, + proxyName, + canaryHost, + canaryMarker, + networkId, + bunImage, + get proxyId() { + return proxyId; + }, + start, + admin, + tls, + absent, + preservedUnchanged, + cleanup, + }; +} diff --git a/tests/native-compose-adoption-routing-fixture.test.ts b/tests/native-compose-adoption-routing-fixture.test.ts new file mode 100644 index 000000000..a70b1dec2 --- /dev/null +++ b/tests/native-compose-adoption-routing-fixture.test.ts @@ -0,0 +1,334 @@ +import { expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivate } from "../src/lib/native-compose-private-state.ts"; +import { mapLegacyNativeRetainedRouting } from "../src/lib/native-config-import-plan.ts"; +import { + prepareRetainedRoutingFixtureLocals, + retainedRoutingFixtureConfig, + retainedRoutingFixtureOrigins, + retainedRoutingFixtureSelection, + retainedRoutingFixtureService, +} from "./e2e/scenarios/native-compose-adoption-routing-inputs.ts"; +import { + nativeComposeAdoptionRoutingWorktreesScenario, + retainedRoutingPartialStopScript, +} from "./e2e/scenarios/native-compose-adoption-routing-worktrees.ts"; +import { ownedAdoptionFixtureObservation } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; + +const IMAGE = `sha256:${"a".repeat(64)}`; +const IDS = ["b".repeat(64), "c".repeat(64), "d".repeat(64)] as const; +function selection(prefer: "alias" | "dev" = "alias") { + return retainedRoutingFixtureSelection({ + image: IMAGE, + name: "retained-origin", + marker: "synthetic-http-marker", + prefer, + }); +} +test("routing acceptance is explicitly selected and retains uncertain source/resource evidence", () => { + expect( + nativeComposeAdoptionRoutingWorktreesScenario.requiresExplicitSelection + ).toBe(true); + expect( + nativeComposeAdoptionRoutingWorktreesScenario.preserveFixtureOnFailure + ).toBe(true); +}); +test("maintained legacy HTTP fixture has a real apex, existing alias and lossless defined candidate", () => { + const route = selection(); + const result = mapLegacyNativeRetainedRouting({ + configText: JSON.stringify({ + name: "retained-origin", + ...retainedRoutingFixtureConfig(route), + worktree: { auto_branch: false, inherit_local: true }, + }), + composeText: JSON.stringify({ + name: "retained-origin", + services: { + web: retainedRoutingFixtureService(route), + db: { image: IMAGE, volumes: ["data:/data"] }, + }, + volumes: { data: {} }, + networks: { "hack-dev": { external: true } }, + }), + }); + expect(result.report.complete).toBe(true); + expect(result.candidate).toBeDefined(); + expect(retainedRoutingFixtureOrigins(route)).toEqual([ + "https://retained-origin.hack.local", + "https://retained-origin.hack.gy", + ]); + expect(retainedRoutingFixtureConfig(selection("dev")).open.prefer).toBe( + "dev" + ); + expect(retainedRoutingFixtureConfig(route).open.prefer).toBe("dev"); + expect(retainedRoutingFixtureService(route).image).toBe(IMAGE); +}); +test("alpha checkout alias differs from authored and primary-local dev selections", async () => { + const root = await mkdtemp(join(tmpdir(), "retained-routing-locals-")); + try { + const primary = { root: join(root, "primary") }; + const instance = { root: join(root, "checkout"), routing: selection() }; + await mkdir(join(primary.root, ".hack"), { recursive: true, mode: 0o700 }); + await mkdir(join(instance.root, ".hack"), { recursive: true, mode: 0o700 }); + await prepareRetainedRoutingFixtureLocals({ + primary, + instances: [instance], + }); + expect(retainedRoutingFixtureConfig(instance.routing).open.prefer).toBe( + "dev" + ); + expect( + JSON.parse( + await readFile(join(primary.root, ".hack/hack.local.json"), "utf8") + ) + ).toEqual({ + schema_version: 1, + routes: { domain: "primary-shadowed.test" }, + open: { prefer: "dev" }, + }); + expect( + JSON.parse( + await readFile(join(instance.root, ".hack/hack.local.json"), "utf8") + ) + ).toEqual({ + schema_version: 1, + routes: { domain: "checkout-selected.test" }, + open: { prefer: "alias" }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +test("new routed web observation requires exact birth, zero mounts and original service/name scope", () => { + const instance = { + root: "/synthetic/retained", + name: "retained-origin", + marker: "synthetic-sql", + routing: selection(), + }; + const row = { + id: IDS[1], + createdAt: "2026-10-09T01:02:03Z", + project: instance.name, + nativeNames: [], + name: `/${instance.name}-web-1`, + service: "web", + workingDir: `${instance.root}/.hack`, + configFiles: `${instance.root}/.hack/docker-compose.yml`, + mounts: [], + }; + expect( + ownedAdoptionFixtureObservation({ instance, kind: "container", row }) + ).toEqual({ id: IDS[1], service: "web", createdAt: row.createdAt }); + for (const change of [ + { createdAt: null }, + { mounts: [{ type: "volume" }] }, + { nativeNames: ["io.hack.native-config.owner"] }, + { service: "other" }, + ]) { + expect(() => + ownedAdoptionFixtureObservation({ + instance, + kind: "container", + row: { ...row, ...change }, + }) + ).toThrow(); + } +}); + +/** Closed child ports, but the emitted marker uses the actual exclusive writer. */ +async function replay( + state: unknown, + args: readonly string[], + existingMarker = false +) { + const root = await mkdtemp(join(tmpdir(), "retained-routing-marker-")); + await chmod(root, 0o700); + const markerPath = join(root, "marker"); + try { + if (existingMarker) { + await writeFile(markerPath, "existing-private-marker", { + flag: "wx", + mode: 0o600, + }); + } + const requests: string[][] = []; + let writeError: string | null = null; + const source = retainedRoutingPartialStopScript({ + engine: "/synthetic/docker", + engineId: '"synthetic-daemon"', + receipt: "/synthetic/receipt", + ids: IDS, + stopId: IDS[0], + marker: markerPath, + }); + const exit = { code: -1 }; + const readReceipt = async (path: string, limit: number) => { + if (path !== "/synthetic/receipt" || limit !== 131_072) { + throw new Error("unexpected read"); + } + return { text: JSON.stringify(state) }; + }; + const fixtureProcess = { + argv: ["bun", "shim", ...args], + exit: (code: number): never => { + exit.code = code; + throw exit; + }, + }; + const fixtureBun = { + spawn: (argv: string[], _options: unknown) => { + requests.push(argv); + if ( + JSON.stringify(argv) === + JSON.stringify([ + "/synthetic/docker", + "info", + "--format", + "{{json .ID}}", + ]) + ) { + return { + exited: Promise.resolve(0), + stdout: new Response('"synthetic-daemon"\n').body, + }; + } + if ( + JSON.stringify(argv) === + JSON.stringify(["/synthetic/docker", "container", "stop", IDS[0]]) + ) { + return { exited: Promise.resolve(0) }; + } + throw new Error("unexpected effect"); + }, + }; + const AsyncFunction: new ( + ...args: string[] + ) => (...ports: unknown[]) => Promise = Object.getPrototypeOf( + async () => undefined + ).constructor; + try { + const lines = source.split("\n"); + if ( + !( + lines[1]?.startsWith("import { readPrivate } from ") && + lines[1].endsWith(";") + ) || + lines[2] !== "import { writeFile } from 'node:fs/promises';" + ) { + throw new Error("emitted bounded reader import missing"); + } + await new AsyncFunction( + "Bun", + "process", + "readPrivate", + "writeFile", + lines.slice(3).join("\n") + )(fixtureBun, fixtureProcess, readReceipt, writeFile); + throw new Error("emitted forwarder did not exit"); + } catch (error: unknown) { + if ( + existingMarker && + typeof error === "object" && + error !== null && + "code" in error && + error.code === "EEXIST" + ) { + writeError = "EEXIST"; + } else if (error !== exit) { + throw error; + } + } + let marker: string | null = null; + let markerMode: number | null = null; + try { + const info = await lstat(markerPath); + markerMode = info.mode & 0o777; + marker = (await readPrivate(markerPath, 128)).text; + } catch (error: unknown) { + if ( + !( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) + ) { + throw error; + } + } + return { code: exit.code, requests, marker, markerMode, writeError }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} +const PENDING = { + adoption_receipt_version: 14, + pendingOperation: { operation: "stop", services: ["db", "web", "worker"] }, + routingOperation: { disposition: "prospective", code: null }, + routingHandoff: "held", +}; +test("emitted partial-stop control requires prospective authority before one original-ID stop and exact marker", async () => { + const result = await replay(PENDING, ["container", "stop", ...IDS]); + expect(result).toEqual({ + code: 71, + requests: [ + ["/synthetic/docker", "info", "--format", "{{json .ID}}"], + ["/synthetic/docker", "container", "stop", IDS[0]], + ], + marker: "known-routing-partial-stop", + markerMode: 0o600, + writeError: null, + }); +}); +test("emitted partial-stop marker refuses an existing private leaf without overwriting it", async () => { + expect(await replay(PENDING, ["container", "stop", ...IDS], true)).toEqual({ + code: -1, + requests: [ + ["/synthetic/docker", "info", "--format", "{{json .ID}}"], + ["/synthetic/docker", "container", "stop", IDS[0]], + ], + marker: "existing-private-marker", + markerMode: 0o600, + writeError: "EEXIST", + }); +}); +test("emitted partial-stop control refuses stale journals, incomplete selection and foreign argv without effects", async () => { + for (const state of [ + { ...PENDING, adoption_receipt_version: 11 }, + { ...PENDING, routingOperation: { disposition: "settled", code: 0 } }, + { + ...PENDING, + pendingOperation: { + operation: "start", + services: ["db", "web", "worker"], + }, + }, + { ...PENDING, routingHandoff: "releasing" }, + ]) { + expect(await replay(state, ["container", "stop", ...IDS])).toEqual({ + code: 98, + requests: [], + marker: null, + markerMode: null, + writeError: null, + }); + } + expect(await replay(PENDING, ["container", "stop", IDS[0]])).toEqual({ + code: 99, + requests: [], + marker: null, + markerMode: null, + writeError: null, + }); +}); diff --git a/tests/native-routing-fixture.test.ts b/tests/native-routing-fixture.test.ts index ced9c955b..7eb9c1650 100644 --- a/tests/native-routing-fixture.test.ts +++ b/tests/native-routing-fixture.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { proxyHasNoPublishedPorts } from "./e2e/scenarios/native-config-routing.ts"; +import { proxyHasNoPublishedPorts } from "./e2e/scenarios/native-routing-fixture-ingress.ts"; test("unpublished exposed and stopped ports are safe for the isolated proxy", () => { for (const runtimePorts of [null, {}, { "80/tcp": null, "443/tcp": null }]) { From d794bdfe2a5a0a705beb32496994cfc2a11ad850 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 02:22:21 -0400 Subject: [PATCH 4/9] fix(native-compose): coalesce read-only retained routing proofs Reuse immutable resource binding only inside one private observational phase. Keep complete entry/final bindings, fresh route observations, source and receipt checks, and scoped callback revocation. --- docs/reference/native-compose-adoption.md | 8 + src/lib/native-compose-adoption-generation.ts | 148 ++++++++++----- ...ompose-adoption-routing-generation.test.ts | 169 ++++++++++++++++++ 3 files changed, 285 insertions(+), 40 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index f3c167e86..c4954d26f 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -522,6 +522,14 @@ record; only its one-use known-return and process-group-absence proof settles th record. Explicit recovery can contain an uncertain child but cannot clear its uncertainty merely because containers are stopped. +Within one read-only dispatch proof, a private owner-issued context reuses its +entry resource observation while checking source, receipt, claims and lease +authority throughout. Routing, ingress and foreign-site observations remain +fresh. A complete resource/runtime observation brackets the proof, including +final volume and inventory rereads. The context is revoked before return and +cannot cross a lifecycle effect, publication or another observation phase. +This reduces nested inspection calls; it is not a measured runtime or CPU claim. + Rollback requires restored source bytes, stopped original resources and absent proxy dispatch before handing hostname claims back to the restored legacy source. It retains a durable handoff state across interrupted claim removal. Builds, jobs, diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 828680bbe..686902663 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -492,6 +492,18 @@ type Context = { { readonly info: Stats; readonly text: string } >; }; +type RetainedRouteObservation = { + readonly binding: LegacyComposeVerifiedBinding; + readonly runtimeConfig: unknown; + readonly assertActive: () => void; + readonly assertManifest: (meta: SavedManifest) => void; +}; +// Only the private read-only routing proof issues this context. Revoked entries +// remain in the WeakMap so a late continuation refuses instead of reacquiring. +const retainedRouteObservations = new WeakMap< + Context, + RetainedRouteObservation +>(); async function assertRetainedBuildSource(opts: { readonly ctx: Context; readonly meta: SavedManifest; @@ -665,25 +677,65 @@ function requireRetainedClaimContext(opts: { } async function assertRetainedRouteState( ctx: Context, - loaded: { readonly inputs: PrivateInputs }, + loaded: Awaited>, phase: "active" | "stopped", deadline: number, - assertOwner: () => Promise + assertOwner: ( + current: Context + ) => Promise>> ): Promise { const binding = loaded.inputs.binding; - if (binding.binding_version !== 14) { + if (binding.binding_version !== 14 || retainedRouteObservations.has(ctx)) { refuse(); } - await assertLegacyComposeRetainedRoutingState({ - binding, - routing: retainedRoutingIntent(loaded.inputs), - proof: binding.routing, - phase, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - deadline, - assertOwner, + const first = await assertOwner(ctx); + if ( + first.manifest.id !== loaded.manifest.id || + JSON.stringify(first.inputs.binding) !== JSON.stringify(binding) + ) { + refuse(); + } + const observedManifest = JSON.stringify(first.manifest); + const current: Context = { ...ctx }; + let active = true; + const assertActive = () => { + cancelled(current.signal); + if (!(active && Number.isFinite(deadline)) || Date.now() >= deadline) { + refuse(); + } + }; + retainedRouteObservations.set(current, { + binding: first.inputs.binding, + runtimeConfig: first.manifest.runtimeConfig, + assertActive, + assertManifest: (meta) => { + assertActive(); + if (JSON.stringify(meta) !== observedManifest) { + refuse(); + } + }, }); + try { + await assertLegacyComposeRetainedRoutingState({ + binding, + routing: retainedRoutingIntent(loaded.inputs), + proof: binding.routing, + phase, + signal: current.signal, + timeoutMs: current.timeoutMs, + deadline, + assertOwner: async () => { + assertActive(); + await assertOwner(current); + assertActive(); + }, + }); + } finally { + active = false; + } + // The original context performs a complete fresh binding/runtime proof again, + // including foreign resources, final volumes and inventories, before success. + await assertOwner(ctx); } function selectedMapper(routing: boolean, basic: boolean) { if (routing) { @@ -711,6 +763,8 @@ async function readInputs( readonly manifest: Manifest; readonly inputs: Readonly; }> { + const observation = retainedRouteObservations.get(ctx); + observation?.assertActive(); await ctx.check(); const generationRoot = join(ctx.generationsRoot, selected.id); const held = await holdDirectory(generationRoot, true); @@ -723,6 +777,7 @@ async function readInputs( refuse(); } const meta = manifest(saved.value, ctx.root, selected.id); + observation?.assertManifest(meta); const configText = await readArtifact( join(generationRoot, "legacy-config.json"), meta.files.config @@ -800,13 +855,15 @@ async function readInputs( refuse(); } } - const observed = await inspectLegacyComposeAdoptionResources({ - root: ctx.root, - intent: planned.intent, - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - composeFiles: projection?.composeFiles, - }); + const observed = observation + ? observation.binding + : await inspectLegacyComposeAdoptionResources({ + root: ctx.root, + intent: planned.intent, + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + composeFiles: projection?.composeFiles, + }); if (JSON.stringify(meta.binding) !== JSON.stringify(observed)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } @@ -842,12 +899,14 @@ async function readInputs( await claims.close(); } } - const runtimeConfig = await inspectLegacyComposeRuntimeConfig({ - binding: observed, - composeFile: join(generationRoot, "legacy-compose.yml"), - signal: ctx.signal, - timeoutMs: ctx.timeoutMs, - }); + const runtimeConfig = observation + ? observation.runtimeConfig + : await inspectLegacyComposeRuntimeConfig({ + binding: observed, + composeFile: join(generationRoot, "legacy-compose.yml"), + signal: ctx.signal, + timeoutMs: ctx.timeoutMs, + }); if (JSON.stringify(meta.runtimeConfig) !== JSON.stringify(runtimeConfig)) { refuse("E_LEGACY_ADOPTION_CHANGED"); } @@ -887,6 +946,7 @@ async function readInputs( } await assertBuildSource(); await ctx.check(); + observation?.assertManifest(meta); freezeImportValue(observed); return { manifest: { ...meta, binding: observed }, @@ -1869,18 +1929,25 @@ async function assertPublicationRoutingStopped( ) { refuse("E_LEGACY_ADOPTION_BUSY"); } - await assertRetainedRouteState(ctx, loaded, "stopped", deadline, async () => { - await readInputs(ctx, state.prepared ?? refuse()); - await requireStopped(ctx, loaded.inputs.binding); - if (restored) { - await requireRestoredRoutingSourceInputs(ctx, loaded); - } - await requireReceiptSnapshot(ctx, state); - cancelled(ctx.signal); - if (Date.now() >= deadline) { - refuse(); + await assertRetainedRouteState( + ctx, + loaded, + "stopped", + deadline, + async (current) => { + const fresh = await readInputs(current, state.prepared ?? refuse()); + await requireStopped(current, fresh.inputs.binding); + if (restored) { + await requireRestoredRoutingSourceInputs(current, fresh); + } + await requireReceiptSnapshot(current, state); + cancelled(current.signal); + if (Date.now() >= deadline) { + refuse(); + } + return fresh; } - }); + ); } type MutationOptions = Parameters< @@ -2440,11 +2507,12 @@ async function mutateRetainedContainersWithinBudget( loaded, captured.operation === "stop" ? "stopped" : "active", captured.deadline ?? 0, - async () => { - const fresh = await readInputs(ctx, owned); - await requireMutationInputs(ctx, activePublication, fresh); - await requireReceiptSnapshot(ctx, state); + async (current) => { + const fresh = await readInputs(current, owned); + await requireMutationInputs(current, activePublication, fresh); + await requireReceiptSnapshot(current, state); requireMutationDeadline(captured, retainedPlan); + return fresh; } ); await requireReceiptSnapshot(ctx, state); diff --git a/tests/native-compose-adoption-routing-generation.test.ts b/tests/native-compose-adoption-routing-generation.test.ts index d8927a965..fad4583aa 100644 --- a/tests/native-compose-adoption-routing-generation.test.ts +++ b/tests/native-compose-adoption-routing-generation.test.ts @@ -182,6 +182,12 @@ test( args.includes("rm") ) ).toBe(false); + expect(h.commands.length).toBeLessThanOrEqual(9000); + expect( + h.commands.filter( + (args) => args[0] === "volume" && args[1] === "inspect" + ).length + ).toBeLessThanOrEqual(136); phase("assertions-complete"); } finally { await store.close(); @@ -192,6 +198,169 @@ test( }, { timeoutMs: 60_000 } ); +test("read-only routing phase refuses volume birth drift at its complete final binding", async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let changedAtVolumeRead: number | undefined; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + changedAtVolumeRead = h.commands.filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + h.model.volumeBirth = "2026-02-02T01:02:03Z"; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(changedAtVolumeRead).toBeDefined(); + expect(windowVolumeReads).toBe(4); + expect( + h.commands.filter((args) => args[0] === "volume" && args[1] === "inspect") + .length + ).toBeGreaterThan(changedAtVolumeRead ?? Number.POSITIVE_INFINITY); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } +}); +test("read-only routing phase still inspects a newly introduced foreign site writer", async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let introduced = false; + let observed = false; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + introduced = true; + h.model.foreign = true; + } else if ( + introduced && + args[0] === "container" && + args[1] === "inspect" && + args.includes(ROUTING_IDS.foreign) + ) { + observed = true; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(introduced).toBe(true); + expect(windowVolumeReads).toBe(4); + expect(observed).toBe(true); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } +}); +test("completed routing observation cannot carry a resource proof into a later operation", async () => { + const { store, generation } = await prepare(); + try { + expect( + await h.operation(store, generation, "stop", { preparation: true }) + ).toBe(0); + expect((await h.receipt()).pendingOperation).toBeNull(); + h.model.volumeBirth = "2026-02-02T01:02:03Z"; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation).toBeNull(); + } finally { + await store.close(); + } +}); +for (const changed of ["source", "receipt"] as const) { + test(`read-only routing phase rechecks ${changed} authority inside its observation window`, async () => { + const { store, generation } = await prepare(); + let adminReads = 0; + let effectCommandOffset = 0; + let windowVolumeReads: number | undefined; + let changedAtVolumeRead: number | undefined; + try { + h.hooks.afterEffect = async () => { + effectCommandOffset = h.commands.length; + }; + h.hooks.afterProbe = async (args) => { + if ( + h.effects.length === 1 && + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" && + ++adminReads === 3 + ) { + windowVolumeReads = h.commands + .slice(effectCommandOffset) + .filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + const path = + changed === "source" + ? join(h.root, ".hack/hack.config.json") + : h.receiptPath; + const bytes = await readFile(path); + await fs.rename(path, join(h.outer, `replaced-${changed}`)); + await fs.writeFile(path, bytes, { mode: 0o600, flag: "wx" }); + changedAtVolumeRead = h.commands.filter( + (command) => command[0] === "volume" && command[1] === "inspect" + ).length; + } + }; + await red(h.operation(store, generation, "stop", { preparation: true })); + expect(changedAtVolumeRead).toBeDefined(); + expect(windowVolumeReads).toBe(4); + if (changedAtVolumeRead === undefined) { + throw new Error("Synthetic authority replacement was not reached"); + } + // A cheap inner authority check must refuse before the final resource scan. + expect( + h.commands.filter( + (args) => args[0] === "volume" && args[1] === "inspect" + ).length + ).toBe(changedAtVolumeRead); + expect(h.effects).toHaveLength(1); + expect((await h.receipt()).pendingOperation?.operation).toBe("stop"); + expect((await h.receipt()).routingHandoff).toBe("held"); + } finally { + h.hooks.afterEffect = undefined; + h.hooks.afterProbe = undefined; + await store.close(); + } + }); +} test("numeric callback cannot settle a prospective child; explicit stop containment retains original uncertainty", async () => { const { store, generation } = await prepare(); try { From 3dd016b96b64af0e4dca63706b4fb3a437b06781 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 03:44:54 -0400 Subject: [PATCH 5/9] test(native-compose): retain routed TLS failure evidence --- .../native-compose-adoption-routing-inputs.ts | 2 +- .../native-routing-fixture-ingress.ts | 138 ++++++++++++++---- ...e-compose-adoption-routing-fixture.test.ts | 10 ++ tests/native-routing-fixture.test.ts | 96 +++++++++++- 4 files changed, 212 insertions(+), 34 deletions(-) diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts index 5d41dddaf..c7a9af923 100644 --- a/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-routing-inputs.ts @@ -66,7 +66,7 @@ export function retainedRoutingFixtureService( environment: { RETAINED_ROUTE_MARKER: selection.marker }, networks: ["default", "hack-dev"], labels: { - caddy: `${selection.devHost},${selection.aliasHost}`, + caddy: `${selection.devHost}, ${selection.aliasHost}`, "caddy.reverse_proxy": "{{upstreams 3000}}", "caddy.tls": "internal", caddy_ingress_network: "hack-dev", diff --git a/tests/e2e/scenarios/native-routing-fixture-ingress.ts b/tests/e2e/scenarios/native-routing-fixture-ingress.ts index 9da078c47..4cf54c631 100644 --- a/tests/e2e/scenarios/native-routing-fixture-ingress.ts +++ b/tests/e2e/scenarios/native-routing-fixture-ingress.ts @@ -1,10 +1,13 @@ import { randomBytes, X509Certificate } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; import { isRecord } from "../../../src/lib/guards.ts"; import { observeNativeComposeIngress } from "../../../src/lib/native-compose-ingress.ts"; import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; -import { expect, runCommand, type ScenarioContext } from "../harness.ts"; +import { findExecutableInPath } from "../../../src/lib/shell.ts"; +import { expect, type ScenarioContext } from "../harness.ts"; +import { runNativeNetworkFixtureCommand } from "../native-config-networks-acceptance.ts"; -const TIMEOUT = 180_000; const OBSERVATION_WINDOW = 30_000; const OBJECT_ID = /^[a-f0-9]{64}$/; const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; @@ -65,12 +68,95 @@ function ids(text: string): readonly string[] { return found; } +/** Each exact TLS attempt retains private bounded streams even on failure. */ +export async function runNativeRoutingFixtureTlsAttempt(opts: { + readonly docker: string; + readonly proxyId: string; + readonly origin: string; + readonly cwd: string; + readonly captures: string; + readonly env: Readonly>; + readonly timeoutMs: number; +}) { + const url = new URL(opts.origin); + expect({ + that: + isAbsolute(opts.docker) && + OBJECT_ID.test(opts.proxyId) && + url.protocol === "https:" && + url.port === "" && + url.pathname === "/" && + !url.username && + !url.password && + !url.search && + !url.hash && + opts.timeoutMs > 0 && + opts.timeoutMs <= OBSERVATION_WINDOW, + message: "Fixture TLS capture admission refused; values omitted", + }); + const result = await runNativeNetworkFixtureCommand({ + argv: [ + opts.docker, + "exec", + opts.proxyId, + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=https", + "--max-redirs", + "0", + "--connect-timeout", + "2", + "--max-time", + "5", + "--cacert", + ROOT_CA, + "--resolve", + `${url.hostname}:443:127.0.0.1`, + "--url", + `${opts.origin}/`, + ], + cwd: opts.cwd, + env: opts.env, + captures: opts.captures, + timeoutMs: opts.timeoutMs, + outputLimit: 64 * 1024, + }); + await writeFile( + join(opts.captures, "attempt.json"), + `${JSON.stringify({ + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + })}\n`, + { flag: "wx", mode: 0o600 } + ); + return result; +} + /** Shared same-engine fixture ingress: no host ports, DNS or trust writes. */ export async function prepareNativeRoutingFixtureIngress(opts: { readonly ctx: ScenarioContext; readonly docker: (args: readonly string[]) => Promise; }) { const { ctx, docker } = opts; + const executable = findExecutableInPath("docker"); + const tlsEnv = Object.freeze({ ...process.env }) as Readonly< + Record + >; + let tlsAttempt = 0; + expect({ + that: Boolean(executable), + message: "Fixed fixture Docker executable is required", + }); const selectors = [ "--filter", `label=${PROJECT_LABEL}=${PROXY_PROJECT}`, @@ -240,37 +326,25 @@ export async function prepareNativeRoutingFixtureIngress(opts: { const deadline = Date.now() + OBSERVATION_WINDOW; while (Date.now() < deadline) { await proxyOwned(); - const result = await runCommand({ - argv: [ - "docker", - "exec", - currentProxy(), - "curl", - "--disable", - "--silent", - "--show-error", - "--fail", - "--proxy", - "", - "--noproxy", - "*", - "--proto", - "=https", - "--max-redirs", - "0", - "--connect-timeout", - "2", - "--max-time", - "5", - "--cacert", - ROOT_CA, - "--resolve", - `${url.hostname}:443:127.0.0.1`, - "--url", - `${origin}/`, - ], + const remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + if (!executable) { + throw new Error("Fixed fixture Docker executable is required"); + } + const result = await runNativeRoutingFixtureTlsAttempt({ + docker: executable, + proxyId: currentProxy(), + origin, cwd: ctx.tempRoot, - timeoutMs: TIMEOUT, + env: tlsEnv, + captures: join( + ctx.tempRoot, + "routing-tls-attempts", + String(tlsAttempt++) + ), + timeoutMs: Math.min(OBSERVATION_WINDOW, remaining), }); if ( result.exitCode === 0 && diff --git a/tests/native-compose-adoption-routing-fixture.test.ts b/tests/native-compose-adoption-routing-fixture.test.ts index a70b1dec2..a7877ea02 100644 --- a/tests/native-compose-adoption-routing-fixture.test.ts +++ b/tests/native-compose-adoption-routing-fixture.test.ts @@ -73,6 +73,16 @@ test("maintained legacy HTTP fixture has a real apex, existing alias and lossles expect(retainedRoutingFixtureConfig(route).open.prefer).toBe("dev"); expect(retainedRoutingFixtureService(route).image).toBe(IMAGE); }); +test("served legacy Caddy origins use separate address tokens", () => { + const route = selection(); + const label = retainedRoutingFixtureService(route).labels.caddy; + const expected = [route.devHost, route.aliasHost]; + expect(label).toBe(expected.join(", ")); + expect(label.split(/,\s+/)).toEqual(expected); + // The retained actual RED used this one-token spelling and Caddy rejected it. + expect(expected.join(",").split(/,\s+/)).not.toEqual(expected); +}); + test("alpha checkout alias differs from authored and primary-local dev selections", async () => { const root = await mkdtemp(join(tmpdir(), "retained-routing-locals-")); try { diff --git a/tests/native-routing-fixture.test.ts b/tests/native-routing-fixture.test.ts index 7eb9c1650..d752d6143 100644 --- a/tests/native-routing-fixture.test.ts +++ b/tests/native-routing-fixture.test.ts @@ -1,5 +1,19 @@ import { expect, test } from "bun:test"; -import { proxyHasNoPublishedPorts } from "./e2e/scenarios/native-routing-fixture-ingress.ts"; +import { + chmod, + lstat, + mkdtemp, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivate } from "../src/lib/native-compose-private-state.ts"; +import { + proxyHasNoPublishedPorts, + runNativeRoutingFixtureTlsAttempt, +} from "./e2e/scenarios/native-routing-fixture-ingress.ts"; test("unpublished exposed and stopped ports are safe for the isolated proxy", () => { for (const runtimePorts of [null, {}, { "80/tcp": null, "443/tcp": null }]) { @@ -38,3 +52,83 @@ test("missing, malformed and explicit published port facts refuse", () => { expect(proxyHasNoPublishedPorts(facts)).toBe(false); } }); + +test("a failed exact TLS attempt retains bounded private stderr and its exit disposition", async () => { + const root = await mkdtemp(join(tmpdir(), "routing-tls-capture-")); + let settled = false; + try { + const executable = join(root, "docker"); + const id = "a".repeat(64); + const expected = [ + "exec", + id, + "curl", + "--disable", + "--silent", + "--show-error", + "--fail", + "--proxy", + "", + "--noproxy", + "*", + "--proto", + "=https", + "--max-redirs", + "0", + "--connect-timeout", + "2", + "--max-time", + "5", + "--cacert", + "/data/caddy/pki/authorities/local/root.crt", + "--resolve", + "retained-origin.test:443:127.0.0.1", + "--url", + "https://retained-origin.test/", + ]; + await writeFile( + executable, + `#!${process.execPath}\nif(JSON.stringify(process.argv.slice(2))!==${JSON.stringify(JSON.stringify(expected))})process.exit(97);console.error("fixed synthetic TLS alert");process.exit(35);\n`, + { flag: "wx", mode: 0o700 } + ); + await chmod(executable, 0o700); + const captures = join(root, "captures"); + const result = await runNativeRoutingFixtureTlsAttempt({ + docker: executable, + proxyId: id, + origin: "https://retained-origin.test", + cwd: root, + captures, + env: { PATH: "/usr/bin:/bin" }, + timeoutMs: 2000, + }); + settled = true; + expect(result.exitCode).toBe(35); + expect(result.timedOut).toBe(false); + expect(result.stderr).toBe("fixed synthetic TLS alert\n"); + const leaves = await readdir(captures); + expect(leaves.length).toBe(3); + for (const leaf of leaves) { + expect((await lstat(join(captures, leaf))).mode & 0o777).toBe(0o600); + } + const receipt = await readPrivate(join(captures, "attempt.json"), 1024); + expect(JSON.parse(receipt.text)).toEqual({ + exitCode: 35, + timedOut: false, + stdoutBytes: 0, + stderrBytes: 26, + }); + const stderr = leaves.find((leaf) => leaf.endsWith(".stderr")); + expect(stderr).toBeDefined(); + if (!stderr) { + throw new Error("Missing private TLS stderr capture"); + } + expect((await readPrivate(join(captures, stderr), 1024)).text).toBe( + "fixed synthetic TLS alert\n" + ); + } finally { + if (settled) { + await rm(root, { recursive: true, force: true }); + } + } +}); From e50868ecef1dafcc8564f86c45364fc8bc3ad9a2 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 05:08:26 -0400 Subject: [PATCH 6/9] fix: preserve retained routing local open preference --- ...ive-compose-adoption-routing-resolution.ts | 21 ++++++- ...tive-compose-adoption-routing-worktrees.ts | 29 ++++++++- ...e-compose-adoption-routing-fixture.test.ts | 37 ++++++++++++ ...ompose-adoption-routing-resolution.test.ts | 60 ++++++++++++++++++- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/lib/native-compose-adoption-routing-resolution.ts b/src/lib/native-compose-adoption-routing-resolution.ts index 5c9a197de..e44d4a8b7 100644 --- a/src/lib/native-compose-adoption-routing-resolution.ts +++ b/src/lib/native-compose-adoption-routing-resolution.ts @@ -1,7 +1,8 @@ import type { LegacyComposeRoutingIntent } from "./native-config-import-routing.ts"; import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; -/** Effective typed-local precedence may qualify only the already served origins. */ +/** Effective typed-local preference selects among the already served origins. + * It may differ from the raw project preference without changing any route. */ export function legacyComposeRoutingResolutionMatches(opts: { readonly routing: LegacyComposeRoutingIntent; readonly resolution: NativeRoutingResolution | null | undefined; @@ -10,11 +11,25 @@ export function legacyComposeRoutingResolutionMatches(opts: { if ( !resolution || resolution.branch !== undefined || - resolution.project_origin !== `https://${routing.devHost}` || - resolution.open_origin !== routing.openOrigin + resolution.project_origin !== `https://${routing.devHost}` ) { return false; } + const devOrigin = `https://${routing.devHost}`; + const aliasOrigin = routing.aliasHost ? `https://${routing.aliasHost}` : null; + let openOrigin: string; + if (resolution.open_preference === "dev") { + openOrigin = devOrigin; + } else if (resolution.open_preference === "auto") { + openOrigin = aliasOrigin ?? devOrigin; + } else if (resolution.open_preference === "alias" && aliasOrigin) { + openOrigin = aliasOrigin; + } else { + return false; + } + if (resolution.open_origin !== openOrigin) { + return false; + } const aliases = routing.aliasHost ? { oauth: `https://${routing.aliasHost}` } : {}; diff --git a/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts index b72f209e4..40c539595 100644 --- a/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-routing-worktrees.ts @@ -2,7 +2,10 @@ import { createHash } from "node:crypto"; import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "../../../src/lib/guards.ts"; -import { readPrivate } from "../../../src/lib/native-compose-private-state.ts"; +import { + readPrivate, + writeExclusive, +} from "../../../src/lib/native-compose-private-state.ts"; import { nativeComposeProxyRoutesMatch } from "../../../src/lib/native-compose-proxy-routes.ts"; import type { CliResult, Scenario } from "../harness.ts"; import { @@ -122,6 +125,7 @@ async function partialStop(h: Runtime) { const result = await h.cli(h.first, ["config", "adopt", "--stop", "--json"], { PATH: `${root}:${process.env.PATH ?? "/usr/bin:/bin"}`, }); + await retainRoutingAdoptStopResult({ root, result }); if ( result.timedOut || result.exitCode !== 71 || @@ -175,6 +179,29 @@ async function partialStop(h: Runtime) { await h.assertStopped(h.first); } +/** Preserve the known CLI return before the fixture's stop oracle can refuse. + * These private streams are evidence only; they grant no cleanup authority. */ +export async function retainRoutingAdoptStopResult(opts: { + readonly root: string; + readonly result: CliResult; +}): Promise { + const { exitCode, timedOut, stdout, stderr } = opts.result; + if ( + !Number.isInteger(exitCode) || + exitCode < 0 || + exitCode > 255 || + typeof timedOut !== "boolean" || + Buffer.byteLength(stdout) > 64 * 1024 || + Buffer.byteLength(stderr) > 64 * 1024 + ) { + return refuse(); + } + await writeExclusive( + join(opts.root, "adopt-stop-result.json"), + `${JSON.stringify({ phase: "adopt-stop-return", exitCode, timedOut, stdout, stderr })}\n` + ); +} + async function savedOpen(h: Runtime, instance: Instance) { const selected = instance.routing; if (!selected) { diff --git a/tests/native-compose-adoption-routing-fixture.test.ts b/tests/native-compose-adoption-routing-fixture.test.ts index a7877ea02..780177a7f 100644 --- a/tests/native-compose-adoption-routing-fixture.test.ts +++ b/tests/native-compose-adoption-routing-fixture.test.ts @@ -22,11 +22,48 @@ import { import { nativeComposeAdoptionRoutingWorktreesScenario, retainedRoutingPartialStopScript, + retainRoutingAdoptStopResult, } from "./e2e/scenarios/native-compose-adoption-routing-worktrees.ts"; import { ownedAdoptionFixtureObservation } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; const IMAGE = `sha256:${"a".repeat(64)}`; const IDS = ["b".repeat(64), "c".repeat(64), "d".repeat(64)] as const; +test("first adoption refusal preserves bounded private result before the stop oracle", async () => { + const root = await mkdtemp(join(tmpdir(), "retained-routing-result-")); + try { + const result = { + command: "not-persisted", + exitCode: 1, + timedOut: false, + stdout: '{"ok":false,"error":{"code":"E_CONFIG_INVALID"}}', + stderr: "synthetic refusal detail", + combined: "not-persisted", + durationMs: 1, + }; + const path = join(root, "adopt-stop-result.json"); + await expect( + retainRoutingAdoptStopResult({ + root, + result: { ...result, stdout: "x".repeat(64 * 1024 + 1) }, + }) + ).rejects.toThrow(); + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + await retainRoutingAdoptStopResult({ root, result }); + expect(JSON.parse((await readPrivate(path, 256 * 1024)).text)).toEqual({ + phase: "adopt-stop-return", + exitCode: 1, + timedOut: false, + stdout: result.stdout, + stderr: result.stderr, + }); + expect((await lstat(path)).mode & 0o777).toBe(0o600); + await expect( + retainRoutingAdoptStopResult({ root, result }) + ).rejects.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); function selection(prefer: "alias" | "dev" = "alias") { return retainedRoutingFixtureSelection({ image: IMAGE, diff --git a/tests/native-compose-adoption-routing-resolution.test.ts b/tests/native-compose-adoption-routing-resolution.test.ts index 1a3e48604..f2abc04a3 100644 --- a/tests/native-compose-adoption-routing-resolution.test.ts +++ b/tests/native-compose-adoption-routing-resolution.test.ts @@ -4,13 +4,13 @@ import { resolveNativeComposeOpenOrigin } from "../src/lib/native-compose-open.t import { mapLegacyComposeRouting } from "../src/lib/native-config-import-routing.ts"; import type { NativeRoutingResolution } from "../src/lib/native-routing-plan-protocol.ts"; -function fixture() { +function fixture(authoredPreference: "auto" | "alias" | "dev" = "alias") { const routing = mapLegacyComposeRouting({ config: { name: "fixture", dev_host: "original.hack.local", oauth: { enabled: true }, - open: { prefer: "alias" }, + open: { prefer: authoredPreference }, }, compose: { name: "fixture", @@ -71,6 +71,62 @@ test("typed-local domain precedence may retain exact served origins and saved op }) ).toBe("https://original.hack.gy"); }); +test("effective checkout preference can differ from raw project preference without changing served origins", () => { + for (const [authored, effective, expected] of [ + ["dev", "alias", "https://original.hack.gy"], + ["alias", "dev", "https://original.hack.local"], + ["dev", "auto", "https://original.hack.gy"], + ] as const) { + const { routing, resolution } = fixture(authored); + const selected: NativeRoutingResolution = { + ...resolution, + open_preference: effective, + open_preference_origin: "checkout_local", + open_origin: expected, + }; + expect(routing.openOrigin).not.toBe(expected); + expect( + legacyComposeRoutingResolutionMatches({ routing, resolution: selected }) + ).toBe(true); + expect(resolveNativeComposeOpenOrigin({ resolution: selected })).toBe( + expected + ); + } +}); +test("effective preference cannot select an inconsistent or unavailable origin", () => { + const { routing, resolution } = fixture("dev"); + const web = resolution.routes.web; + if (!web) { + throw new Error("Synthetic route missing"); + } + for (const change of [ + { open_preference: "dev", open_origin: "https://original.hack.gy" }, + { open_preference: "alias", open_origin: "https://original.hack.local" }, + { open_preference: "auto", open_origin: "https://original.hack.local" }, + ] as const) { + expect( + legacyComposeRoutingResolutionMatches({ + routing, + resolution: { ...resolution, ...change }, + }) + ).toBe(false); + } + expect( + legacyComposeRoutingResolutionMatches({ + routing: { ...routing, aliasHost: null }, + resolution: { + ...resolution, + aliases: {}, + oauth_alias: null, + open_preference: "alias", + open_origin: "https://original.hack.local", + routes: { + web: { ...web, aliases: {} }, + }, + }, + }) + ).toBe(false); +}); for (const change of [ { branch: "other" }, { project_origin: "https://renamed.test" }, From 3fb9319d929e08ed2dc7cf5cff8625764573f342 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 05:48:41 -0400 Subject: [PATCH 7/9] fix: retain owner-issued process policy refusal diagnostics --- .github/workflows/ci.yml | 1 + src/lib/native-compose-ownership.ts | 143 +++++++++++++----- .../native-process-policy-initial-replay.ts | 99 +++++++++--- .../scenarios/native-config-process-policy.ts | 13 +- tests/native-compose-ownership.test.ts | 105 +++++++++++-- ...ative-process-policy-initial-trace.test.ts | 104 ++++++++++++- ...ative-process-policy-replay-reason.test.ts | 20 +-- 7 files changed, 396 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31345967d..29c7115f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,7 @@ jobs: run: HACK_TEST_COMPOSE_CONFIG=1 bun test tests/native-compose-renderer-config.test.ts --test-name-pattern '^one authored bridge keeps internal policy and static aliases through compiler and Compose normalization$' - name: Run local and Docker E2E env: + HACK_E2E_PROCESS_POLICY_REFUSAL_CAPSULE: "1" HACK_E2E_CLI_BIN: ${{ github.workspace }}/dist/hack run: | HACK_E2E_DOCKER_HOST="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}" diff --git a/src/lib/native-compose-ownership.ts b/src/lib/native-compose-ownership.ts index fe852b0c2..b9a17c8c0 100644 --- a/src/lib/native-compose-ownership.ts +++ b/src/lib/native-compose-ownership.ts @@ -155,6 +155,28 @@ export type NativeComposeProbeFailure = | "decode"; const probeFailures = new WeakMap(); +/** The first refused ownership predicate only; never a claim about its external cause. */ +export type NativeComposeOwnershipRefusal = + | "resource-label" + | "generation" + | "state" + | "volume-birth" + | "bridge-policy" + | "topology" + | "endpoint" + | "cross-scan-drift" + | "unknown"; +const ownershipRefusals = new WeakMap(); + +/** Only captured-reply replay reads this diagnostic; callers cannot mint it from error properties. */ +export function nativeComposeOwnershipRefusal( + error: unknown +): NativeComposeOwnershipRefusal | undefined { + return typeof error === "object" && error !== null + ? ownershipRefusals.get(error) + : undefined; +} + /** Copies, prototypes and caller-created errors cannot acquire an observed probe classification. */ export function nativeComposeProbeFailure( error: unknown @@ -196,12 +218,20 @@ export class NativeComposeOwnershipError extends Error { this.code = code; } } -function refuse(code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP"): never { - throw new NativeComposeOwnershipError(code); +function refuse( + code: FailureCode = "E_NATIVE_COMPOSE_OWNERSHIP", + reason: NativeComposeOwnershipRefusal = "unknown" +): never { + const error = new NativeComposeOwnershipError(code); + ownershipRefusals.set(error, reason); + throw error; } -function requireValue(value: unknown): asserts value { +function requireValue( + value: unknown, + reason: NativeComposeOwnershipRefusal = "unknown" +): asserts value { if (!value) { - refuse(); + refuse("E_NATIVE_COMPOSE_OWNERSHIP", reason); } } @@ -229,7 +259,7 @@ export function mergeNativeComposeNetworkPolicies(opts: { next.internal === previous.internal ) ) { - return refuse("E_NATIVE_COMPOSE_NETWORK_TRANSITION"); + return refuse("E_NATIVE_COMPOSE_NETWORK_TRANSITION", "bridge-policy"); } } } @@ -484,7 +514,8 @@ async function queryNativeComposeOwned( current.storage === volume.storage && (volume.createdAt === undefined || current.createdAt === volume.createdAt) - ) + ), + "volume-birth" ); } } @@ -522,21 +553,25 @@ async function queryNativeComposeOwned( first.name === second.name && first.generationId === second.generationId && first.service === second.service && - first.oneoff === second.oneoff + first.oneoff === second.oneoff, + "cross-scan-drift" ); } requireValue( JSON.stringify(topologySnapshot(observations, restarting)) === - JSON.stringify(topologySnapshot(rechecked, restarting)) + JSON.stringify(topologySnapshot(rechecked, restarting)), + "cross-scan-drift" ); requireValue( JSON.stringify(volumeSnapshot(observations)) === - JSON.stringify(volumeSnapshot(rechecked)) + JSON.stringify(volumeSnapshot(rechecked)), + "cross-scan-drift" ); for (const kind of ["container", "volume", "network"] as const) { requireValue( JSON.stringify(await inventory(kind)) === - JSON.stringify(selected.get(kind)) + JSON.stringify(selected.get(kind)), + "cross-scan-drift" ); } return { @@ -594,19 +629,22 @@ async function collectInspections(input: { remaining.delete(row.id); requireValue( row.name === - (kind === "container" ? `/${resource.name}` : resource.name) + (kind === "container" ? `/${resource.name}` : resource.name), + "resource-label" ); requireValue( - row.project === opts.composeProject && row.project === resource.project + row.project === opts.composeProject && row.project === resource.project, + "resource-label" ); requireValue( row.version === "1" && row.instance === opts.runtimeIdentity && - row.owner === opts.ownerToken + row.owner === opts.ownerToken, + "resource-label" ); if (kind === "container") { containers.push(containerObservation(row, opts)); - requireValue(isRecord(row.networks)); + requireValue(isRecord(row.networks), "endpoint"); observations.endpoints.set(resource.id, row.networks); } else if (kind === "volume") { requireValue( @@ -622,18 +660,25 @@ async function collectInspections(input: { ]) ); requireValue( - typeof row.storage === "string" && SERVICE.test(row.storage) + typeof row.storage === "string" && SERVICE.test(row.storage), + "resource-label" ); const expectedVolume = opts.expectedVolumes?.find( (volume) => volume.name === resource.name ); requireValue( - expectedVolume !== undefined && expectedVolume.storage === row.storage + expectedVolume !== undefined && + expectedVolume.storage === row.storage, + "resource-label" + ); + requireValue( + nativeComposeVolumeCreatedAt(row.createdAt), + "volume-birth" ); - requireValue(nativeComposeVolumeCreatedAt(row.createdAt)); requireValue( expectedVolume.createdAt === undefined || - expectedVolume.createdAt === row.createdAt + expectedVolume.createdAt === row.createdAt, + "volume-birth" ); volumes.push({ name: resource.name, @@ -660,11 +705,15 @@ async function collectInspections(input: { requireValue( expected !== undefined && row.driver === expected.driver && - row.internal === expected.internal + row.internal === expected.internal, + "bridge-policy" ); - requireValue(isRecord(row.containers)); + requireValue(isRecord(row.containers), "topology"); const members = Object.keys(row.containers); - requireValue(members.every((id) => ID.test(id))); + requireValue( + members.every((id) => ID.test(id)), + "topology" + ); observations.members.set(resource.name, members.sort()); networks.push({ id: resource.id, name: resource.name }); } @@ -695,17 +744,20 @@ function containerObservation( ); requireValue( typeof row.generation === "string" && - opts.generationIds.includes(row.generation) + opts.generationIds.includes(row.generation), + "generation" ); requireValue( typeof row.service === "string" && - opts.expectedServices.includes(row.service) + opts.expectedServices.includes(row.service), + "resource-label" ); requireValue( row.oneoff === "True" || row.oneoff === "False" || row.oneoff === "true" || - row.oneoff === "false" + row.oneoff === "false", + "resource-label" ); requireValue( row.state === "created" || @@ -714,18 +766,21 @@ function containerObservation( row.state === "removing" || row.state === "paused" || row.state === "exited" || - row.state === "dead" + row.state === "dead", + "state" ); requireValue( typeof row.exitCode === "number" && Number.isSafeInteger(row.exitCode) && - row.exitCode >= 0 + row.exitCode >= 0, + "state" ); requireValue( row.health === null || row.health === "starting" || row.health === "healthy" || - row.health === "unhealthy" + row.health === "unhealthy", + "state" ); requireValue(typeof row.id === "string" && ID.test(row.id)); requireValue(typeof row.name === "string"); @@ -745,7 +800,8 @@ function endpointAliases(value: unknown): readonly string[] { requireValue( Array.isArray(value) && value.every((alias) => typeof alias === "string" && NAME.test(alias)) && - new Set(value).size === value.length + new Set(value).size === value.length, + "endpoint" ); return [...new Set(value as string[])].sort(); } @@ -767,7 +823,8 @@ function validateEndpointIdentity(opts: { if (unrealizedCreatedEndpoint) { requireValue( id !== undefined && - (endpoint.NetworkID === undefined || endpoint.NetworkID === "") + (endpoint.NetworkID === undefined || endpoint.NetworkID === ""), + "endpoint" ); if ( endpoint.Aliases === undefined || @@ -777,16 +834,20 @@ function validateEndpointIdentity(opts: { return; } } else if (absentOwnedRecovery) { - requireValue(endpoint.NetworkID === undefined || endpoint.NetworkID === ""); + requireValue( + endpoint.NetworkID === undefined || endpoint.NetworkID === "", + "endpoint" + ); if (endpoint.Aliases === undefined || endpoint.Aliases === null) { return; } } else { - requireValue(id !== undefined && endpoint.NetworkID === id); + requireValue(id !== undefined && endpoint.NetworkID === id, "endpoint"); } requireValue( JSON.stringify(endpointAliases(endpoint.Aliases)) === - JSON.stringify(expectedAliases) + JSON.stringify(expectedAliases), + "endpoint" ); } @@ -869,7 +930,8 @@ function requireLiveMember(opts: { networkPolicies(selection).length === 1 && networkPolicies(selection)[0]?.internal === false && networkId !== undefined && - endpoint.NetworkID === networkId + endpoint.NetworkID === networkId, + "topology" ); return true; } @@ -894,7 +956,8 @@ function validateTopology( (id) => containers.has(id) && Object.hasOwn(observations.endpoints.get(id) ?? {}, name) - ) + ), + "topology" ); } for (const container of observations.containers) { @@ -903,18 +966,19 @@ function validateTopology( workload.generationId === container.generationId && workload.service === container.service ); - requireValue(policy !== undefined); + requireValue(policy !== undefined, "topology"); const endpoints = observations.endpoints.get(container.id); requireValue( endpoints !== undefined && hasKeys( endpoints, policy.networks.map((network) => network.name) - ) + ), + "topology" ); for (const attachment of policy.networks) { const endpoint = endpoints[attachment.name]; - requireValue(isRecord(endpoint)); + requireValue(isRecord(endpoint), "endpoint"); const id = attachment.externalId ?? owned.get(attachment.name); const absentOwnedRecovery = opts.recovery === "down" && @@ -930,7 +994,8 @@ function validateTopology( }); if (unrealizedCreatedEndpoint) { requireValue( - !observations.members.get(attachment.name)?.includes(container.id) + !observations.members.get(attachment.name)?.includes(container.id), + "topology" ); } const expected = container.oneoff @@ -984,7 +1049,7 @@ function topologySnapshot( id, Object.entries(endpoints) .map(([name, endpoint]) => { - requireValue(isRecord(endpoint)); + requireValue(isRecord(endpoint), "endpoint"); return [ name, endpoint.NetworkID, diff --git a/tests/e2e/native-process-policy-initial-replay.ts b/tests/e2e/native-process-policy-initial-replay.ts index 5f103492e..ee14e83a6 100644 --- a/tests/e2e/native-process-policy-initial-replay.ts +++ b/tests/e2e/native-process-policy-initial-replay.ts @@ -3,16 +3,15 @@ import { join } from "node:path"; import { isRecord } from "../../src/lib/guards.ts"; import { openNativeComposeGenerationStore } from "../../src/lib/native-compose-generation.ts"; import { readNativeComposeNetworkTopology } from "../../src/lib/native-compose-network-topology.ts"; -import type { NativeComposeOwnershipOptions } from "../../src/lib/native-compose-ownership.ts"; +import type { + NativeComposeOwnershipOptions, + NativeComposeOwnershipRefusal, +} from "../../src/lib/native-compose-ownership.ts"; import { exec } from "../../src/lib/shell.ts"; import { type ProcessPolicyInitialTraceQuery, readProcessPolicyInitialTrace, } from "./native-process-policy-initial-trace.ts"; -import { - isProcessPolicyReplayReason, - type ProcessPolicyReplayReason, -} from "./native-process-policy-replay-reason.ts"; const REFUSAL = "Initial process-policy replay unavailable; values omitted"; const SERVICES = ["forced", "graceful", "reaper", "retry"] as const; @@ -26,13 +25,28 @@ const CODES = [ type Replay = { readonly outcome: "owned" | "unready" | "refused"; readonly code: (typeof CODES)[number] | null; + /** First replayed ownership predicate only; absent for success or unclassified probe errors. */ + readonly reason: NativeComposeOwnershipRefusal | null; readonly consumed: number; readonly protocolMatched: boolean; - readonly reason: ProcessPolicyReplayReason | null; }; function replayCode(value: unknown): value is Replay["code"] { return value === null || CODES.some((code) => code === value); } +function replayReason(value: unknown): value is Replay["reason"] { + return ( + value === null || + value === "resource-label" || + value === "generation" || + value === "state" || + value === "volume-birth" || + value === "bridge-policy" || + value === "topology" || + value === "endpoint" || + value === "cross-scan-drift" || + value === "unknown" + ); +} /** Run the real ownership policy against original recorded replies, with no engine access. */ export async function replayProcessPolicyInitialOwnership(opts: { @@ -70,14 +84,11 @@ await Bun.write(Bun.stdout,row.stdout);process.exit(row.exitCode); ); await chmod(docker, 0o700); const program = ` -import {captureProcessPolicyOwnershipSource,sameProcessPolicyOwnershipSource,processPolicyReplayReason} from ${JSON.stringify(join(import.meta.dir, "native-process-policy-replay-reason.ts"))}; -const sourceBefore=captureProcessPolicyOwnershipSource(); -const {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError}=await import(${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}); +import {assertNativeComposeOwned,observeNativeComposeStartupOwned,NativeComposeOwnershipError,nativeComposeOwnershipRefusal} from ${JSON.stringify(join(import.meta.dir, "../../src/lib/native-compose-ownership.ts"))}; const {selection}=await Bun.file(${JSON.stringify(inputs)}).json(); let outcome="refused",code=null,reason=null; try {const value=${opts.mode === "startup" ? 'await observeNativeComposeStartupOwned(selection,["retry"])' : "await assertNativeComposeOwned(selection)"};outcome=value===null?"unready":"owned";} -catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=code==="E_NATIVE_COMPOSE_OWNERSHIP"&&sourceBefore?processPolicyReplayReason({stack:error.stack,sourcePath:sourceBefore.path,sourceSha256:sourceBefore.sha256}):"unavailable";} -if(outcome==="refused"&&!sameProcessPolicyOwnershipSource(sourceBefore,captureProcessPolicyOwnershipSource()))reason="unavailable"; +catch(error){code=error instanceof NativeComposeOwnershipError?error.code:null;reason=nativeComposeOwnershipRefusal(error)??null;} const consumed=(await Bun.file(${JSON.stringify(cursor)}).exists())?Number(await Bun.file(${JSON.stringify(cursor)}).text()):0; process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatched:!(await Bun.file(${JSON.stringify(mismatch)}).exists())})); `; @@ -101,13 +112,9 @@ process.stdout.write(JSON.stringify({outcome,code,reason,consumed,protocolMatche value.outcome === "unready" || value.outcome === "refused") && replayCode(value.code) && - (value.reason === null || isProcessPolicyReplayReason(value.reason)) && - (value.outcome === "refused" - ? value.reason !== null - : value.reason === null) && - (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" || - value.reason === null || - value.reason === "unavailable") && + replayReason(value.reason) && + (value.outcome === "refused" || value.reason === null) && + (value.code === "E_NATIVE_COMPOSE_OWNERSHIP" || value.reason === null) && Number.isInteger(value.consumed) && typeof value.consumed === "number" && value.consumed >= 0 && @@ -276,3 +283,59 @@ export async function summarizeProcessPolicyInitialTrace(opts: { await store.close(); } } + +/** Explicit opt-in persists only the first owner-issued refusal from matching after-Compose + * recorded replies. This cannot identify the original caller mode or timing. */ +export async function persistProcessPolicyFirstAfterComposeRefusal(opts: { + readonly directory: string; + readonly enabled: string | undefined; + readonly summary: Awaited< + ReturnType + >; +}) { + if (opts.enabled !== "1") { + return null; + } + const row = opts.summary.observations.find( + (observation) => + observation.phase === "after-compose" && + observation.startup.protocolMatched && + observation.strict.protocolMatched && + [observation.startup, observation.strict].some( + (mode) => + mode.outcome === "refused" && + mode.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ) + ); + if (!row) { + return null; + } + const capsule = { + version: 1 as const, + kind: "native-process-policy-after-compose-replay-refusal" as const, + observationIndex: row.index, + replayUsesRecordedReplies: true as const, + replaysWallTiming: false as const, + originalCallerModeKnown: false as const, + startupReason: + row.startup.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ? row.startup.reason + : null, + strictReason: + row.strict.code === "E_NATIVE_COMPOSE_OWNERSHIP" + ? row.strict.reason + : null, + }; + const handle = await open( + join(opts.directory, "first-after-compose-replay-refusal.json"), + "wx", + 0o600 + ); + try { + await handle.writeFile(`${JSON.stringify(capsule)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + return capsule; +} diff --git a/tests/e2e/scenarios/native-config-process-policy.ts b/tests/e2e/scenarios/native-config-process-policy.ts index 88a20ae75..aa52125fe 100644 --- a/tests/e2e/scenarios/native-config-process-policy.ts +++ b/tests/e2e/scenarios/native-config-process-policy.ts @@ -16,7 +16,10 @@ import { type Scenario, } from "../harness.ts"; import { prepareNativeEngineTripwire } from "../native-engine-tripwire.ts"; -import { summarizeProcessPolicyInitialTrace } from "../native-process-policy-initial-replay.ts"; +import { + persistProcessPolicyFirstAfterComposeRefusal, + summarizeProcessPolicyInitialTrace, +} from "../native-process-policy-initial-replay.ts"; import { prepareProcessPolicyInitialTrace } from "../native-process-policy-initial-trace.ts"; import { isKnownUncertainProcessPolicyStartup, @@ -733,6 +736,14 @@ export const nativeConfigProcessPolicyScenario: Scenario = { ctx.log( `fixed-field original-query replay: ${JSON.stringify(replay)}` ); + const capsule = await persistProcessPolicyFirstAfterComposeRefusal({ + directory: ctx.tempRoot, + enabled: process.env.HACK_E2E_PROCESS_POLICY_REFUSAL_CAPSULE, + summary: replay, + }); + ctx.log( + `fixed-field first after-compose replay refusal: ${JSON.stringify(capsule)}` + ); } catch { stage( "fixed-field original-query replay unavailable; no cause inferred" diff --git a/tests/native-compose-ownership.test.ts b/tests/native-compose-ownership.test.ts index f758b63a3..5bbca5bc0 100644 --- a/tests/native-compose-ownership.test.ts +++ b/tests/native-compose-ownership.test.ts @@ -7,6 +7,8 @@ import { mergeNativeComposeNetworkPolicies, NativeComposeOwnershipError, type NativeComposeOwnershipOptions, + type NativeComposeOwnershipRefusal, + nativeComposeOwnershipRefusal, observeNativeComposeStartupOwned, observeSavedNativeComposeOwned, } from "../src/lib/native-compose-ownership.ts"; @@ -172,7 +174,8 @@ async function commands(): Promise { } async function expectRefusal( opts = options, - code = "E_NATIVE_COMPOSE_OWNERSHIP" + code = "E_NATIVE_COMPOSE_OWNERSHIP", + reason?: NativeComposeOwnershipRefusal ) { try { await assertNativeComposeOwned(opts); @@ -180,12 +183,73 @@ async function expectRefusal( } catch (error: unknown) { expect(error).toBeInstanceOf(NativeComposeOwnershipError); expect(error).toMatchObject({ code }); + if (reason !== undefined) { + expect(nativeComposeOwnershipRefusal(error)).toBe(reason); + } expect(String(error)).not.toContain(CANARY); expect(JSON.stringify(error)).not.toContain(CANARY); } expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); } +test("ownership refusal diagnostics cannot be forged, copied or obtained through getters", () => { + let issued: unknown; + try { + mergeNativeComposeNetworkPolicies({ + proposed: [], + retained: [ + [{ name: "fixture_default", driver: "bridge", internal: false }], + ], + }); + } catch (error: unknown) { + issued = error; + } + expect(issued).toBeInstanceOf(NativeComposeOwnershipError); + if (!(issued instanceof NativeComposeOwnershipError)) { + throw new Error("Missing owner-issued refusal"); + } + expect(nativeComposeOwnershipRefusal(issued)).toBe("bridge-policy"); + expect(issued).toMatchObject({ code: "E_NATIVE_COMPOSE_NETWORK_TRANSITION" }); + expect(String(issued)).toBe( + "NativeComposeOwnershipError: Native Compose network topology changed. Run hack down for this instance before applying the change; values omitted." + ); + expect(JSON.parse(JSON.stringify(issued))).toEqual({ + code: "E_NATIVE_COMPOSE_NETWORK_TRANSITION", + name: "NativeComposeOwnershipError", + }); + const clone = { ...issued, reason: "bridge-policy", secret: CANARY }; + let getters = 0; + const accessor = Object.defineProperty({}, "reason", { + get() { + getters += 1; + throw new Error(CANARY); + }, + }); + const proxy = new Proxy( + {}, + { + get() { + getters += 1; + throw new Error(CANARY); + }, + } + ); + for (const candidate of [ + new NativeComposeOwnershipError("E_NATIVE_COMPOSE_OWNERSHIP"), + clone, + Object.create(issued), + accessor, + proxy, + null, + undefined, + CANARY, + 1, + ]) { + expect(nativeComposeOwnershipRefusal(candidate)).toBeUndefined(); + } + expect(getters).toBe(0); +}); + test("selected on-failure restart is unready until two stable owned scans", async () => { const fixture = owned(); const container = fixture.container?.[0]; @@ -198,7 +262,7 @@ test("selected on-failure restart is unready until two stable owned scans", asyn await prepare(fixture); expect(await observeNativeComposeStartupOwned(options, ["web"])).toBeNull(); // The same observation must never authorize effect or finalization ownership. - await expectRefusal(options); + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", "topology"); fixture.mode = "restart-member-transition"; await prepare(fixture); await expectRefusal(options); @@ -453,7 +517,11 @@ test("old or missing owner tokens never adopt same-instance resources", async () } row.owner = owner; await prepare(fixture); - await expectRefusal(); + await expectRefusal( + options, + "E_NATIVE_COMPOSE_OWNERSHIP", + "resource-label" + ); } } }); @@ -465,7 +533,7 @@ test("unsolicited raw inspect fields are rejected without disclosing private val } row.Config = { Env: [CANARY], Image: CANARY }; await prepare(fixture); - await expectRefusal(); + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", "unknown"); }); test("every project container is checked, including unknown services and stale or absent generation", async () => { for (const fields of [ @@ -494,11 +562,23 @@ test("every project container is checked, including unknown services and stale o state: "running", exitCode: 0, health: null, + networks: { + [`${PROJECT}_default`]: { + NetworkID: NETWORK_ID, + Aliases: ["otherwise-unexpected-name", "web"], + }, + }, ...fields, }, ]; await prepare(fixture); - await expectRefusal(); + const reason = + "generation" in fields + ? "generation" + : "state" in fields || "health" in fields || "exitCode" in fields + ? "state" + : "resource-label"; + await expectRefusal(options, "E_NATIVE_COMPOSE_OWNERSHIP", reason); } }); test("persistent storage must match exact generated name and logical storage ownership", async () => { @@ -533,7 +613,7 @@ test("retained volume policy requires exact presence and birth while cold invent { name: VOLUME, storage: "data", createdAt: CREATED }, ]); await prepare({}); - await expectRefusal(retained); + await expectRefusal(retained, "E_NATIVE_COMPOSE_OWNERSHIP", "volume-birth"); // Legacy history can require presence without inventing a prior birth. await expectRefusal({ ...options, @@ -547,7 +627,7 @@ test("retained volume policy requires exact presence and birth while cold invent } volume.createdAt = REBORN; await prepare(replaced); - await expectRefusal(retained); + await expectRefusal(retained, "E_NATIVE_COMPOSE_OWNERSHIP", "volume-birth"); }); test.each([ null, @@ -614,7 +694,8 @@ test("vanished resources and changed selected inventory refuse without retry or options, mode === "vanished" ? "E_NATIVE_COMPOSE_PROBE" - : "E_NATIVE_COMPOSE_OWNERSHIP" + : "E_NATIVE_COMPOSE_OWNERSHIP", + mode === "vanished" ? undefined : "cross-scan-drift" ); } }); @@ -833,7 +914,13 @@ test("custom bridge driver, internal flag, aliases, generation and unexpected en fixture.mode = change; } await prepare(fixture); - await expectRefusal(selection); + const reason = + change === "driver" || change === "internal" + ? "bridge-policy" + : change === "alias" || change === "endpoint-drift" + ? "endpoint" + : "topology"; + await expectRefusal(selection, "E_NATIVE_COMPOSE_OWNERSHIP", reason); expect(await Bun.file(join(root, "mutated")).exists()).toBe(false); } }); diff --git a/tests/native-process-policy-initial-trace.test.ts b/tests/native-process-policy-initial-trace.test.ts index f0c9b7126..43f7593d5 100644 --- a/tests/native-process-policy-initial-trace.test.ts +++ b/tests/native-process-policy-initial-trace.test.ts @@ -1,5 +1,14 @@ import { expect, test } from "bun:test"; -import { chmod, mkdtemp, readdir, rm, symlink, unlink } from "node:fs/promises"; +import { + chmod, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + unlink, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -7,7 +16,10 @@ import { type NativeComposeOwnershipOptions, } from "../src/lib/native-compose-ownership.ts"; import { exec } from "../src/lib/shell.ts"; -import { replayProcessPolicyInitialOwnership } from "./e2e/native-process-policy-initial-replay.ts"; +import { + persistProcessPolicyFirstAfterComposeRefusal, + replayProcessPolicyInitialOwnership, +} from "./e2e/native-process-policy-initial-replay.ts"; import { type ProcessPolicyInitialTraceQuery, prepareProcessPolicyInitialTrace, @@ -404,7 +416,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(strict).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "live-network-membership", + reason: "topology", consumed: 6, protocolMatched: true, }); @@ -430,7 +442,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(policy).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "network-policy", + reason: "bridge-policy", consumed: 6, protocolMatched: true, }); @@ -453,13 +465,91 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc expect(aliases).toEqual({ outcome: "refused", code: "E_NATIVE_COMPOSE_OWNERSHIP", - reason: "endpoint-aliases", + reason: "endpoint", consumed: 6, protocolMatched: true, }); expect(JSON.stringify({ policy, aliases })).not.toContain(owner); expect(JSON.stringify({ policy, aliases })).not.toContain(id); expect(JSON.stringify({ policy, aliases })).not.toContain(import.meta.dir); + const summary: Parameters< + typeof persistProcessPolicyFirstAfterComposeRefusal + >[0]["summary"] = { + status: "captured", + replayUsesRecordedReplies: true, + replaysWallTiming: false, + originalCallerModeKnown: false, + queryCount: queries.length, + consumedQueries: queries.length, + complete: true, + observations: [ + { index: 0, phase: "before-compose", startup: strict, strict }, + { index: 1, phase: "after-compose", startup, strict }, + { index: 2, phase: "after-compose", startup: strict, strict }, + ], + }; + // Disabled and malformed opt-ins cannot create a capsule, even for a matching refusal. + for (const enabled of [undefined, "", "0", "true", "1\n"]) { + expect( + await persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled, + summary, + }) + ).toBeNull(); + expect( + await Bun.file( + join(root, "first-after-compose-replay-refusal.json") + ).exists() + ).toBe(false); + } + const capsule = await persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled: "1", + summary, + }); + expect(capsule).toEqual({ + version: 1, + kind: "native-process-policy-after-compose-replay-refusal", + observationIndex: 1, + replayUsesRecordedReplies: true, + replaysWallTiming: false, + originalCallerModeKnown: false, + startupReason: null, + strictReason: "topology", + }); + const capsulePath = join(root, "first-after-compose-replay-refusal.json"); + const capsuleBytes = await readFile(capsulePath, "utf8"); + expect(JSON.parse(capsuleBytes)).toEqual(capsule); + expect((await stat(capsulePath)).mode & 0o777).toBe(0o600); + expect(capsuleBytes).not.toContain(owner); + expect(capsuleBytes).not.toContain(id); + expect(capsuleBytes).not.toContain(CANARY); + await expect( + persistProcessPolicyFirstAfterComposeRefusal({ + directory: root, + enabled: "1", + summary, + }) + ).rejects.toThrow(); + expect(await readFile(capsulePath, "utf8")).toBe(capsuleBytes); + expect( + await persistProcessPolicyFirstAfterComposeRefusal({ + directory: join(root, "unmatched"), + enabled: "1", + summary: { + ...summary, + observations: [ + { + index: 1, + phase: "after-compose", + startup, + strict: { ...strict, protocolMatched: false }, + }, + ], + }, + }) + ).toBeNull(); const missing = await replayProcessPolicyInitialOwnership({ directory: join(root, "missing"), queries: queries.slice(0, 1), @@ -468,7 +558,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc }); expect(missing.protocolMatched).toBe(false); expect(missing.outcome).toBe("refused"); - expect(missing.reason).toBe("unavailable"); + expect(missing.reason).toBeNull(); const first = queries[0]; if (!first) { throw new Error("Missing synthetic query"); @@ -481,7 +571,7 @@ test("trace replay exposes startup vs strict ownership without accepting a trunc }); expect(changed.protocolMatched).toBe(false); expect(changed.outcome).toBe("refused"); - expect(changed.reason).toBe("unavailable"); + expect(changed.reason).toBeNull(); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/tests/native-process-policy-replay-reason.test.ts b/tests/native-process-policy-replay-reason.test.ts index 5c3530369..f50f03560 100644 --- a/tests/native-process-policy-replay-reason.test.ts +++ b/tests/native-process-policy-replay-reason.test.ts @@ -19,23 +19,13 @@ function stack(caller: string): string { return `${header}\n at refuse (${sourcePath}:200:9)\n at requireValue (${sourcePath}:204:5)\n at ${caller}\n at synthetic (private-canary:1:1)`; } -test("replay source pin verifies the actual canonical owner and refuses changed identity correspondence", () => { +test("historical stack classifier refuses the superseding owner source", () => { + // The replay now uses owner-issued reasons; the old line/hash classifier stays unavailable. const before = captureProcessPolicyOwnershipSource(); const after = captureProcessPolicyOwnershipSource(); - expect(before?.sha256).toBe(sourceSha256); - expect(before?.path).toBe(sourcePath); - expect(sameProcessPolicyOwnershipSource(before, after)).toBe(true); - if (!before) { - throw new Error("Missing canonical source pin"); - } - expect( - sameProcessPolicyOwnershipSource(before, { - ...before, - identity: "replaced", - }) - ).toBe(false); - expect(sameProcessPolicyOwnershipSource(before, null)).toBe(false); - expect(sameProcessPolicyOwnershipSource(null, after)).toBe(false); + expect(before).toBeNull(); + expect(after).toBeNull(); + expect(sameProcessPolicyOwnershipSource(before, after)).toBe(false); }); test("only fixed immediate owning callsites can name a replay reason", () => { From 742625a88853d5e4e784129c17415b7ea2da040a Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 09:17:00 -0400 Subject: [PATCH 8/9] fix: reuse publication routing entry proof --- docs/reference/native-compose-adoption.md | 6 + src/lib/native-compose-adoption-generation.ts | 27 +++- ...ompose-adoption-routing-generation.test.ts | 124 ++++++++++++++++++ 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 02228d1e6..1541c6f2e 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -549,6 +549,12 @@ fresh. A complete resource/runtime observation brackets the proof, including final volume and inventory rereads. The context is revoked before return and cannot cross a lifecycle effect, publication or another observation phase. This reduces nested inspection calls; it is not a measured runtime or CPU claim. +Before moving originals, publication uses its just-completed resource observation +as that proof's entry. Candidate admission and stopped-state reads do not extend +the observation into an effect: source, receipt, claims and stopped state are +rechecked inside the scoped context, and a complete fresh binding remains the +exit gate before originals can move. The same absolute publication deadline +applies; an already expired entry still refuses. Publication refusals may include `legacy_adoption_publication_refusal: {stage, reason}` in the JSON error detail. diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index e46489eb7..6440d204a 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -815,13 +815,17 @@ async function assertRetainedRouteState( deadline: number, assertOwner: ( current: Context - ) => Promise>> + ) => Promise>>, + entryObserved = false ): Promise { const binding = loaded.inputs.binding; if (binding.binding_version !== 14 || retainedRouteObservations.has(ctx)) { refuse(); } - const first = await assertOwner(ctx); + // Publication has just completed this full resource proof, followed only by + // candidate admission and stopped-state reads. Recheck source/receipt authority + // under the scoped observation; the exit still performs a full fresh binding. + const first = entryObserved ? loaded : await assertOwner(ctx); if ( first.manifest.id !== loaded.manifest.id || JSON.stringify(first.inputs.binding) !== JSON.stringify(binding) @@ -849,6 +853,10 @@ async function assertRetainedRouteState( }, }); try { + if (entryObserved) { + await assertOwner(current); + assertActive(); + } await assertLegacyComposeRetainedRoutingState({ binding, routing: retainedRoutingIntent(loaded.inputs), @@ -1965,7 +1973,14 @@ async function completePublication( stage = "publication-stopped"; await requireStopped(ctx, loaded.inputs.binding); stage = "publication-routing"; - await assertPublicationRoutingStopped(ctx, loaded, state, routingDeadline); + await assertPublicationRoutingStopped( + ctx, + loaded, + state, + routingDeadline, + false, + true + ); stage = "publication-originals-directory"; const held = await holdDirectory( join(ctx.generationsRoot, publication.generation.id, "originals"), @@ -2208,7 +2223,8 @@ async function assertPublicationRoutingStopped( loaded: Awaited>, state: Receipt, deadline: number, - restored = false + restored = false, + entryObserved = false ): Promise { if (!loaded.inputs.retainedRouting) { return; @@ -2236,7 +2252,8 @@ async function assertPublicationRoutingStopped( refuse(); } return fresh; - } + }, + entryObserved ); } diff --git a/tests/native-compose-adoption-routing-generation.test.ts b/tests/native-compose-adoption-routing-generation.test.ts index 6b7458454..2ceb462e4 100644 --- a/tests/native-compose-adoption-routing-generation.test.ts +++ b/tests/native-compose-adoption-routing-generation.test.ts @@ -802,3 +802,127 @@ test("interrupted claim handoff retries from durable releasing state after exact await store.close(); } }); + +test("publication uses its just-observed entry binding within the unchanged proof deadline", async () => { + const { store, generation } = await prepare(); + const originalNow = Date.now; + let virtualNow: number | undefined; + let charged = 0; + const clock = spyOn(Date, "now").mockImplementation( + () => virtualNow ?? originalNow() + ); + try { + h.hooks.afterProbe = async (args) => { + const saved = await h.receipt(); + if ( + args[0] === "volume" && + args[1] === "inspect" && + saved.publication?.phase === "switching" && + saved.publication.native === null + ) { + charged += 1; + virtualNow ??= originalNow(); + virtualNow += 2600; + } + }; + await store.publish({ generation, binary: h.compiler }); + expect(charged).toBe(4); + expect((await h.receipt()).publication?.phase).toBe("active"); + expect(h.effects).toEqual([]); + } finally { + clock.mockRestore(); + Reflect.deleteProperty(h.hooks, "afterProbe"); + await store.close(); + } +}); + +test("publication keeps the fixed deadline when its first full proof already expires", async () => { + const { store, generation } = await prepare(); + const originalNow = Date.now; + let virtualNow: number | undefined; + let reached = false; + const clock = spyOn(Date, "now").mockImplementation( + () => virtualNow ?? originalNow() + ); + try { + h.hooks.afterProbe = async (args) => { + if ( + !reached && + args[0] === "volume" && + args[1] === "inspect" && + (await h.receipt()).publication?.phase === "switching" + ) { + reached = true; + virtualNow = originalNow() + 16_000; + } + }; + let error: unknown; + try { + await store.publish({ generation, binary: h.compiler }); + } catch (caught: unknown) { + error = caught; + } + expect(reached).toBe(true); + expect(legacyComposePublicationRefusal(error)).toEqual({ + stage: "publication-routing", + reason: "legacy-state", + }); + expect((await h.receipt()).publication).toMatchObject({ + phase: "switching", + native: null, + }); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect(h.effects).toEqual([]); + } finally { + clock.mockRestore(); + Reflect.deleteProperty(h.hooks, "afterProbe"); + await store.close(); + } +}); + +test("publication repeats full volume authority after its observed-entry routing window", async () => { + const { store, generation } = await prepare(); + let admins = 0; + let volumeReads = 0; + let reached = false; + try { + h.hooks.afterProbe = async (args) => { + const saved = await h.receipt(); + if ( + saved.publication?.phase !== "switching" || + saved.publication.native !== null + ) { + return; + } + if (args[0] === "volume" && args[1] === "inspect") { + volumeReads += 1; + } + if ( + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" + ) { + admins += 1; + if (admins === 2) { + expect(volumeReads).toBe(2); + reached = true; + h.model.volumeBirth = "2026-02-02T02:03:04Z"; + } + } + }; + await red(store.publish({ generation, binary: h.compiler })); + expect(reached).toBe(true); + expect((await h.receipt()).publication).toMatchObject({ + phase: "switching", + native: null, + }); + expect(await readFile(join(h.root, ".hack/hack.config.json"), "utf8")).toBe( + h.config + ); + expect(h.effects).toEqual([]); + } finally { + Reflect.deleteProperty(h.hooks, "afterProbe"); + await store.close(); + } +}); From a489a126d50446bdfbe3e0867be369dfed8e1714 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 10:00:48 -0400 Subject: [PATCH 9/9] fix: avoid duplicate routing proof before publication commit --- docs/reference/native-compose-adoption.md | 15 ++ src/lib/native-compose-adoption-generation.ts | 177 ++++++++++------ ...ompose-adoption-publication-diagnostics.ts | 26 +++ ...e-adoption-publication-diagnostics.test.ts | 19 ++ ...ompose-adoption-routing-generation.test.ts | 199 ++++++++++++++++++ 5 files changed, 377 insertions(+), 59 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 1541c6f2e..aff970427 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -556,8 +556,23 @@ rechecked inside the scoped context, and a complete fresh binding remains the exit gate before originals can move. The same absolute publication deadline applies; an already expired entry still refuses. +At the final active-receipt boundary, publication captures the actual final +resource read and reuses only its entry observation across the exclusive temporary +receipt write. The authoritative switching receipt is still unchanged. Scoped +source, manifest, claim, receipt, stopped-state and deadline checks continue; +the full closing resource binding and final receipt snapshot run before rename. +This reuse cannot cross an authoritative save, native installation, original +move, lifecycle effect or another callback. Clock controls cover this installed +native boundary separately from the earlier pre-move routing proof; they do not +identify a historical runtime refusal by themselves. + Publication refusals may include `legacy_adoption_publication_refusal: {stage, reason}` in the JSON error detail. +The active-receipt save distinguishes its original context, receipt, staging, +routing, commit and readback boundaries. Its explicit deadline guard may issue +`proof-deadline`; other state refusals retain their owner code classification. +An inner issued diagnostic survives the outer publication catch. These fixed +labels contain no paths, identities, source text or error messages. The owner records the fixed boundary that rejected and a closed error category; it retains no source values, resource identities, compiler output or error text. The public error code, guard order, deadlines and recovery requirements remain diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 6440d204a..80a9fc1ef 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -223,6 +223,21 @@ export class LegacyComposeAdoptedGenerationError extends Error { function refuse(code: Code = "E_LEGACY_ADOPTION_STATE"): never { throw new LegacyComposeAdoptedGenerationError(code); } +/** Only the original deadline guards may issue this value-free reason. */ +function refuseRoutingProofDeadline( + stage?: LegacyComposePublicationRefusal["stage"] +): never { + const error = new LegacyComposeAdoptedGenerationError( + "E_LEGACY_ADOPTION_STATE" + ); + if (stage) { + retainLegacyComposePublicationRefusal(error, { + stage, + reason: "proof-deadline", + }); + } + throw error; +} function cancelled(signal?: AbortSignal) { if (signal?.aborted) { refuse("E_LEGACY_ADOPTION_CANCELLED"); @@ -263,6 +278,9 @@ function recordPublicationRefusal( stage: LegacyComposePublicationRefusal["stage"] ): void { try { + if (legacyComposePublicationRefusal(error)) { + return; + } let reason: LegacyComposePublicationRefusal["reason"] = "unclassified"; const code: unknown = typeof error === "object" && error !== null @@ -816,15 +834,16 @@ async function assertRetainedRouteState( assertOwner: ( current: Context ) => Promise>>, - entryObserved = false + entryObserved = false, + refusalStage?: LegacyComposePublicationRefusal["stage"] ): Promise { const binding = loaded.inputs.binding; if (binding.binding_version !== 14 || retainedRouteObservations.has(ctx)) { refuse(); } - // Publication has just completed this full resource proof, followed only by - // candidate admission and stopped-state reads. Recheck source/receipt authority - // under the scoped observation; the exit still performs a full fresh binding. + // The caller just completed this full resource proof. Only read-only admission + // or an uncommitted receipt staging write may intervene. Scoped observations + // recheck source/receipt authority; exit still performs a full fresh binding. const first = entryObserved ? loaded : await assertOwner(ctx); if ( first.manifest.id !== loaded.manifest.id || @@ -837,9 +856,12 @@ async function assertRetainedRouteState( let active = true; const assertActive = () => { cancelled(current.signal); - if (!(active && Number.isFinite(deadline)) || Date.now() >= deadline) { + if (!(active && Number.isFinite(deadline))) { refuse(); } + if (Date.now() >= deadline) { + refuseRoutingProofDeadline(refusalStage); + } }; retainedRouteObservations.set(current, { binding: first.inputs.binding, @@ -1195,48 +1217,77 @@ async function save( ctx: Context, value: Receipt, expected: Receipt, - opts?: { readonly beforeCommit: () => Promise } -): Promise { - await ctx.check(); - const previous = await json(ctx.receiptPath); - receipt(previous.value, ctx.checkout); - const snapshot = ctx.receiptSnapshots.get(expected); - if ( - !(snapshot && sameFile(previous.info, snapshot.info)) || - previous.text !== snapshot.text - ) { - refuse(); - } - const temporary = join(ctx.stateRoot, `${token()}.receipt`); - await writeExclusive(temporary, JSON.stringify(value)); - const staged = await readPrivate(temporary, STATE_LIMIT); - await ctx.check(); - const latest = await json(ctx.receiptPath); - if (!sameFile(previous.info, latest.info) || previous.text !== latest.text) { - refuse(); - } - if (opts) { - await opts.beforeCommit(); - } - if (value.adoption_receipt_version === 14 || ctx.sourceBind.current) { - // Both owners await fresh source proofs; preserve receipt incarnation and - // revalidate mounted directories after that last admission boundary. - await requireReceiptSnapshot(ctx, expected); + opts?: { + readonly beforeCommit?: () => Promise; + readonly publication?: true; } - await rename(temporary, ctx.receiptPath); - await ctx.directories.at(-2)?.file.sync(); - const published = await json(ctx.receiptPath); - if ( - !sameFile(staged.info, published.info) || - staged.text !== published.text - ) { - refuse(); +): Promise { + const beforeCommit = opts?.beforeCommit; + const publication = opts?.publication === true; + let stage: LegacyComposePublicationRefusal["stage"] = + "publication-save-active-context"; + try { + await ctx.check(); + stage = "publication-save-active-previous"; + const previous = await json(ctx.receiptPath); + receipt(previous.value, ctx.checkout); + const snapshot = ctx.receiptSnapshots.get(expected); + if ( + !(snapshot && sameFile(previous.info, snapshot.info)) || + previous.text !== snapshot.text + ) { + refuse(); + } + const temporary = join(ctx.stateRoot, `${token()}.receipt`); + stage = "publication-save-active-staging"; + await writeExclusive(temporary, JSON.stringify(value)); + const staged = await readPrivate(temporary, STATE_LIMIT); + stage = "publication-save-active-staged-context"; + await ctx.check(); + stage = "publication-save-active-latest"; + const latest = await json(ctx.receiptPath); + if ( + !sameFile(previous.info, latest.info) || + previous.text !== latest.text + ) { + refuse(); + } + if (beforeCommit) { + stage = "publication-save-active-routing"; + await beforeCommit(); + } + if (value.adoption_receipt_version === 14 || ctx.sourceBind.current) { + // Both owners await fresh source proofs; preserve receipt incarnation and + // revalidate mounted directories after that last admission boundary. + stage = "publication-save-active-receipt"; + await requireReceiptSnapshot(ctx, expected); + } + stage = "publication-save-active-rename"; + await rename(temporary, ctx.receiptPath); + stage = "publication-save-active-sync"; + await ctx.directories.at(-2)?.file.sync(); + stage = "publication-save-active-published"; + const published = await json(ctx.receiptPath); + if ( + !sameFile(staged.info, published.info) || + staged.text !== published.text + ) { + refuse(); + } + stage = "publication-save-active-final-context"; + await ctx.check(); + stage = "publication-save-active-decode"; + const result = receipt(published.value, ctx.checkout); + ctx.receiptSnapshots.set(result, published); + return result; + } catch (error: unknown) { + if (publication) { + recordPublicationRefusal(error, stage); + } + throw error; } - await ctx.check(); - const result = receipt(published.value, ctx.checkout); - ctx.receiptSnapshots.set(result, published); - return result; } + function claim( selected: Anchor, known: WeakMap, @@ -2040,7 +2091,7 @@ async function completePublication( await held.file.sync(); await transaction.directories[1]?.file.sync(); stage = "publication-final-inputs"; - await readInputs(transaction, publication.generation); + const finalInputs = await readInputs(transaction, publication.generation); stage = "publication-final-stopped"; await requireStopped(transaction, loaded.inputs.binding); stage = "publication-final-directories"; @@ -2053,17 +2104,23 @@ async function completePublication( publication: { ...installed, phase: "active" }, }, current, - loaded.inputs.retainedRouting - ? { - beforeCommit: () => - assertPublicationRoutingStopped( - transaction, - loaded, - current, - routingDeadline - ), - } - : undefined + { + publication: true, + ...(loaded.inputs.retainedRouting + ? { + beforeCommit: () => + assertPublicationRoutingStopped( + transaction, + finalInputs, + current, + routingDeadline, + false, + true, + "publication-save-active-routing" + ), + } + : {}), + } ); } finally { const bodyStage = stage; @@ -2224,7 +2281,8 @@ async function assertPublicationRoutingStopped( state: Receipt, deadline: number, restored = false, - entryObserved = false + entryObserved = false, + refusalStage?: LegacyComposePublicationRefusal["stage"] ): Promise { if (!loaded.inputs.retainedRouting) { return; @@ -2249,11 +2307,12 @@ async function assertPublicationRoutingStopped( await requireReceiptSnapshot(current, state); cancelled(current.signal); if (Date.now() >= deadline) { - refuse(); + refuseRoutingProofDeadline(refusalStage); } return fresh; }, - entryObserved + entryObserved, + refusalStage ); } diff --git a/src/lib/native-compose-adoption-publication-diagnostics.ts b/src/lib/native-compose-adoption-publication-diagnostics.ts index 3ddc774d7..8d0f0add9 100644 --- a/src/lib/native-compose-adoption-publication-diagnostics.ts +++ b/src/lib/native-compose-adoption-publication-diagnostics.ts @@ -18,6 +18,18 @@ export type LegacyComposePublicationRefusal = { | "publication-final-stopped" | "publication-final-directories" | "publication-save-active" + | "publication-save-active-context" + | "publication-save-active-previous" + | "publication-save-active-staging" + | "publication-save-active-staged-context" + | "publication-save-active-latest" + | "publication-save-active-routing" + | "publication-save-active-receipt" + | "publication-save-active-rename" + | "publication-save-active-sync" + | "publication-save-active-published" + | "publication-save-active-final-context" + | "publication-save-active-decode" | "publication-close-originals"; readonly reason: | "legacy-state" @@ -30,6 +42,7 @@ export type LegacyComposePublicationRefusal = { | "private-uncertain" | "private-stale" | "compiler-transport" + | "proof-deadline" | "unclassified"; }; @@ -52,6 +65,18 @@ const stages: readonly LegacyComposePublicationRefusal["stage"][] = [ "publication-final-stopped", "publication-final-directories", "publication-save-active", + "publication-save-active-context", + "publication-save-active-previous", + "publication-save-active-staging", + "publication-save-active-staged-context", + "publication-save-active-latest", + "publication-save-active-routing", + "publication-save-active-receipt", + "publication-save-active-rename", + "publication-save-active-sync", + "publication-save-active-published", + "publication-save-active-final-context", + "publication-save-active-decode", "publication-close-originals", ]; const reasons: readonly LegacyComposePublicationRefusal["reason"][] = [ @@ -65,6 +90,7 @@ const reasons: readonly LegacyComposePublicationRefusal["reason"][] = [ "private-uncertain", "private-stale", "compiler-transport", + "proof-deadline", "unclassified", ]; diff --git a/tests/native-compose-adoption-publication-diagnostics.test.ts b/tests/native-compose-adoption-publication-diagnostics.test.ts index 227151d2a..f527e6f40 100644 --- a/tests/native-compose-adoption-publication-diagnostics.test.ts +++ b/tests/native-compose-adoption-publication-diagnostics.test.ts @@ -154,3 +154,22 @@ test("config adopt exposes issued publication detail without changing code or fi opened.mockRestore(); } }); + +test("save-active diagnostic substages and the proof-deadline reason remain closed data", () => { + const original = new Error("private-source-canary"); + attachLegacyComposePublicationRefusal(original, { + stage: "publication-save-active-routing", + reason: "proof-deadline", + }); + expect(legacyComposePublicationRefusal(original)).toEqual({ + stage: "publication-save-active-routing", + reason: "proof-deadline", + }); + expect(legacyComposePublicationRefusal({ ...original })).toBeUndefined(); + expect(() => + attachLegacyComposePublicationRefusal(new Error("fixed"), { + stage: "publication-save-active-routing", + reason: "private-deadline-canary", + }) + ).toThrow("values omitted"); +}); diff --git a/tests/native-compose-adoption-routing-generation.test.ts b/tests/native-compose-adoption-routing-generation.test.ts index 2ceb462e4..e57b7934e 100644 --- a/tests/native-compose-adoption-routing-generation.test.ts +++ b/tests/native-compose-adoption-routing-generation.test.ts @@ -926,3 +926,202 @@ test("publication repeats full volume authority after its observed-entry routing await store.close(); } }); + +test("save-active uses its newly observed binding within the unchanged publication deadline", async () => { + const { store, generation } = await prepare(); + const originalNow = Date.now; + let virtualNow = originalNow(); + let charged = 0; + const clock = spyOn(Date, "now").mockImplementation(() => virtualNow); + try { + h.hooks.afterProbe = async (args) => { + const saved = await h.receipt(); + if ( + args[0] === "volume" && + args[1] === "inspect" && + saved.publication?.phase === "switching" && + saved.publication.native !== null + ) { + charged += 1; + virtualNow += 2600; + } + }; + await store.publish({ generation, binary: h.compiler }); + expect(charged).toBe(4); + expect((await h.receipt()).publication?.phase).toBe("active"); + expect(h.effects).toEqual([]); + } finally { + if (h.canRestore()) { + clock.mockRestore(); + Reflect.deleteProperty(h.hooks, "afterProbe"); + } + await store.close(); + } +}); + +test("save-active classifies its original deadline guard after staging without active rename", async () => { + const { store, generation } = await prepare(); + const originalNow = Date.now; + let virtualNow = originalNow(); + let reached = false; + const clock = spyOn(Date, "now").mockImplementation(() => virtualNow); + let restoreRead: (() => void) | undefined; + try { + const originalRead = privateState.readPrivate; + const readSpy = spyOn(privateState, "readPrivate").mockImplementation( + async (...args) => { + const value = await originalRead(...args); + if (!reached && args[0].endsWith(".receipt")) { + const staged: unknown = JSON.parse(value.text); + if ( + isRecord(staged) && + isRecord(staged.publication) && + staged.publication.phase === "active" + ) { + expect((await h.receipt()).publication).toMatchObject({ + phase: "switching", + }); + reached = true; + virtualNow += 16_000; + } + } + return value; + } + ); + restoreRead = () => readSpy.mockRestore(); + let error: unknown; + try { + await store.publish({ generation, binary: h.compiler }); + } catch (caught: unknown) { + error = caught; + } + expect(reached).toBe(true); + expect(legacyComposePublicationRefusal(error)).toEqual({ + stage: "publication-save-active-routing", + reason: "proof-deadline", + }); + const saved = await h.receipt(); + expect(saved.publication?.phase).toBe("switching"); + expect(saved.publication?.native).not.toBeNull(); + expect(saved.routingHandoff).toBe("held"); + expect(h.effects).toEqual([]); + } finally { + if (h.canRestore()) { + clock.mockRestore(); + restoreRead?.(); + } + await store.close(); + } +}); + +test("save-active rechecks receipt incarnation after the staged-write await", async () => { + const { store, generation } = await prepare(); + let reached = false; + let restoreRead: (() => void) | undefined; + try { + const originalRead = privateState.readPrivate; + const readSpy = spyOn(privateState, "readPrivate").mockImplementation( + async (...args) => { + const value = await originalRead(...args); + if (!reached && args[0].endsWith(".receipt")) { + const staged: unknown = JSON.parse(value.text); + if ( + isRecord(staged) && + isRecord(staged.publication) && + staged.publication.phase === "active" + ) { + const path = join( + h.root, + ".hack/.internal/legacy-compose-adoption-v1/receipt.json" + ); + const previous = await readFile(path, "utf8"); + await fs.rename( + path, + join(h.outer, "receipt-before-save-active-rebirth") + ); + await fs.writeFile(path, previous, { flag: "wx", mode: 0o600 }); + reached = true; + } + } + return value; + } + ); + restoreRead = () => readSpy.mockRestore(); + let error: unknown; + try { + await store.publish({ generation, binary: h.compiler }); + } catch (caught: unknown) { + error = caught; + } + expect(reached).toBe(true); + expect(legacyComposePublicationRefusal(error)).toEqual({ + stage: "publication-save-active-latest", + reason: "legacy-state", + }); + const saved = await h.receipt(); + expect(saved.publication?.phase).toBe("switching"); + expect(saved.publication?.native).not.toBeNull(); + expect(saved.routingHandoff).toBe("held"); + expect(h.effects).toEqual([]); + } finally { + if (h.canRestore()) { + restoreRead?.(); + } + await store.close(); + } +}); + +test("save-active repeats full volume authority after its scoped routing observations", async () => { + const { store, generation } = await prepare(); + let admins = 0; + let volumeReads = 0; + let readsAtInjection: number | undefined; + let reached = false; + try { + h.hooks.afterProbe = async (args) => { + const saved = await h.receipt(); + if ( + saved.publication?.phase !== "switching" || + saved.publication.native === null + ) { + return; + } + if (args[0] === "volume" && args[1] === "inspect") { + volumeReads += 1; + } + if ( + args[0] === "exec" && + args.at(-1) === "http://127.0.0.1:2019/config/apps/http/servers" + ) { + admins += 1; + if (admins === 2) { + readsAtInjection = volumeReads; + reached = true; + h.model.volumeBirth = "2026-02-02T02:03:04Z"; + } + } + }; + let error: unknown; + try { + await store.publish({ generation, binary: h.compiler }); + } catch (caught: unknown) { + error = caught; + } + expect(reached).toBe(true); + expect(readsAtInjection).toBe(2); + expect(legacyComposePublicationRefusal(error)).toEqual({ + stage: "publication-save-active-routing", + reason: "legacy-changed", + }); + const saved = await h.receipt(); + expect(saved.publication?.phase).toBe("switching"); + expect(saved.publication?.native).not.toBeNull(); + expect(saved.routingHandoff).toBe("held"); + expect(h.effects).toEqual([]); + } finally { + if (h.canRestore()) { + Reflect.deleteProperty(h.hooks, "afterProbe"); + } + await store.close(); + } +});