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.
298 lines
12 KiB
TypeScript
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`,
|
|
);
|
|
});
|
|
});
|