evOo-local/infra/updater/src/index.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

481 lines
20 KiB
TypeScript

import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, 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 } from "vitest";
import {
commandEnvironment,
createUpdaterApp,
restoreCheckoutArgv,
type UpdaterCommandRunner,
} from "./index.js";
import { resolveUpdaterConfig } from "./updater-logic.js";
const token = "fake-review-updater-token-000000000000";
const app = createUpdaterApp(
resolveUpdaterConfig({
RAKAZO_DEPLOY_DIR: "/rakazo-updater-tests-no-such-directory",
RAKAZO_UPDATER_TOKEN: token,
}),
);
const authorized = { authorization: `Bearer ${token}`, "content-type": "application/json" };
const currentCommit = "1".repeat(40);
const targetCommit = "2".repeat(40);
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function deployment(env = "RAKAZO_IMAGE_TAG=v1.0.0\nRAKAZO_IMAGE_TAG_PREVIOUS=v0.9.0\n") {
const deployDir = await mkdtemp(path.join(os.tmpdir(), "rakazo-updater-test-"));
temporaryDirectories.push(deployDir);
await writeFile(path.join(deployDir, ".env"), env);
return {
deployDir,
config: resolveUpdaterConfig({ RAKAZO_DEPLOY_DIR: deployDir, RAKAZO_UPDATER_TOKEN: token }),
};
}
function ok(output = "") {
return { ok: true, exitCode: 0, output };
}
function failed(output: string) {
return { ok: false, exitCode: 1, output };
}
function request(app: ReturnType<typeof createUpdaterApp>, pathname: string, body?: unknown) {
return app.request(pathname, {
method: "POST",
headers: authorized,
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe("updater HTTP surface", () => {
it("answers health without credentials, for the compose healthcheck", async () => {
const response = await app.request("/health");
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ ok: true, service: "updater" });
});
it("rejects every privileged route without the shared token", async () => {
const routes: Array<[string, string]> = [
["GET", "/state"],
["POST", "/plan"],
["POST", "/apply"],
["POST", "/rollback"],
];
for (const [method, pathname] of routes) {
const response = await app.request(pathname, { method });
expect(response.status, `${method} ${pathname}`).toBe(401);
await expect(response.json()).resolves.toEqual({ error: "unauthorized" });
}
});
it("rejects a wrong or malformed token", async () => {
for (const authorization of [`Bearer ${"x".repeat(token.length)}`, token, "Basic abc"]) {
const response = await app.request("/state", { headers: { authorization } });
expect(response.status).toBe(401);
}
});
it("re-validates the repository URL at its own boundary", async () => {
const cases = [
"http://github.com/a/b",
"file:///etc/passwd",
"https://user:pw@github.com/a/b",
"--upload-pack=id",
"https://github.com/a/b?x=1",
];
for (const repoUrl of cases) {
const response = await app.request("/apply", {
method: "POST",
headers: authorized,
body: JSON.stringify({ repoUrl, branch: "main" }),
});
expect(response.status, repoUrl).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error, repoUrl).toBeTruthy();
}
});
it("re-validates the branch at its own boundary", async () => {
const response = await app.request("/apply", {
method: "POST",
headers: authorized,
body: JSON.stringify({ repoUrl: "https://github.com/elie222/rakazo", branch: "--exec=id" }),
});
expect(response.status).toBe(400);
});
it("refuses a fork build when the deployment has no checkout to build from", async () => {
const fixture = await deployment();
const response = await createUpdaterApp(fixture.config).request("/apply", {
method: "POST",
headers: authorized,
body: JSON.stringify({ repoUrl: "https://github.com/someone/rakazo", branch: "main" }),
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toMatch(/no \.git directory/);
});
it("refuses a rollback when no previous tag was recorded", async () => {
const fixture = await deployment("RAKAZO_IMAGE_TAG=v1.0.0\n");
const response = await createUpdaterApp(fixture.config).request("/rollback", {
method: "POST",
headers: authorized,
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toMatch(/nothing to roll back/);
});
it("reports the deployment it manages without touching Docker", async () => {
const fixture = await deployment();
const response = await createUpdaterApp(fixture.config).request("/state", {
headers: authorized,
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
deployDir: fixture.deployDir,
currentTag: "v1.0.0",
previousTag: "v0.9.0",
checkout: { present: false },
});
});
it("fails closed when the deployment environment cannot be read", async () => {
const response = await app.request("/state", { headers: authorized });
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: expect.stringMatching(/\.env/) });
});
});
describe("updater orchestration", () => {
it("serializes updates before their asynchronous preflight can race", async () => {
const fixture = await deployment();
let releaseGate: ((result: ReturnType<typeof ok>) => void) | undefined;
const pendingRelease = new Promise<ReturnType<typeof ok>>((resolve) => {
releaseGate = resolve;
});
let reachedRemote!: () => void;
const atRemote = new Promise<void>((resolve) => {
reachedRemote = resolve;
});
const run: UpdaterCommandRunner = async (command, args) => {
if (command === "git" && args.includes("ls-remote")) {
reachedRemote();
return pendingRelease;
}
return ok();
};
const subject = createUpdaterApp(fixture.config, { run });
const input = { repoUrl: "https://github.com/elie222/rakazo", branch: "main" };
const first = request(subject, "/apply", input);
await atRemote;
const second = await request(subject, "/apply", input);
expect(second.status).toBe(400);
await expect(second.json()).resolves.toEqual({ error: "An update is already running." });
releaseGate?.(ok(`${targetCommit}\trefs/tags/v1.1.0\n`));
expect((await first).status).toBe(200);
});
it("restores the previous remote when a fork update fails after repointing it", async () => {
const fixture = await deployment();
await mkdir(path.join(fixture.deployDir, ".git"));
const calls: Array<{ command: string; args: string[] }> = [];
const previousRemote = "https://github.com/example/previous-fork";
const nextRemote = "https://github.com/example/next-fork";
const run: UpdaterCommandRunner = async (command, args) => {
calls.push({ command, args });
const joined = args.join(" ");
if (joined === "rev-parse HEAD") return ok(currentCommit);
if (joined === "rev-parse --abbrev-ref HEAD") return ok("main");
if (joined === "remote get-url origin") return ok(previousRemote);
if (args.includes("status") || joined === "ls-files --others --exclude-standard") return ok();
if (args.includes("ls-remote")) return ok(`${targetCommit}\trefs/heads/main\n`);
if (args[0] === "fetch") return failed("registry unavailable");
return ok();
};
const subject = createUpdaterApp(fixture.config, { run });
const response = await request(subject, "/apply", { repoUrl: nextRemote, branch: "main" });
const record = (await response.json()) as ServerUpdateRun;
expect(record.ok).toBe(false);
expect(record.steps.map((step) => step.id)).toEqual(["remote", "fetch", "restore-remote"]);
expect(
calls.filter(({ args }) => args.slice(0, 3).join(" ") === "remote set-url origin"),
).toEqual([
{ command: "git", args: ["remote", "set-url", "origin", nextRemote] },
{ command: "git", args: ["remote", "set-url", "origin", previousRemote] },
]);
});
it("restores the prior image after a recreate fails health checks", async () => {
const fixture = await deployment();
const calls: Array<{ command: string; args: string[]; env?: Record<string, string> }> = [];
let upCalls = 0;
const run: UpdaterCommandRunner = async (command, args, options) => {
calls.push({ command, args, env: options.env });
if (command === "git") return ok(`${targetCommit}\trefs/tags/v1.1.0\n`);
if (args.includes("pull")) return ok("pulled");
upCalls += 1;
return upCalls === 1 ? failed("api did not become healthy") : ok("restored");
};
const subject = createUpdaterApp(fixture.config, { run });
const response = await request(subject, "/apply", {
repoUrl: "https://github.com/elie222/rakazo",
branch: "main",
});
const record = (await response.json()) as ServerUpdateRun;
expect(record).toMatchObject({ ok: false, restart: "not-required" });
expect(record.steps.map((step) => step.id)).toEqual(["pull", "recreate", "recover"]);
expect(record.restartAdvice).toMatch(/restored the previously running v1\.0\.0 image/);
const composeUp = calls.filter(({ args }) => args.includes("up"));
expect(composeUp).toHaveLength(2);
expect(composeUp[0]?.args).toEqual(expect.arrayContaining(["--wait", "--pull", "never"]));
expect(composeUp[0]?.args).toContain("--no-build");
expect(composeUp[1]?.args).toContain("--no-build");
expect(composeUp[0]?.env?.RAKAZO_IMAGE_TAG).toBe(`sha-${targetCommit}`);
expect(composeUp[1]?.env?.RAKAZO_IMAGE_TAG).toBe("v1.0.0");
expect(await readFile(path.join(fixture.deployDir, ".env"), "utf8")).toContain(
"RAKAZO_IMAGE_TAG=v1.0.0",
);
const state = await subject.request("/state", { method: "GET", headers: authorized });
expect(state.status).toBe(200);
await expect(state.json()).resolves.toMatchObject({
running: false,
lastRun: { ok: false, fromTag: "v1.0.0", toTag: `sha-${targetCommit}` },
});
});
it("resets a fork checkout when recreate fails after the fast-forward", async () => {
const fixture = await deployment("RAKAZO_IMAGE_TAG=local\n");
await mkdir(path.join(fixture.deployDir, ".git"));
const calls: string[][] = [];
let upCalls = 0;
const run: UpdaterCommandRunner = async (command, args) => {
calls.push(args);
const joined = args.join(" ");
if (joined === "rev-parse HEAD") {
// After the merge step the checkout is on the target; before that it is still current.
const merged = calls.some((seen) => seen[0] === "merge");
return ok(merged ? targetCommit : currentCommit);
}
if (joined === "rev-parse --abbrev-ref HEAD") return ok("main");
if (joined === "remote get-url origin") return ok("https://github.com/example/fork");
if (args.includes("status") || joined === "ls-files --others --exclude-standard") return ok();
if (args.includes("ls-remote")) return ok(`${targetCommit}\trefs/heads/main\n`);
if (command === "docker" && args.includes("up")) {
upCalls += 1;
return upCalls === 1 ? failed("api did not become healthy") : ok("restored");
}
return ok();
};
const response = await request(createUpdaterApp(fixture.config, { run }), "/apply", {
repoUrl: "https://github.com/example/fork",
branch: "main",
});
const record = (await response.json()) as ServerUpdateRun;
expect(record.ok).toBe(false);
expect(record.steps.map((step) => step.id)).toEqual([
"fetch",
"checkout",
"merge",
"recreate",
"restore-checkout",
"recover",
]);
expect(calls).toContainEqual(["checkout", "-B", "main", currentCommit]);
expect(await readFile(path.join(fixture.deployDir, ".env"), "utf8")).toContain(
"RAKAZO_IMAGE_TAG=local",
);
});
it("restores the prior branch when merge fails after checkout switches", async () => {
const fixture = await deployment("RAKAZO_IMAGE_TAG=local\n");
await mkdir(path.join(fixture.deployDir, ".git"));
const calls: string[][] = [];
const run: UpdaterCommandRunner = async (_command, args) => {
calls.push(args);
const joined = args.join(" ");
if (joined === "rev-parse HEAD") return ok(currentCommit);
if (joined === "rev-parse --abbrev-ref HEAD") return ok("deploy");
if (joined === "remote get-url origin") return ok("https://github.com/example/fork");
if (args.includes("status") || joined === "ls-files --others --exclude-standard") return ok();
if (args.includes("ls-remote")) return ok(`${targetCommit}\trefs/heads/main\n`);
if (args[0] === "merge") return failed("not a fast-forward");
return ok();
};
const response = await request(createUpdaterApp(fixture.config, { run }), "/apply", {
repoUrl: "https://github.com/example/fork",
branch: "main",
});
const record = (await response.json()) as ServerUpdateRun;
expect(record.ok).toBe(false);
expect(record.steps.map((step) => step.id)).toEqual([
"fetch",
"checkout",
"merge",
"restore-checkout",
]);
expect(calls).toContainEqual(["checkout", "-B", "deploy", currentCommit]);
});
it("rolls back from the cached image without trusting the registry tag again", async () => {
const fixture = await deployment();
const calls: string[][] = [];
const run: UpdaterCommandRunner = async (_command, args) => {
calls.push(args);
return ok();
};
const response = await request(createUpdaterApp(fixture.config, { run }), "/rollback");
const record = (await response.json()) as ServerUpdateRun;
expect(record.ok).toBe(true);
expect(calls.some((args) => args.includes("pull"))).toBe(false);
expect(calls.find((args) => args.includes("up"))).toEqual(
expect.arrayContaining(["--pull", "never"]),
);
});
it("preserves the environment owner and surrounding values with mode 0600", async () => {
const fixture = await deployment("FAKE_SETTING=kept\nRAKAZO_IMAGE_TAG=v1.0.0\n");
const envFile = path.join(fixture.deployDir, ".env");
await chmod(envFile, 0o644);
const before = await lstat(envFile);
const run: UpdaterCommandRunner = async (command) =>
command === "git" ? ok(`${targetCommit}\trefs/tags/v1.1.0\n`) : ok();
const response = await request(createUpdaterApp(fixture.config, { run }), "/apply", {
repoUrl: "https://github.com/elie222/rakazo",
branch: "main",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ ok: true });
const after = await lstat(envFile);
expect({ uid: after.uid, gid: after.gid, mode: after.mode & 0o777 }).toEqual({
uid: before.uid,
gid: before.gid,
mode: 0o600,
});
expect(await readFile(envFile, "utf8")).toMatch(/FAKE_SETTING=kept/);
});
it("refuses to replace a symlinked deployment environment", async () => {
const fixture = await deployment();
const envFile = path.join(fixture.deployDir, ".env");
const target = path.join(fixture.deployDir, "fake-target");
await writeFile(target, "RAKAZO_IMAGE_TAG=v1.0.0\n");
await rm(envFile);
await symlink(target, envFile);
const run: UpdaterCommandRunner = async (command) =>
command === "git" ? ok(`${targetCommit}\trefs/tags/v1.1.0\n`) : ok();
const response = await request(createUpdaterApp(fixture.config, { run }), "/apply", {
repoUrl: "https://github.com/elie222/rakazo",
branch: "main",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: false,
error: expect.stringMatching(/persist/),
});
expect(await readFile(target, "utf8")).toBe("RAKAZO_IMAGE_TAG=v1.0.0\n");
});
it("fails closed when it cannot verify checkout cleanliness", async () => {
const fixture = await deployment();
await mkdir(path.join(fixture.deployDir, ".git"));
const run: UpdaterCommandRunner = async (_command, args) => {
const joined = args.join(" ");
if (joined === "rev-parse HEAD") return ok(currentCommit);
if (joined === "rev-parse --abbrev-ref HEAD") return ok("main");
if (joined === "remote get-url origin") return ok("https://github.com/example/fork");
if (args.includes("status")) return failed("cannot read index");
return ok();
};
const response = await request(createUpdaterApp(fixture.config, { run }), "/apply", {
repoUrl: "https://github.com/example/fork",
branch: "main",
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringMatching(/verified/),
});
});
});
describe("child process environment", () => {
it("declares the deployment directory safe so git works on a non-root checkout", () => {
// The child environment is rebuilt from an allowlist, so GIT_CONFIG_* set on the Compose
// service never reaches git. Without these three, every git call exits 128 with "detected
// dubious ownership" on the layout docs/self-host.md recommends.
const env = commandEnvironment({ RAKAZO_DEPLOY_DIR: "/srv/rakazo" });
expect(env.GIT_CONFIG_COUNT).toBe("1");
expect(env.GIT_CONFIG_KEY_0).toBe("safe.directory");
expect(env.GIT_CONFIG_VALUE_0).toBe("/srv/rakazo");
});
it("keeps the exemption scoped to the deployment directory, whoever calls it", () => {
const env = commandEnvironment(
{ RAKAZO_DEPLOY_DIR: "/opt/rakazo" },
{ GIT_CONFIG_VALUE_0: "*", GIT_CONFIG_COUNT: "9" },
);
expect(env.GIT_CONFIG_VALUE_0).toBe("/opt/rakazo");
expect(env.GIT_CONFIG_COUNT).toBe("1");
});
it("sets no git ownership exemption when no deployment directory is known", () => {
const env = commandEnvironment({ PATH: "/usr/bin" });
expect(env.GIT_CONFIG_COUNT).toBeUndefined();
expect(env.GIT_CONFIG_KEY_0).toBeUndefined();
expect(env.GIT_CONFIG_VALUE_0).toBeUndefined();
});
it("passes operational settings and explicit overrides without leaking application secrets", () => {
const env = commandEnvironment(
{
PATH: "/usr/bin",
HTTPS_PROXY: "http://proxy.invalid",
BETTER_AUTH_SECRET: "fake-secret-that-must-not-leak",
DATABASE_URL: "postgres://fake.invalid/db",
AXIOM_TOKEN: "fake-axiom-token",
LOG_LEVEL: "debug",
},
{ RAKAZO_IMAGE_TAG: "sha-123" },
);
expect(env).toMatchObject({
PATH: "/usr/bin",
HTTPS_PROXY: "http://proxy.invalid",
RAKAZO_IMAGE_TAG: "sha-123",
GIT_TERMINAL_PROMPT: "0",
});
expect(env.BETTER_AUTH_SECRET).toBeUndefined();
expect(env.DATABASE_URL).toBeUndefined();
expect(env.AXIOM_TOKEN).toBeUndefined();
expect(env.LOG_LEVEL).toBeUndefined();
});
it("restores detached checkouts without attaching to a branch tip", () => {
expect(restoreCheckoutArgv("HEAD", currentCommit)).toEqual([
"checkout",
"--detach",
currentCommit,
]);
expect(restoreCheckoutArgv(null, currentCommit)).toEqual([
"checkout",
"--detach",
currentCommit,
]);
expect(restoreCheckoutArgv("deploy", currentCommit)).toEqual([
"checkout",
"-B",
"deploy",
currentCommit,
]);
});
});