evOo-local/infra/updater/src/fork-recovery.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

298 lines
12 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ServerUpdateRun } from "@rakazo/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createUpdaterApp } from "./index.js";
import { DEFAULT_COMPOSE_FILE, resolveUpdaterConfig } from "./updater-logic.js";
const token = "fake-updater-recovery-token-0000000000";
const currentCommit = "1".repeat(40);
const targetCommit = "2".repeat(40);
const oldTag = `local-${currentCommit}`;
const originalRemote = "https://github.com/example/previous-fork";
const nextRemote = "https://github.com/example/next-fork";
const originalEnv = `RAKAZO_IMAGE_TAG=${oldTag}\nRAKAZO_IMAGE_TAG_PREVIOUS=v0.9.0\n`;
const directories: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true })));
});
type Failure = "checkout" | "merge" | "recreate" | "restore-checkout" | "recover" | "read-head";
interface Invocation {
command: string;
args: string[];
commit: string;
compose?: string;
serviceEnv?: string;
imageTag?: string;
envFile?: string;
}
// These executables use only fixture files. PATH contains no real git or Docker, and the
// updater's production execFile runner is used so command ordering is exercised end to end.
const fakeExecutable = String.raw`
const fs = require("node:fs");
const path = require("node:path");
const command = path.basename(process.argv[1]);
const args = process.argv.slice(2);
const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, "fixture.json"), "utf8"));
const stateFile = path.join(__dirname, "state.json");
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
const call = { command, args, commit: state.commit };
const log = () => fs.appendFileSync(path.join(__dirname, "calls.jsonl"), JSON.stringify(call) + "\n");
const fail = (message) => { process.stderr.write(message); process.exit(1); };
const hasFailure = (name) => fixture.failures.includes(name);
const moveCheckout = (commit, branch, revision) => {
state.commit = commit;
state.branch = branch;
fs.writeFileSync(fixture.composeFile, fixture[revision + "Compose"]);
fs.writeFileSync(fixture.serviceEnv, "REVISION=" + revision + "\n");
};
if (command === "docker") {
call.compose = fs.readFileSync(args[args.indexOf("--file") + 1], "utf8");
call.serviceEnv = fs.readFileSync(fixture.serviceEnv, "utf8");
call.envFile = fs.readFileSync(args[args.indexOf("--env-file") + 1], "utf8");
call.imageTag = process.env.RAKAZO_IMAGE_TAG;
log();
if (!args.includes("up")) fail("Unexpected Docker command");
if (args.includes("--build") && fixture.failures.length > 0) fail("new API unhealthy");
if (args.includes("--no-build") && hasFailure("recover")) fail("cached image unavailable");
} else if (command === "git") {
log();
const joined = args.join(" ");
if (joined === "rev-parse HEAD") {
if (hasFailure("read-head")) fail("HEAD unreadable");
process.stdout.write(state.commit);
} else if (joined === "rev-parse --abbrev-ref HEAD") {
process.stdout.write(state.branch);
} else if (joined === "remote get-url origin") {
process.stdout.write(state.remote);
} else if (args[0] === "ls-remote") {
process.stdout.write(fixture.targetCommit + "\trefs/heads/main\n");
} else if (args[0] === "remote" && args[1] === "set-url") {
state.remote = args[3];
fs.writeFileSync(stateFile, JSON.stringify(state));
} else if (args[0] === "checkout" || args[0] === "merge") {
const restore = args.includes("-B") || args.includes("--detach");
if (restore && hasFailure("restore-checkout")) fail("checkout restore blocked");
if (restore) {
moveCheckout(args.at(-1), args.includes("-B") ? args[2] : "HEAD", "old");
} else {
moveCheckout(fixture.targetCommit, "main", "new");
}
fs.writeFileSync(stateFile, JSON.stringify(state));
if (!restore && hasFailure(args[0])) fail("checkout transition failed");
} else if (args[0] !== "fetch" && !args.includes("status") && !args.includes("ls-files")) {
fail("Unexpected Git command");
}
} else {
fail("Unexpected executable");
}
`;
async function deployment(options: { failures: Failure[]; branch?: string; composePath?: string }) {
const deployDir = await mkdtemp(path.join(os.tmpdir(), "rakazo-fork-recovery-"));
directories.push(deployDir);
const bin = path.join(deployDir, "fake-bin");
const composeFile = path.join(deployDir, options.composePath ?? DEFAULT_COMPOSE_FILE);
const config = resolveUpdaterConfig({
RAKAZO_DEPLOY_DIR: deployDir,
RAKAZO_UPDATER_TOKEN: token,
RAKAZO_COMPOSE_FILE: options.composePath,
COMPOSE_PROJECT_NAME: "fixture-stack",
});
const serviceEnv = path.join(path.dirname(composeFile), "service.env");
const compose = (revision: string) =>
`services:\n api:\n image: example/app:\${RAKAZO_IMAGE_TAG}\n env_file: ./service.env\n command: ${revision}\n`;
await Promise.all([
mkdir(bin),
mkdir(path.join(deployDir, ".git")),
mkdir(path.dirname(composeFile), { recursive: true }),
]);
await Promise.all([
writeFile(config.envFile, originalEnv),
writeFile(composeFile, compose("old")),
writeFile(serviceEnv, "REVISION=old\n"),
writeFile(
path.join(bin, "fixture.json"),
JSON.stringify({
failures: options.failures,
targetCommit,
composeFile,
serviceEnv,
oldCompose: compose("old"),
newCompose: compose("new"),
}),
),
writeFile(
path.join(bin, "state.json"),
JSON.stringify({
commit: currentCommit,
branch: options.branch ?? "deploy",
remote: originalRemote,
}),
),
...["git", "docker"].map((name) =>
writeFile(path.join(bin, name), `#!${process.execPath}\n${fakeExecutable}`, { mode: 0o700 }),
),
]);
vi.stubEnv("PATH", bin);
const subject = createUpdaterApp(config);
const response = await subject.request("/apply", {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ repoUrl: nextRemote, branch: "main" }),
});
const calls = (await readFile(path.join(bin, "calls.jsonl"), "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Invocation);
return {
response,
record: (await response.json()) as ServerUpdateRun,
calls,
config,
composeFile,
state: JSON.parse(await readFile(path.join(bin, "state.json"), "utf8")),
};
}
describe.skipIf(process.platform === "win32")("fork recovery through fake executables", () => {
it.each([
{ branch: "deploy", composePath: undefined },
{ branch: "HEAD", composePath: "ops/custom-compose.yml" },
])("restores $branch and its Compose configuration before recovery", async (options) => {
const fixture = await deployment({ ...options, failures: ["recreate"] });
expect(fixture.response.status).toBe(200);
expect(fixture.record).toMatchObject({ ok: false, restart: "not-required" });
const docker = fixture.calls.filter((call) => call.command === "docker");
expect(docker).toHaveLength(2);
expect(docker[0]).toMatchObject({
commit: targetCommit,
compose: expect.stringContaining("command: new"),
serviceEnv: "REVISION=new\n",
});
expect(docker[1]).toMatchObject({
commit: currentCommit,
compose: expect.stringContaining("command: old"),
serviceEnv: "REVISION=old\n",
imageTag: oldTag,
envFile: originalEnv,
});
expect(fixture.record.steps.map((step) => step.id)).toEqual([
"remote",
"fetch",
"checkout",
"merge",
"recreate",
"restore-checkout",
"recover",
"restore-remote",
]);
expect(docker[1]?.args).toEqual(
expect.arrayContaining([
"-p",
"fixture-stack",
"--file",
fixture.composeFile,
"--pull",
"never",
"--no-build",
]),
);
expect(fixture.state).toEqual({
commit: currentCommit,
branch: options.branch,
remote: originalRemote,
});
expect(await readFile(fixture.config.envFile, "utf8")).toBe(originalEnv);
});
it("skips Compose recovery when restoring the checkout fails", async () => {
const fixture = await deployment({ failures: ["restore-checkout"] });
expect(fixture.record).toMatchObject({
ok: false,
restart: "manual",
error: "Build the new images and recreate the services failed.",
});
expect(fixture.record.restartAdvice).toMatch(/skipped.*checkout could not be restored/);
expect(fixture.calls.filter((call) => call.command === "docker")).toHaveLength(1);
expect(fixture.record.steps.filter((step) => step.id === "restore-checkout")).toEqual([
expect.objectContaining({ ok: false }),
]);
expect(fixture.record.steps.at(-1)).toMatchObject({ id: "restore-remote", ok: true });
expect(fixture.state.remote).toBe(originalRemote);
expect(await readFile(fixture.config.envFile, "utf8")).toBe(originalEnv);
});
it("reports failed Compose recovery while keeping the restored checkout and image pin", async () => {
const fixture = await deployment({ failures: ["recover"] });
expect(fixture.record).toMatchObject({
ok: false,
restart: "manual",
error: "Build the new images and recreate the services failed.",
});
expect(fixture.record.steps.find((step) => step.id === "recover")).toMatchObject({ ok: false });
expect(fixture.calls.filter((call) => call.command === "docker")[1]).toMatchObject({
commit: currentCommit,
serviceEnv: "REVISION=old\n",
imageTag: oldTag,
});
expect(fixture.state).toMatchObject({
commit: currentCommit,
branch: "deploy",
remote: originalRemote,
});
expect(await readFile(fixture.config.envFile, "utf8")).toBe(originalEnv);
});
it.each(["checkout", "merge"] as const)(
"restores a partially failed %s without invoking Compose",
async (failure) => {
const fixture = await deployment({ failures: [failure] });
expect(fixture.record.ok).toBe(false);
expect(fixture.record.steps.find((step) => step.id === "restore-checkout")).toMatchObject({
ok: true,
});
expect(fixture.calls.every((call) => call.command === "git")).toBe(true);
expect(fixture.state).toEqual({
commit: currentCommit,
branch: "deploy",
remote: originalRemote,
});
expect(await readFile(fixture.config.envFile, "utf8")).toBe(originalEnv);
},
);
it("refuses a fork update if its original commit cannot be read", async () => {
const fixture = await deployment({ failures: ["read-head"] });
expect(fixture.response.status).toBe(400);
expect(fixture.record.error).toMatch(/commit.*before updating/);
expect(
fixture.calls.some(
(call) =>
call.args.includes("fetch") ||
call.args.includes("checkout") ||
call.command === "docker",
),
).toBe(false);
expect(await readFile(fixture.config.envFile, "utf8")).toBe(originalEnv);
});
it("keeps the new checkout and image pin after a successful update", async () => {
const fixture = await deployment({ failures: [] });
expect(fixture.record).toMatchObject({ ok: true, restart: "recreated" });
expect(
fixture.record.steps.some((step) => step.id.startsWith("restore") || step.id === "recover"),
).toBe(false);
expect(fixture.calls.filter((call) => call.command === "docker")).toHaveLength(1);
expect(fixture.state).toEqual({ commit: targetCommit, branch: "main", remote: nextRemote });
expect(await readFile(fixture.config.envFile, "utf8")).toContain(
`RAKAZO_IMAGE_TAG=local-${targetCommit}\n`,
);
});
});