evOo-local/packages/testkit/src/evo-deploy.test.ts
Cursor Agent 075c711295
fix(evo-deploy): validate restart config before mutating checkout
Move restart-mode prerequisites (systemd unit default, tmux, compose file,
script hook, EVO_SYSTEMD_SCOPE) ahead of git fetch/install/build/migrate.
Explicit systemd with no EVO_SYSTEMD_UNIT falls back to EVO_SYSTEMD_AUTO_UNIT.
die() after checkout triggers rollback; rollback marks in-progress to avoid loops.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 17:24:25 +00:00

470 lines
16 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const deployScript = path.join(repoRoot, "ops/evo-deploy/deploy.sh");
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function write(file: string, content: string, mode?: number) {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, content);
if (mode !== undefined) chmodSync(file, mode);
}
function temporaryRoot(prefix: string) {
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), prefix)));
temporaryDirectories.push(root);
return root;
}
function searchPath(bin: string) {
return [bin, path.dirname(process.execPath), "/usr/bin", "/bin"].join(path.delimiter);
}
function readLines(file: string) {
return existsSync(file) ? readFileSync(file, "utf8").trim().split("\n") : [];
}
interface Revision {
build?: "ok" | "fail";
health?: "ok" | "down";
}
/**
* A checkout at revision A whose served build came from A, with revision B to deploy. Fake pnpm,
* curl and restart hook record what ran against which checked-out revision.
*/
function deployFixture(next: Revision, previous: Revision = {}) {
const root = temporaryRoot("evo-deploy-");
const checkout = path.join(root, "checkout");
const bin = path.join(root, "bin");
const home = path.join(root, "home");
const commandLog = path.join(root, "commands.log");
const env = {
HOME: home,
PATH: searchPath(bin),
COMMAND_LOG: commandLog,
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@example.com",
GIT_CONFIG_NOSYSTEM: "1",
};
const git = (...args: string[]) =>
execFileSync("git", args, {
cwd: checkout,
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
// deploy.sh runs pnpm through a login shell, which may reset PATH from the system profile.
write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`);
write(
path.join(bin, "pnpm"),
`#!/bin/bash
printf '%s %s\\n' "$(cat version.txt)" "$*" >> "$COMMAND_LOG"
if [[ "$1 $2 $3 $4" == "--filter @rakazo/web build --outDir" ]]; then
echo "vite building for production"
mkdir -p "$5"
if [[ "$(cat build.txt)" != ok ]]; then echo "simulated build error" >&2; exit 1; fi
echo "built $(cat version.txt)" > "$5/index.html"
fi
`,
0o755,
);
write(
path.join(bin, "curl"),
`#!/bin/bash\n[[ "$(cat "$EVO_DEPLOY_DIR/health.txt")" == ok ]]\n`,
0o755,
);
write(
path.join(bin, "restart"),
`#!/bin/bash
served="$(cat "$EVO_DEPLOY_DIR/apps/web/dist/index.html" 2>/dev/null || echo none)"
printf 'restart %s %s\\n' "$(cat "$EVO_DEPLOY_DIR/version.txt")" "$served" >> "$COMMAND_LOG"
`,
0o755,
);
mkdirSync(checkout, { recursive: true });
git("init", "-q", "-b", "main");
const commit = (version: string, revision: Revision) => {
write(
path.join(checkout, "package.json"),
'{"scripts":{"db:migrate":"prisma migrate deploy"}}',
);
write(path.join(checkout, "version.txt"), version);
write(path.join(checkout, "build.txt"), revision.build ?? "ok");
write(path.join(checkout, "health.txt"), revision.health ?? "ok");
git("add", "-A");
git("commit", "-q", "-m", version);
return git("rev-parse", "HEAD");
};
const a = commit("A", previous);
const b = commit("B", next);
git("reset", "-q", "--hard", a);
git("remote", "add", "origin", checkout);
write(path.join(checkout, "apps/web/dist/index.html"), "built A");
return {
a,
b,
checkout,
run(args: string[] = []) {
const result = spawnSync("/bin/bash", [deployScript, ...args], {
env: {
...env,
EVO_DEPLOY_DIR: checkout,
EVO_DEPLOY_REF: b,
EVO_RESTART_MODE: "script",
EVO_RESTART_SCRIPT: path.join(bin, "restart"),
EVO_HEALTH_TIMEOUT_SEC: "1",
},
encoding: "utf8",
timeout: 30_000,
});
expect(result.error).toBeUndefined();
return result;
},
commands: () => readLines(commandLog),
head: () => git("rev-parse", "HEAD"),
served: () => readFileSync(path.join(checkout, "apps/web/dist/index.html"), "utf8").trim(),
state: () => JSON.parse(readFileSync(path.join(checkout, ".evo-deploy/state.json"), "utf8")),
build: (version: string) =>
`${version} --filter @rakazo/web build --outDir ${path.join(checkout, ".evo-deploy/web-dist")} --emptyOutDir`,
};
}
describe("evo deploy", () => {
it("builds before migrating and restarts on the new build", () => {
const f = deployFixture({});
const result = f.run();
expect(result.status, result.stderr).toBe(0);
expect(f.commands()).toEqual([
"B install --frozen-lockfile",
f.build("B"),
"B db:generate",
"B db:migrate",
"restart B built B",
]);
expect(result.stderr).not.toContain("vite building for production");
expect(f.served()).toBe("built B");
expect(existsSync(path.join(f.checkout, ".evo-deploy/web-dist"))).toBe(false);
// Regression: git output on stdout once polluted the captured SHAs.
expect(f.state()).toMatchObject({ previousSha: f.a, deployedSha: f.b, status: "success" });
});
it("stops a failing build before the restart and keeps the served build", () => {
const f = deployFixture({ build: "fail" });
const result = f.run();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("simulated build error");
expect(result.stderr).toContain(`deploy failed; rolled back to ${f.a}`);
expect(f.commands()).toEqual([
"B install --frozen-lockfile",
f.build("B"),
"A install --frozen-lockfile",
"A db:generate",
"restart A built A",
]);
expect(f.head()).toBe(f.a);
expect(f.served()).toBe("built A");
expect(f.state()).toMatchObject({ previousSha: f.a, deployedSha: f.a, status: "rolled-back" });
});
it("rolls back code, dependencies and build when the new revision is unhealthy", () => {
const f = deployFixture({ health: "down" });
const result = f.run();
expect(result.status).not.toBe(0);
expect(f.commands()).toEqual([
"B install --frozen-lockfile",
f.build("B"),
"B db:generate",
"B db:migrate",
"restart B built B",
"A install --frozen-lockfile",
"A db:generate",
f.build("A"),
"restart A built A",
]);
expect(f.head()).toBe(f.a);
expect(f.served()).toBe("built A");
expect(f.state()).toMatchObject({ deployedSha: f.a, status: "rolled-back" });
});
it("reports a rollback that does not come back healthy", () => {
const f = deployFixture({ health: "down" }, { health: "down" });
const result = f.run();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(`rollback to ${f.a} did not complete`);
expect(f.state()).toMatchObject({ deployedSha: f.a, status: "rollback-failed" });
});
it("changes nothing in a dry run", () => {
const f = deployFixture({});
const result = f.run(["--dry-run"]);
expect(result.status, result.stderr).toBe(0);
expect(f.commands()).toEqual([]);
expect(f.head()).toBe(f.a);
expect(f.served()).toBe("built A");
expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false);
});
it("rejects script mode without EVO_RESTART_SCRIPT before mutating the checkout", () => {
const f = deployFixture({});
const root = path.dirname(f.checkout);
const bin = path.join(root, "bin");
const home = path.join(root, "home");
const result = spawnSync("/bin/bash", [deployScript], {
env: {
HOME: home,
PATH: searchPath(bin),
GIT_CONFIG_NOSYSTEM: "1",
EVO_DEPLOY_DIR: f.checkout,
EVO_DEPLOY_REF: f.b,
EVO_RESTART_MODE: "script",
EVO_HEALTH_TIMEOUT_SEC: "1",
},
encoding: "utf8",
timeout: 30_000,
});
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr).toContain("EVO_RESTART_MODE=script requires EVO_RESTART_SCRIPT");
expect(f.commands()).toEqual([]);
expect(f.head()).toBe(f.a);
expect(existsSync(path.join(f.checkout, ".evo-deploy/state.json"))).toBe(false);
});
it("systemd mode without EVO_SYSTEMD_UNIT uses the auto unit name before deploy", () => {
const f = deployFixture({});
const root = path.dirname(f.checkout);
const bin = path.join(root, "bin");
const home = path.join(root, "home");
write(
path.join(bin, "systemctl"),
`#!/bin/bash
case "$*" in
"--user restart evoo-local.service") exit 0 ;;
esac
exit 1
`,
0o755,
);
write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`);
const result = spawnSync("/bin/bash", [deployScript], {
env: {
HOME: home,
PATH: searchPath(bin),
COMMAND_LOG: path.join(root, "commands.log"),
GIT_CONFIG_NOSYSTEM: "1",
EVO_DEPLOY_DIR: f.checkout,
EVO_DEPLOY_REF: f.b,
EVO_RESTART_MODE: "systemd",
EVO_SYSTEMD_SCOPE: "user",
EVO_HEALTH_TIMEOUT_SEC: "1",
},
encoding: "utf8",
timeout: 30_000,
});
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toContain("EVO_SYSTEMD_UNIT unset — using evoo-local.service");
expect(result.stderr).toContain("restart mode: systemd");
});
it("auto mode prefers systemd over compose when the default unit is active", () => {
const root = temporaryRoot("evo-deploy-autodetect-");
const checkout = path.join(root, "checkout");
const bin = path.join(root, "bin");
const home = path.join(root, "home");
const env = {
HOME: home,
PATH: searchPath(bin),
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@example.com",
GIT_CONFIG_NOSYSTEM: "1",
};
write(
path.join(bin, "systemctl"),
`#!/bin/bash
case "$*" in
"--user is-active --quiet evoo-local.service") exit 0 ;;
"--user is-enabled --quiet evoo-local.service") exit 1 ;;
"--user show -p LoadState --value evoo-local.service") echo loaded; exit 0 ;;
esac
exit 1
`,
0o755,
);
write(path.join(home, ".bash_profile"), `export PATH="${bin}:$PATH"\n`);
mkdirSync(checkout, { recursive: true });
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: checkout, env });
write(path.join(checkout, "package.json"), "{}");
execFileSync("git", ["add", "-A"], { cwd: checkout, env });
execFileSync("git", ["commit", "-q", "-m", "init"], { cwd: checkout, env });
execFileSync("git", ["remote", "add", "origin", checkout], { cwd: checkout, env });
write(path.join(checkout, "infra/compose/docker-compose.yml"), "services: {}\n");
const result = spawnSync("/bin/bash", [deployScript, "--dry-run"], {
env: {
...env,
EVO_DEPLOY_DIR: checkout,
EVO_RESTART_MODE: "auto",
EVO_HEALTH_TIMEOUT_SEC: "1",
},
encoding: "utf8",
timeout: 30_000,
});
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toContain("restart mode: systemd");
expect(result.stderr).not.toContain("restart mode: compose");
});
});
/** A checkout layout around a copy of start-stack.sh, with the real tsx and vite installs. */
function stackFixture(options: { build?: boolean; env?: string } = {}) {
const root = temporaryRoot("evo-stack-");
const bin = path.join(root, "bin");
const commandLog = path.join(root, "commands.log");
const script = path.join(root, "ops/evo-deploy/start-stack.sh");
mkdirSync(path.dirname(script), { recursive: true });
copyFileSync(path.join(repoRoot, "ops/evo-deploy/start-stack.sh"), script);
for (const linked of ["node_modules", "packages/core", "apps/web/node_modules"]) {
mkdirSync(path.dirname(path.join(root, linked)), { recursive: true });
symlinkSync(path.join(repoRoot, linked), path.join(root, linked));
}
if (options.build !== false) write(path.join(root, "apps/web/dist/index.html"), "<html></html>");
if (options.env !== undefined) write(path.join(root, ".env"), options.env);
write(
path.join(bin, "pnpm"),
`#!/bin/bash
printf '%s|%s|%s\\n' "$PWD" "\${NODE_ENV:-}" "$*" >> "$COMMAND_LOG"
if [[ "$*" == "dev --filter=!@rakazo/web" ]]; then exec sleep 30; fi
if [[ "$1" == --filter ]]; then
# Exit only after the services were started, so both launches are on record.
for _ in $(seq 50); do
grep -q -e "--filter=!@rakazo/web" "$COMMAND_LOG" && exit 3
sleep 0.1
done
exit 4
fi
`,
0o755,
);
return {
root,
run(args: string[] = [], mode?: string) {
const result = spawnSync("/bin/bash", [script, ...args], {
env: {
HOME: path.join(root, "home"),
PATH: searchPath(bin),
COMMAND_LOG: commandLog,
...(mode ? { EVO_WEB_MODE: mode } : {}),
},
encoding: "utf8",
timeout: 30_000,
});
expect(result.error).toBeUndefined();
return result;
},
commands: () => readLines(commandLog),
};
}
const previewEnv =
"RAKAZO_HOST=app.example.com\nSCREEN_PROXY_SECRET=fake-screen-proxy-secret-for-tests-0123456789\n";
describe("evo stack start script", () => {
it("runs pnpm dev by default", () => {
const f = stackFixture();
const result = f.run();
expect(result.status, result.stderr).toBe(0);
expect(f.commands()).toEqual([`${f.root}||dev`]);
});
it("rejects an unknown web mode", () => {
const result = stackFixture().run(["--check"], "production");
expect(result.status).toBe(1);
expect(result.stderr).toContain("EVO_WEB_MODE must be dev or preview (got: production)");
});
it.each([
["no build", { build: false, env: previewEnv }, ["apps/web/dist has no build"]],
[
"no host or secret",
{ env: "" },
["Set RAKAZO_HOST", "Set SCREEN_PROXY_SECRET to a dedicated random screen proxy credential."],
],
[
"the example secret",
{
env: "RAKAZO_HOST=app.example.com\nSCREEN_PROXY_SECRET=replace-with-32-plus-character-screen-proxy-secret\n",
},
["Replace the SCREEN_PROXY_SECRET placeholder"],
],
])("refuses preview with %s before starting anything", (_name, options, messages) => {
const f = stackFixture(options);
const result = f.run([], "preview");
expect(result.status).toBe(1);
for (const message of messages) expect(result.stderr).toContain(message);
expect(f.commands()).toEqual([]);
});
it("accepts a preview configuration from the root .env", () => {
const f = stackFixture({ env: previewEnv });
const result = f.run(["--check"], "preview");
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toContain("ok (preview)");
expect(f.commands()).toEqual([]);
});
it("serves the build next to the other services and stops when one exits", () => {
const f = stackFixture({ env: previewEnv });
const result = f.run([], "preview");
expect(result.status).toBe(1);
expect(result.stderr).toContain("a service exited");
expect(f.commands().sort()).toEqual([
`${f.root}|production|--filter @rakazo/web preview --strictPort`,
`${f.root}||dev --filter=!@rakazo/web`,
]);
});
});