Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/native-compose-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ Container observations bracket the active-member read. If an original changes
running state while all immutable facts remain verified, the mixed observation
is discarded within the same aggregate deadline and output allowance. Foreign
members, replaced identities and stable membership mismatches refuse immediately.
Docker keys an endpoint that is not joined to a sandbox as `ep-<endpoint>`. This
happens for a moment while any container starts or exits. Such a member is never
attributed, admitted or saved: the observation is discarded and re-read within
the same deadline, and a placeholder that persists until the deadline refuses.
This is not an atomic daemon snapshot: delayed endpoint removal can still refuse,
and fresh successful job completion remains the scheduler's separate decision.
The Docker routing environment is captured for later comparison. Queries never
Expand Down
73 changes: 54 additions & 19 deletions src/lib/native-compose-adoption-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ import {
import type { LegacyComposeSourceBindIntent } from "./native-config-import-storage.ts";

const ID = /^[a-f0-9]{64}$/;
/**
* Docker lists an endpoint that is not joined to a sandbox as `ep-<endpoint>`
* in a bridge's `.Containers`. That happens for a moment on every container
* start (endpoint created, sandbox not yet joined) and exit (sandbox left,
* endpoint not yet deleted), and the bridge read takes no container lock.
*/
const ENDPOINT_PLACEHOLDER = /^ep-[a-f0-9]{64}$/;
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/;
const ENGINE_ID = /^[a-zA-Z0-9:._-]{1,128}$/;
const TIME = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/;
Expand Down Expand Up @@ -708,6 +715,28 @@ function containerRows(
})
.sort((a, b) => a.service.localeCompare(b.service));
}
/**
* Split a bridge's `.Containers` keys into container IDs and a transient flag.
* A placeholder is never attributed, admitted or retained: it only marks this
* bridge read as mid-transition so the caller re-observes under its original
* deadline. Any other non-ID key, or a duplicate, refuses as before.
*/
function bridgeMembers(value: unknown): {
readonly ids: readonly string[];
readonly transient: boolean;
} {
requireValue(
Array.isArray(value) &&
value.every(
(key) =>
typeof key === "string" &&
(ID.test(key) || ENDPOINT_PLACEHOLDER.test(key))
) &&
new Set(value).size === value.length
);
const ids = value.filter((key: string) => ID.test(key));
return { ids, transient: ids.length !== value.length };
}
function networkRow(
rows: readonly Record<string, unknown>[],
intent: LegacyComposeStorageIntent
Expand Down Expand Up @@ -736,11 +765,9 @@ function networkRow(
timestamp(row.createdAt) &&
row.driver === "bridge" &&
row.scope === "local" &&
row.internal === (intent.ownedNetwork?.internal ?? false) &&
Array.isArray(row.containers) &&
row.containers.every((id) => typeof id === "string" && ID.test(id)) &&
new Set(row.containers).size === row.containers.length
row.internal === (intent.ownedNetwork?.internal ?? false)
);
const members = bridgeMembers(row.containers);
return {
network: {
id: row.id,
Expand All @@ -753,7 +780,8 @@ function networkRow(
}
: {}),
},
containerIds: row.containers,
containerIds: members.ids,
transient: members.transient,
};
}
function pluralNetworkRows(
Expand All @@ -762,7 +790,9 @@ function pluralNetworkRows(
): {
readonly networks: readonly LegacyComposeVerifiedNetwork[];
readonly members: ReadonlyMap<string, readonly string[]>;
readonly transient: boolean;
} {
let transient = false;
const declared = intent.ownedNetworks?.networks;
requireValue(declared && declared.length === 2 && rows.length === 2);
requireValue(
Expand Down Expand Up @@ -794,7 +824,8 @@ function pluralNetworkRows(
]);
const expected = declared.find((network) => network.name === row.name);
const id = row.id;
const rowMembers = row.containers;
const rowMembers = bridgeMembers(row.containers);
transient ||= rowMembers.transient;
requireValue(
expected &&
typeof id === "string" &&
Expand All @@ -805,16 +836,11 @@ function pluralNetworkRows(
row.internal === expected.internal &&
row.driver === "bridge" &&
row.scope === "local" &&
timestamp(row.createdAt) &&
Array.isArray(rowMembers) &&
rowMembers.every(
(member) => typeof member === "string" && ID.test(member)
) &&
new Set(rowMembers).size === rowMembers.length
timestamp(row.createdAt)
);
ids.add(id);
names.add(expected.name);
members.set(expected.logical, [...rowMembers].sort());
members.set(expected.logical, [...rowMembers.ids].sort());
return {
id,
name: expected.name,
Expand All @@ -824,7 +850,7 @@ function pluralNetworkRows(
};
})
.sort((a, b) => a.logical.localeCompare(b.logical));
return { networks, members };
return { networks, members, transient };
}
async function engine(probe: Probe) {
const rows = lines(
Expand Down Expand Up @@ -981,8 +1007,14 @@ async function inspectResources(
)
);
}
return { plural, single };
return {
plural,
single,
transient: plural?.transient === true || single?.transient === true,
};
};
// The first bridge read only fixes identity and refuses foreign members; the
// loop below always re-reads, so a placeholder here needs no extra scan.
let { plural, single } = await readNetwork();
const networkIdentity = JSON.stringify(plural?.networks ?? single?.network);
let ingress: NativeComposeIngressBinding | undefined;
Expand Down Expand Up @@ -1019,18 +1051,21 @@ async function inspectResources(
let containerFacts: readonly Record<string, unknown>[];
let containers: LegacyComposeVerifiedContainer[];
for (;;) {
({ plural, single } = await readNetwork());
let transient: boolean;
({ plural, single, transient } = await readNetwork());
requireValue(
JSON.stringify(plural?.networks ?? single?.network) === networkIdentity
);
const after = await readContainers();
// Both scans validate all labels, mounts and configured attachments against
// the same originals. Only running state is transient; stopped aliases may
// be absent under the existing contract. No completion authority is granted.
// the same originals. Only running state and a sandbox-less endpoint
// placeholder are transient; stopped aliases may be absent under the
// existing contract. No completion authority is granted.
requireValue(JSON.stringify(after.containers) === containerIdentity);
if (
transient ||
JSON.stringify(before.facts.map((row) => [row.id, row.running])) !==
JSON.stringify(after.facts.map((row) => [row.id, row.running]))
JSON.stringify(after.facts.map((row) => [row.id, row.running]))
) {
// Reuse this aggregate probe's original deadline and output allowance.
// Never accept mixed membership, reset a timer, or retry an identity error.
Expand Down
56 changes: 51 additions & 5 deletions tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { isRecord } from "../../../src/lib/guards.ts";
import type { LegacyComposeOrderedRefusal } from "../../../src/lib/native-compose-adoption-diagnostics.ts";
import { LegacyComposeAdoptedGenerationError } from "../../../src/lib/native-compose-adoption-generation.ts";
import { LEGACY_ADOPTION_SELECTION_MESSAGES } from "../../../src/lib/native-compose-adoption-marker.ts";
import { ProjectInputAccessError } from "../../../src/lib/project-input-selection.ts";
import type { CliResult } from "../harness.ts";

const STAGES = [
Expand All @@ -15,6 +18,7 @@ const STAGES = [
type Stage = (typeof STAGES)[number];
const CODES = [
"E_CONFIG_INVALID",
"E_PERMISSION",
"E_LIFECYCLE_FAILED",
"E_STATE",
"E_NATIVE_COMPOSE_ADOPTION",
Expand Down Expand Up @@ -58,6 +62,46 @@ const SCHEDULER_REASONS: readonly SchedulerReason[] = [
"completion-authority",
];
const MAX_REPLY_BYTES = 64 * 1024;
/**
* Closed refusal tokens for the fixed, value-free messages an adopted start can
* end with (HACK-1421). Only exact matches map to a token; the message itself
* is never emitted, and any other text is `other`.
*/
const REFUSALS: readonly (readonly [string, string])[] = [
...(
[
["legacy-state", "E_LEGACY_ADOPTION_STATE"],
["legacy-busy", "E_LEGACY_ADOPTION_BUSY"],
["legacy-changed", "E_LEGACY_ADOPTION_CHANGED"],
["legacy-unsupported", "E_LEGACY_ADOPTION_UNSUPPORTED"],
["legacy-cancelled", "E_LEGACY_ADOPTION_CANCELLED"],
["legacy-deadline", "E_LEGACY_ADOPTION_DEADLINE"],
["legacy-literal-env", "E_LEGACY_ADOPTION_LITERAL_ENV"],
] as const
).map(
([token, code]) =>
[token, new LegacyComposeAdoptedGenerationError(code).message] as const
),
[
"adopted-refused",
"Adopted Compose ownership or execution refused; original data is retained. Values omitted.",
],
[
"adopted-cancelled",
"Adopted Compose operation cancelled; pending ownership is retained. Values omitted.",
],
["marker-adoption", LEGACY_ADOPTION_SELECTION_MESSAGES.adoption],
["marker-permission", LEGACY_ADOPTION_SELECTION_MESSAGES.permission],
// The marker's input-access message is the shared project-input guard's.
["input-access", new ProjectInputAccessError("unreadable").message],
];

function refusalToken(message: unknown): string {
if (typeof message !== "string") {
return "unavailable";
}
return REFUSALS.find(([, text]) => text === message)?.[0] ?? "other";
}

function isStage(value: unknown): value is Stage {
return STAGES.some((stage) => stage === value);
Expand Down Expand Up @@ -98,6 +142,7 @@ function cliFacts(stdout: string) {
code: "unavailable",
orderedStage: "unavailable",
orderedReason: "unavailable",
refusal: "unavailable",
} as const;
if (Buffer.byteLength(stdout) > MAX_REPLY_BYTES) {
return unavailable;
Expand All @@ -108,7 +153,7 @@ function cliFacts(stdout: string) {
return unavailable;
}
if (value.ok === true) {
return { ...unavailable, code: "none" };
return { ...unavailable, code: "none", refusal: "none" };
}
if (value.ok === false && isRecord(value.error)) {
const error = value.error;
Expand All @@ -119,6 +164,7 @@ function cliFacts(stdout: string) {
code,
orderedStage: ordered?.stage ?? "unavailable",
orderedReason: ordered?.reason ?? "unavailable",
refusal: refusalToken(error.message),
};
}
} catch {
Expand All @@ -128,8 +174,8 @@ function cliFacts(stdout: string) {
}

/**
* Record only closed start/restart substages, CLI codes and exact ordered-refusal
* detail. No reply values, paths, argv, identifiers, arbitrary errors or
* Record only closed start/restart substages, CLI codes, exact ordered-refusal
* detail and a closed token for a fixed refusal message. No reply values, paths, argv, identifiers, arbitrary errors or
* elapsed-budget claims are emitted. Logging failure never replaces the result.
*/
export function createCompletedJobFixtureStartDiagnostics(opts: {
Expand Down Expand Up @@ -180,11 +226,11 @@ export function createCompletedJobFixtureStartDiagnostics(opts: {
const timeout = result.timedOut ? "yes" : "no";
const facts = cliFacts(result.stdout);
emit(
`stage=cli-result exit=${exit} timed-out=${timeout} code=${facts.code} ordered-stage=${facts.orderedStage} ordered-reason=${facts.orderedReason}`
`stage=cli-result exit=${exit} timed-out=${timeout} code=${facts.code} ordered-stage=${facts.orderedStage} ordered-reason=${facts.orderedReason} refusal=${facts.refusal}`
);
} catch {
emit(
"stage=cli-result exit=unavailable timed-out=unavailable code=unavailable ordered-stage=unavailable ordered-reason=unavailable"
"stage=cli-result exit=unavailable timed-out=unavailable code=unavailable ordered-stage=unavailable ordered-reason=unavailable refusal=unavailable"
);
}
},
Expand Down
75 changes: 75 additions & 0 deletions tests/native-compose-adoption-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ if (fixture.mode?.startsWith('source-bind-c2-') && kind === 'container' && actio
writeFileSync(root+'/fixture.json',JSON.stringify(fixture));
}
}
if (fixture.mode?.startsWith("snapshot-placeholder-") && kind === "network" && action === "inspect") {
// Docker keys an endpoint that has no sandbox (mid start/stop) as ep-<endpoint>; this read only.
const at = fixture.mode.slice("snapshot-placeholder-".length);
const key = at.endsWith("-malformed") ? "ep-not-an-endpoint" : "ep-" + "d".repeat(64);
if (at.startsWith("always") || at === String(networkOrdinal)) for (const bridge of fixture.network) bridge.containers = [...bridge.containers, key];
}
if (kind === "info") {console.log(JSON.stringify({id: fixture.engine, os: "linux"}));}
else if (action === "ls") {for (const row of fixture[kind]) console.log(JSON.stringify({id: row.id, name: kind === 'container' ? row.name.slice(1) : row.name, project: row.project ?? ""}));}
else {
Expand Down Expand Up @@ -646,6 +652,75 @@ test("moving snapshot guard exhausts the original shared deadline", async () =>
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
}, 10_000);

function networkInspections(observed: readonly string[][]): number {
return observed.filter(
([kind, action]) => kind === "network" && action === "inspect"
).length;
}

test.each([
// The identity-only first read is always followed by a fresh loop read.
["1", 0],
// A placeholder in the accepted loop read costs exactly one more scan.
["2", 1],
] as const)(
"a sandbox-less endpoint placeholder on bridge read %s is re-observed, never admitted or refused (HACK-1421)",
async (ordinal, extra) => {
const clean = await acquireLegacyComposeAdoptionBinding({ projectRoot });
const original = await clean.resolveBinding({ projectRoot });
const baseline = networkInspections(await commands());
await rm(join(root, "commands"));
fixture.mode = `snapshot-placeholder-${ordinal}`;
await writeFile(join(root, "snapshot-network-ordinal"), "0");
await save();
const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot });
expect(await acquired.resolveBinding({ projectRoot })).toEqual(original);
expect(networkInspections(await commands())).toBe(baseline + extra);
expect(JSON.stringify(original)).not.toContain("ep-");
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
},
60_000
);

test("a placeholder on one of two owned bridges is re-observed with both bridges (HACK-1421)", async () => {
await twoBridges();
const clean = await acquireLegacyComposeAdoptionBinding({ projectRoot });
const original = await clean.resolveBinding({ projectRoot });
const baseline = networkInspections(await commands());
await rm(join(root, "commands"));
// Reads 1-2 fix identity; read 3 is the first bridge of the accepted loop scan.
fixture.mode = "snapshot-placeholder-3";
await writeFile(join(root, "snapshot-network-ordinal"), "0");
await save();
const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot });
expect(await acquired.resolveBinding({ projectRoot })).toEqual(original);
expect(networkInspections(await commands())).toBe(baseline + 2);
expect(await Bun.file(join(root, "mutation")).exists()).toBe(false);
}, 60_000);

test("a persistent endpoint placeholder exhausts the original shared deadline", async () => {
fixture.mode = "snapshot-placeholder-always";
await save();
const started = Date.now();
await refusal(
acquireLegacyComposeAdoptionBinding({ projectRoot, timeoutMs: 6000 }),
"E_LEGACY_COMPOSE_BINDING_PROBE"
);
expect(Date.now() - started).toBeLessThan(9000);
// Identity read plus at least two discarded loop reads before the deadline.
expect(networkInspections(await commands())).toBeGreaterThan(2);
}, 20_000);

test("a malformed placeholder-shaped bridge member still refuses identity without retry", async () => {
fixture.mode = "snapshot-placeholder-always-malformed";
await save();
await refusal(
acquireLegacyComposeAdoptionBinding({ projectRoot }),
"E_LEGACY_COMPOSE_BINDING_IDENTITY"
);
expect(networkInspections(await commands())).toBe(1);
}, 10_000);

test("moving snapshot guard rejects a selected ID on its undeclared bridge before retry", async () => {
await twoBridges();
fixture.mode = "snapshot-wrong-bridge";
Expand Down
Loading
Loading