Skip to content
Merged
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
8 changes: 8 additions & 0 deletions docs/reference/native-compose-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,14 @@ publication and `--recover` switch completion refuse at the `publication-inputs`
boundary with the closed `literal-env-override` reason, before any original moves.
The switch stays journaled and the fixed message names
`hack config adopt --recover --rollback`; rollback stays allowed (HACK-1318).
A repeated `--recover --stop` after that refusal does not reach the publication
boundary again: the switch is already journaled, so the retried preparation stop
refuses it as a pending publication with the generic `E_LEGACY_ADOPTION_BUSY`
code ("busy or requires explicit interrupted-lock recovery") and no publication
diagnostic; the literal-env message appears only on the first refusal.
`hack doctor` reports the pinned literal override and names
`hack config adopt --recover --rollback` as the next step while the switch is
pending, since `hack down` and plain `--rollback` refuse in that state (HACK-1391).

Unmodified v4 clients cannot be universally fenced: they ignore future legacy
versions and may select ancestor Compose inputs. Adoption requires the upgraded
Expand Down
4 changes: 2 additions & 2 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {
} from "../lib/caddy-hosts.ts";
import { emitCliResult, okResult } from "../lib/cli-result.ts";
import {
ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP,
adoptedComposeEnvOverrideNextStep,
composeEnvAtRestRepairs,
describeComposeEnvAtRest,
inspectComposeEnvAtRest,
Expand Down Expand Up @@ -4186,7 +4186,7 @@ async function maybeRemovePlaintextComposeEnvOverride(opts: {
const repairs = composeEnvAtRestRepairs(report);
if (report.adoptionBound) {
note(
`Kept ${file}: a saved 'hack config adopt' generation pins it, and removing it would break the adoption (values omitted). ${ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP}`,
`Kept ${file}: a saved 'hack config adopt' generation pins it, and removing it would break the adoption (values omitted). ${adoptedComposeEnvOverrideNextStep(report)}`,
"compose env at rest"
);
}
Expand Down
61 changes: 52 additions & 9 deletions src/lib/compose-env-at-rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ import {
* and inode in its integrity proof, and adopted commands never write it. While
* such a generation exists (prepared, active or pending), nothing here removes
* the file, because removal would make the adoption unloadable; the supported
* way to drop its values is rollback, `hack up`, then adopting again. New
* adoptions refuse a literal override (`hack config adopt`).
* way to drop its values is rollback (`hack config adopt --rollback`, or
* `--recover --rollback` while an adoption operation or switch is pending),
* `hack up`, then adopting again. New adoptions refuse a literal override
* (`hack config adopt`).
*/
export const COMPOSE_ENV_OVERRIDE_FILENAME = "compose.env.override.yml";

Expand Down Expand Up @@ -331,17 +333,34 @@ export async function removePlaintextComposeEnvOverride(opts: {
export async function composeEnvOverrideAdoptionBound(opts: {
readonly projectDir: string;
}): Promise<boolean> {
return (await composeEnvOverrideAdoptionBinding(opts)).bound;
}

/**
* How a saved adoption binds the override: `bound` as in
* {@link composeEnvOverrideAdoptionBound}, and `pending` when the binding
* adoption has a pending or interrupted operation or switch. A pending
* adoption fences `hack down` and plain `hack config adopt --rollback`, so
* only explicit recovery (`hack config adopt --recover --rollback`) releases
* the file (HACK-1318). Unreadable adoption state is bound and not pending.
*/
export async function composeEnvOverrideAdoptionBinding(opts: {
readonly projectDir: string;
}): Promise<{ readonly bound: boolean; readonly pending: boolean }> {
const projectDir = resolve(opts.projectDir);
if (basename(projectDir) !== HACK_PROJECT_DIR_PRIMARY) {
return false;
return { bound: false, pending: false };
}
try {
const status = await inspectLegacyComposeAdoptionSelection({
projectRoot: dirname(projectDir),
});
return status !== null && status !== "rolled-back";
return {
bound: status !== null && status !== "rolled-back",
pending: status === "pending",
};
} catch {
return true;
return { bound: true, pending: false };
}
}

Expand Down Expand Up @@ -451,6 +470,10 @@ export type ComposeEnvAtRestReport = {
readonly override: ComposeEnvOverrideAtRest;
/** A saved adoption may pin the override; see {@link composeEnvOverrideAdoptionBound}. */
readonly adoptionBound: boolean;
/** The pinning adoption has a pending or interrupted operation or switch,
* so only `hack config adopt --recover --rollback` releases the file; see
* {@link composeEnvOverrideAdoptionBinding}. False when not bound. */
readonly adoptionPending: boolean;
/** Asides left by interrupted removals; each may hold literal values. */
readonly asides: { readonly stale: number; readonly fresh: number };
};
Expand All @@ -472,11 +495,13 @@ export async function inspectComposeEnvAtRest(opts: {
now: opts.now ?? Date.now,
})
: { stale: [], fresh: [] };
const binding = composeEnvOverrideMayHoldValues(override)
? await composeEnvOverrideAdoptionBinding(opts)
: { bound: false, pending: false };
return {
override,
adoptionBound:
composeEnvOverrideMayHoldValues(override) &&
(await composeEnvOverrideAdoptionBound(opts)),
adoptionBound: binding.bound,
adoptionPending: binding.bound && binding.pending,
asides: { stale: asides.stale.length, fresh: asides.fresh.length },
};
}
Expand Down Expand Up @@ -506,6 +531,22 @@ export function composeEnvAtRestRepairs(report: ComposeEnvAtRestReport): {
/** Supported way to drop values that a saved adoption pins; see the module doc. */
export const ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP =
"To drop them, stop the adopted containers ('hack down'), run 'hack config adopt --rollback', run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'.";
/**
* The same, while the pinning adoption has a pending or interrupted operation
* or switch: `hack down` and plain `--rollback` refuse in that state, and only
* explicit recovery releases the switch (HACK-1318, HACK-1391).
*/
export const PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP =
"To drop them, release the pending adoption switch with 'hack config adopt --recover --rollback' (plain rollback and 'hack down' refuse while it is pending), run 'hack up' so Hack rewrites the file with references only, then adopt again with 'hack config adopt --stop'.";

/** The next step for a report whose override a saved adoption pins. */
export function adoptedComposeEnvOverrideNextStep(
report: Pick<ComposeEnvAtRestReport, "adoptionPending">
): string {
return report.adoptionPending
? PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP
: ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP;
}

/**
* Doctor status and message. Names the project-relative path and counts only;
Expand All @@ -521,6 +562,7 @@ export function describeComposeEnvAtRest(opts: {
file,
projectDirName: opts.projectDirName,
adoptionBound: opts.report.adoptionBound,
adoptionNextStep: adoptedComposeEnvOverrideNextStep(opts.report),
});
const asides = opts.report.asides.stale + opts.report.asides.fresh;
if (asides === 0) {
Expand All @@ -539,10 +581,11 @@ function describeOverride(opts: {
readonly file: string;
readonly projectDirName: string;
readonly adoptionBound: boolean;
readonly adoptionNextStep: string;
}): { readonly status: "ok" | "warn"; readonly message: string } {
const { state, file } = opts;
const next = opts.adoptionBound
? `A saved 'hack config adopt' generation pins this file, so 'hack doctor --fix' keeps it: removing it would break the adoption. ${ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP}`
? `A saved 'hack config adopt' generation pins this file, so 'hack doctor --fix' keeps it: removing it would break the adoption. ${opts.adoptionNextStep}`
: "Run 'hack doctor --fix' to remove it; Compose commands now write references only.";
switch (state.kind) {
case "absent":
Expand Down
55 changes: 55 additions & 0 deletions tests/compose-env-at-rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import { join } from "node:path";
import { YAML } from "bun";
import { buildDoctorRemediationPlanLines } from "../src/commands/doctor.ts";
import {
ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP,
adoptedComposeEnvOverrideNextStep,
composeEnvAtRestRepairs,
composeEnvOverridePath,
describeComposeEnvAtRest,
inspectComposeEnvAtRest,
inspectComposeEnvOverrideAtRest,
PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP,
removePlaintextComposeEnvOverride,
writeComposeEnvOverride,
} from "../src/lib/compose-env-at-rest.ts";
Expand Down Expand Up @@ -372,6 +375,7 @@ test("doctor descriptions are ok for absent and reference-only files", () => {
report: {
override,
adoptionBound: false,
adoptionPending: false,
asides: { stale: 0, fresh: 0 },
},
projectDirName: ".hack",
Expand Down Expand Up @@ -481,6 +485,9 @@ test("an override that an adoption state may pin is kept by removal and reported
expect(await readFile(path, "utf8")).toBe(literalText());
const report = await inspectComposeEnvAtRest({ projectDir });
expect(report.adoptionBound).toBe(true);
// Unreadable state is bound but not known to be pending, so the next step
// stays the plain rollback.
expect(report.adoptionPending).toBe(false);
expect(composeEnvAtRestRepairs(report).override).toBe(false);
const described = describeComposeEnvAtRest({
report,
Expand All @@ -489,6 +496,7 @@ test("an override that an adoption state may pin is kept by removal and reported
expect(described.status).toBe("warn");
expect(described.message).toContain("pins this file");
expect(described.message).toContain("hack config adopt --rollback");
expect(described.message).not.toContain("--recover --rollback");
expect(described.message).not.toContain(CANARY);

// Without the adoption state the same file is removed.
Expand All @@ -498,6 +506,52 @@ test("an override that an adoption state may pin is kept by removal and reported
expect(await lstat(path).catch(() => null)).toBeNull();
});

test("doctor names explicit recovery rollback for an override that a pending adoption pins (HACK-1391)", () => {
const path = "/unused";
for (const kind of ["plaintext", "unverifiable"] as const) {
const override =
kind === "plaintext"
? { kind, path, values: 2, services: 1 }
: { kind, path };
const pending = describeComposeEnvAtRest({
report: {
override,
adoptionBound: true,
adoptionPending: true,
asides: { stale: 0, fresh: 0 },
},
projectDirName: ".hack",
});
expect(pending.status).toBe("warn");
expect(pending.message).toContain("pins this file");
expect(pending.message).toContain("hack config adopt --recover --rollback");
// Plain rollback and `hack down` refuse while the adoption is pending, so
// neither is the next step.
expect(pending.message).not.toContain("'hack config adopt --rollback'");
expect(pending.message).not.toContain("stop the adopted containers");
expect(pending.message).toContain(
PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP
);
expect(adoptedComposeEnvOverrideNextStep({ adoptionPending: true })).toBe(
PENDING_ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP
);
expect(adoptedComposeEnvOverrideNextStep({ adoptionPending: false })).toBe(
ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP
);
const settled = describeComposeEnvAtRest({
report: {
override,
adoptionBound: true,
adoptionPending: false,
asides: { stale: 0, fresh: 0 },
},
projectDirName: ".hack",
});
expect(settled.message).toContain(ADOPTED_COMPOSE_ENV_OVERRIDE_NEXT_STEP);
expect(settled.message).not.toContain("--recover --rollback");
}
});

/** Names and contents of every entry in a directory outside the project. */
async function snapshotDirectory(
directory: string
Expand Down Expand Up @@ -546,6 +600,7 @@ for (const linked of [".hack", ".hack/.internal"] as const) {
expect(report).toEqual({
override: { kind: "refused", path },
adoptionBound: false,
adoptionPending: false,
asides: { stale: 0, fresh: 0 },
});
expect(composeEnvAtRestRepairs(report)).toEqual({
Expand Down
11 changes: 6 additions & 5 deletions tests/helpers/retained-routing-adoption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ export type RetainedRoutingOperationDeadlineMs =
| 15_000
| typeof DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS;
/** Value-free timing of the most recent `operation()` call. A retained mutation
* refuses as `E_LEGACY_ADOPTION_CHANGED` both when its observed binding differs
* from the recorded one and when its caller deadline expires (the product's
* fail-closed deadline guard). `deadlineExpired` and `bindingUnchanged` (whether
* every synthetic binding fact except the running state still equals the
* fixture's original) tell the two apart without exposing any value. */
* refuses as `E_LEGACY_ADOPTION_CHANGED` when its observed binding differs from
* the recorded one and as `E_LEGACY_ADOPTION_DEADLINE` when only its caller
* deadline expires (the product's fail-closed deadline guard, HACK-1340).
* `deadlineExpired` and `bindingUnchanged` (whether every synthetic binding fact
* except the running state still equals the fixture's original) tell the two
* apart without exposing any value. */
export interface RetainedRoutingOperationTiming {
readonly operation: "start" | "stop";
readonly preparation: boolean;
Expand Down
40 changes: 40 additions & 0 deletions tests/native-compose-adoption-generation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2336,6 +2336,46 @@ boundedTest(
await expectOriginals(original);
expect((await lstat(override)).ino).toBe(pinned.ino);
}
// A second `--recover --stop` no longer reaches the publication boundary:
// the switch is already journaled, so the retried preparation stop refuses
// it as a pending publication (the generic busy code) without the
// literal-env message or a publication diagnostic (HACK-1391).
const repeated = await adoptCli(["--recover", "--stop", "--json"], binary);
expect(repeated.code).not.toBe(0);
expect(JSON.parse(repeated.stdout)).toEqual({
ok: false,
error: {
code: "E_CONFIG_INVALID",
message: new LegacyComposeAdoptedGenerationError(
"E_LEGACY_ADOPTION_BUSY"
).message,
},
});
expect(repeated.output).not.toContain(CANARY);
expect((await readReceipt()).publication).toMatchObject({
phase: "switching",
native: null,
});
// Doctor's next step for the pinned literal override must be a command
// this pending state accepts: explicit recovery rollback, not plain
// rollback or `hack down`, which it refuses (HACK-1391).
const report = await inspectComposeEnvAtRest({
projectDir: join(projectRoot, ".hack"),
});
expect(report).toMatchObject({
override: { kind: "plaintext" },
adoptionBound: true,
adoptionPending: true,
});
const checked = await checkComposeEnvOverrideAtRest({
startDir: projectRoot,
});
expect(checked.status).toBe("warn");
expect(checked.message).toContain("pins this file");
expect(checked.message).toContain("hack config adopt --recover --rollback");
expect(checked.message).not.toContain("'hack config adopt --rollback'");
expect(checked.message).not.toContain("stop the adopted containers");
expect(checked.message).not.toContain(CANARY);
// The named step releases the switch; nothing pins the file afterwards.
const released = await adoptCli(
["--recover", "--rollback", "--json"],
Expand Down
Loading
Loading