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>
470 lines
16 KiB
TypeScript
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`,
|
|
]);
|
|
});
|
|
});
|