5a793e70cc
The two `upLane qc option` tests booted a real stack and then only released the slot. `downLane` locates a service's pid file through the lane's slot directory, so releasing the slot first orphaned the child with nothing left able to reach it — one `python3 -m http.server` survived every run, holding a port from a pool that is only ten wide. Thirteen had accumulated; the eleventh run onwards fails with EPORTBUSY in whichever test boots next, which reads as an unrelated flake. Both tests now stop the stack before releasing the slot, and assert the port went quiet — so a teardown that breaks again fails here rather than leaking into the next run. A suite-level `after` covers the case a test throws before its own teardown; it runs before SUITE_ROOT is removed, since the pid files it needs live inside it.
474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
/**
|
|
* @file Tests for lane runtime isolation: slot and port allocation, profile
|
|
* parsing and hook execution, and the boot/down lifecycle.
|
|
*
|
|
* The lifecycle tests run a REAL profile whose boot hook starts a REAL server and
|
|
* whose health hook polls it, because the properties worth proving are exactly
|
|
* the ones a mock cannot show: that a service detached by `harness_spawn` keeps
|
|
* listening after the hook exits, that runtime facts recomputed from scratch still
|
|
* find it (the "survives a dashboard restart" claim), and that `downLane` reaches
|
|
* a process it never spawned directly.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const pathMod = require("node:path");
|
|
const fsMod = require("node:fs");
|
|
|
|
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "ccam-runtime-"));
|
|
process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db");
|
|
process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes");
|
|
|
|
const { describe, it, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const net = require("node:net");
|
|
|
|
const lanesLib = require("../lib/lanes");
|
|
const slots = require("../lib/lane-slots");
|
|
const profileLib = require("../lib/lane-profile");
|
|
const runtime = require("../lib/lane-runtime");
|
|
const { isListening } = require("../lib/ports");
|
|
|
|
/**
|
|
* Lanes whose boot hook actually spawned a detached process. `harness_spawn`
|
|
* children outlive the hook by design, so a test that throws before its own
|
|
* `downLane` — or never calls one — leaves a real server holding a real port
|
|
* AFTER the suite exits. The port pool is only ten wide per name, so ten such
|
|
* runs exhaust it and every later boot fails with EPORTBUSY: a leak in these
|
|
* tests reads as a failure in whichever test runs next.
|
|
*/
|
|
const bootedLanes = new Set();
|
|
|
|
// One teardown, in this order on purpose: downLane reads the pid files under
|
|
// LANES_ROOT, which lives inside SUITE_ROOT — removing the tree first would
|
|
// leave nothing to kill the children with.
|
|
after(async () => {
|
|
for (const id of bootedLanes) {
|
|
try {
|
|
const lane = lanesLib.getLane(id);
|
|
if (lane) await runtime.downLane(lane);
|
|
} catch {
|
|
/* already stopped, or torn down by the test itself */
|
|
}
|
|
}
|
|
fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
let laneSeq = 0;
|
|
/** A lane row backed by a real directory, so profile lookup and mkdir work. */
|
|
function makeLane(over = {}) {
|
|
laneSeq += 1;
|
|
const cwd = pathMod.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
|
|
fsMod.mkdirSync(cwd, { recursive: true });
|
|
return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed", ...over });
|
|
}
|
|
|
|
/** Write a profile into a repo directory. `hooks` maps name -> script body. */
|
|
function writeProfile(root, envText, hooks = {}) {
|
|
const dir = pathMod.join(root, ".ccam", "profile");
|
|
fsMod.mkdirSync(pathMod.join(dir, "hooks"), { recursive: true });
|
|
fsMod.writeFileSync(pathMod.join(dir, "profile.env"), envText);
|
|
for (const [name, body] of Object.entries(hooks)) {
|
|
const file = pathMod.join(dir, "hooks", `${name}.sh`);
|
|
fsMod.writeFileSync(file, body);
|
|
fsMod.chmodSync(file, 0o755);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
/** Hold a port open so the allocator has to step aside. */
|
|
function occupy(port) {
|
|
const server = net.createServer(() => {});
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(port, "127.0.0.1", () => resolve(server));
|
|
});
|
|
}
|
|
|
|
describe("profile parsing", () => {
|
|
it("keeps command substitution literal instead of executing it", () => {
|
|
const parsed = profileLib.parseEnvFile(
|
|
['PORTS="api fe"', "export EVIL=$(id)", "TICK=`whoami`", "# comment", "", "BARE=plain"].join(
|
|
"\n"
|
|
)
|
|
);
|
|
assert.equal(parsed.PORTS, "api fe");
|
|
assert.equal(parsed.EVIL, "$(id)");
|
|
assert.equal(parsed.TICK, "`whoami`");
|
|
assert.equal(parsed.BARE, "plain");
|
|
assert.equal(parsed["# comment"], undefined);
|
|
});
|
|
|
|
it("fills defaults for omitted declarations", () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=web\n");
|
|
const profile = profileLib.resolveProfile(lane);
|
|
assert.deepEqual(profile.ports, ["web"]);
|
|
assert.equal(profile.env.PORT_BASE_fe, "3000");
|
|
assert.equal(profile.env.BACKEND_DIR, "backend");
|
|
});
|
|
|
|
it("returns null when no profile exists, without throwing", () => {
|
|
const lane = makeLane();
|
|
assert.equal(profileLib.resolveProfile(lane), null);
|
|
});
|
|
|
|
it("prefers the lane's own working copy over the source repo", () => {
|
|
const source = pathMod.join(SUITE_ROOT, "source-repo");
|
|
fsMod.mkdirSync(source, { recursive: true });
|
|
writeProfile(source, "PORTS=fromsource\n");
|
|
const lane = makeLane({ source_repo: source });
|
|
writeProfile(lane.cwd, "PORTS=fromlane\n");
|
|
assert.deepEqual(profileLib.resolveProfile(lane).ports, ["fromlane"]);
|
|
});
|
|
|
|
it("falls back to the source repo when the worktree has none", () => {
|
|
const source = pathMod.join(SUITE_ROOT, "source-repo-2");
|
|
fsMod.mkdirSync(source, { recursive: true });
|
|
writeProfile(source, "PORTS=fromsource\n");
|
|
const lane = makeLane({ source_repo: source });
|
|
assert.deepEqual(profileLib.resolveProfile(lane).ports, ["fromsource"]);
|
|
});
|
|
});
|
|
|
|
describe("slot allocation", () => {
|
|
it("hands out the lowest free slot and reuses a released one", () => {
|
|
const made = [];
|
|
for (let i = 0; i < 4; i += 1) {
|
|
const lane = makeLane();
|
|
slots.allocateSlot(lane.id);
|
|
made.push(lanesLib.getLane(lane.id));
|
|
}
|
|
assert.deepEqual(
|
|
made.map((lane) => lane.slot),
|
|
[1, 2, 3, 4]
|
|
);
|
|
|
|
slots.releaseSlot(made[1].id);
|
|
assert.equal(lanesLib.getLane(made[1].id).slot, null);
|
|
|
|
const next = makeLane();
|
|
assert.equal(slots.allocateSlot(next.id), 2);
|
|
|
|
for (const lane of [...made, next]) slots.releaseSlot(lane.id);
|
|
});
|
|
|
|
it("refuses to allocate past the configured ceiling", () => {
|
|
const previous = process.env.LANE_MAX_SLOTS;
|
|
process.env.LANE_MAX_SLOTS = "2";
|
|
const made = [makeLane(), makeLane(), makeLane()];
|
|
slots.allocateSlot(made[0].id);
|
|
slots.allocateSlot(made[1].id);
|
|
assert.throws(
|
|
() => slots.allocateSlot(made[2].id),
|
|
(err) => err.code === "ESLOTS"
|
|
);
|
|
for (const lane of made) slots.releaseSlot(lane.id);
|
|
if (previous === undefined) delete process.env.LANE_MAX_SLOTS;
|
|
else process.env.LANE_MAX_SLOTS = previous;
|
|
});
|
|
});
|
|
|
|
describe("port allocation", () => {
|
|
it("derives base + slot when the port is free", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18500\n");
|
|
slots.allocateSlot(lane.id);
|
|
const current = lanesLib.getLane(lane.id);
|
|
const ports = await slots.resolvePorts(current, profileLib.resolveProfile(current));
|
|
assert.equal(ports.api, 18500 + current.slot);
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
|
|
it("steps aside by 100 when the preferred port is taken, keeping the slot digit", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18600\n");
|
|
slots.allocateSlot(lane.id);
|
|
const current = lanesLib.getLane(lane.id);
|
|
const held = await occupy(18600 + current.slot);
|
|
try {
|
|
const ports = await slots.resolvePorts(current, profileLib.resolveProfile(current));
|
|
assert.equal(ports.api, 18700 + current.slot);
|
|
assert.equal(ports.api % 10, current.slot % 10);
|
|
} finally {
|
|
held.close();
|
|
slots.releaseSlot(lane.id);
|
|
}
|
|
});
|
|
|
|
it("never reuses a port another lane has recorded, even while that lane is down", async () => {
|
|
const a = makeLane();
|
|
const b = makeLane();
|
|
writeProfile(a.cwd, "PORTS=api\nPORT_BASE_api=18800\n");
|
|
// b's base is offset so its preferred number collides with a's recorded one.
|
|
slots.allocateSlot(a.id);
|
|
const laneA = lanesLib.getLane(a.id);
|
|
lanesLib.setProvisioningFacts(laneA.id, { ports: { api: 18800 + laneA.slot } });
|
|
|
|
slots.allocateSlot(b.id);
|
|
const laneB = lanesLib.getLane(b.id);
|
|
writeProfile(b.cwd, `PORTS=api\nPORT_BASE_api=${18800 + laneA.slot - laneB.slot}\n`);
|
|
const ports = await slots.resolvePorts(laneB, profileLib.resolveProfile(laneB));
|
|
assert.notEqual(ports.api, 18800 + laneA.slot);
|
|
|
|
slots.releaseSlot(a.id);
|
|
slots.releaseSlot(b.id);
|
|
});
|
|
});
|
|
|
|
describe("hook execution", () => {
|
|
it("exports the lane contract and scrubs inherited git variables", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18900\nCUSTOM=from-profile\n", {
|
|
boot: '#!/usr/bin/env bash\necho "LANE=$LANE API_PORT=$API_PORT CUSTOM=$CUSTOM GITDIR=[${GIT_DIR:-unset}] ARG=$1"\n',
|
|
});
|
|
slots.allocateSlot(lane.id);
|
|
lanesLib.setProvisioningFacts(lane.id, { ports: { api: 18999 } });
|
|
const current = lanesLib.getLane(lane.id);
|
|
|
|
process.env.GIT_DIR = "/somewhere/else/.git";
|
|
try {
|
|
const result = await profileLib.runHook(current, profileLib.resolveProfile(current), "boot", [
|
|
"--no-build",
|
|
]);
|
|
assert.equal(result.code, 0);
|
|
assert.match(result.output, new RegExp(`LANE=${current.slot}\\b`));
|
|
assert.match(result.output, /API_PORT=18999/);
|
|
assert.match(result.output, /CUSTOM=from-profile/);
|
|
assert.match(result.output, /GITDIR=\[unset\]/);
|
|
assert.match(result.output, /ARG=--no-build/);
|
|
} finally {
|
|
delete process.env.GIT_DIR;
|
|
slots.releaseSlot(lane.id);
|
|
}
|
|
});
|
|
|
|
it("rejects a hook name outside the allowlist without spawning anything", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=api\n", { boot: "#!/usr/bin/env bash\ntrue\n" });
|
|
slots.allocateSlot(lane.id);
|
|
const current = lanesLib.getLane(lane.id);
|
|
await assert.rejects(
|
|
() => profileLib.runHook(current, profileLib.resolveProfile(current), "../../etc/passwd"),
|
|
(err) => err.code === "ENOHOOK"
|
|
);
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
|
|
it("resolves with the exit code rather than throwing when a hook fails", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=api\n", {
|
|
boot: '#!/usr/bin/env bash\necho "nope" >&2\nexit 3\n',
|
|
});
|
|
slots.allocateSlot(lane.id);
|
|
const current = lanesLib.getLane(lane.id);
|
|
const result = await profileLib.runHook(current, profileLib.resolveProfile(current), "boot");
|
|
assert.equal(result.code, 3);
|
|
assert.match(result.output, /nope/);
|
|
assert.ok(fsMod.existsSync(result.logPath));
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
});
|
|
|
|
describe("lifecycle", () => {
|
|
/** A profile whose boot hook starts a real HTTP listener and detaches it. */
|
|
function serverProfile(lane, base) {
|
|
writeProfile(lane.cwd, `PORTS=web\nPORT_BASE_web=${base}\nLANE_DIRS="uploads .cache"\n`, {
|
|
boot: [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'harness_spawn web "$LANE_DIR" python3 -m http.server "$WEB_PORT" --bind 127.0.0.1',
|
|
"",
|
|
].join("\n"),
|
|
health: [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
"for _ in $(seq 1 50); do",
|
|
' if curl -sf "http://127.0.0.1:$WEB_PORT/" >/dev/null; then exit 0; fi',
|
|
" sleep 0.2",
|
|
"done",
|
|
"exit 1",
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
}
|
|
|
|
it("boots a detached stack, still sees it after a cold recompute, then stops it", async () => {
|
|
const lane = makeLane();
|
|
serverProfile(lane, 19100);
|
|
|
|
bootedLanes.add(lane.id);
|
|
const facts = await runtime.upLane(lanesLib.getLane(lane.id));
|
|
assert.equal(facts.available, true);
|
|
assert.equal(facts.up, true);
|
|
assert.equal(facts.healthy, true);
|
|
|
|
const booted = lanesLib.getLane(lane.id);
|
|
const port = booted.ports.web;
|
|
assert.ok(port, "a port was recorded");
|
|
assert.equal(await isListening(port), true);
|
|
|
|
// The declared per-lane directories exist inside the working copy.
|
|
assert.ok(fsMod.existsSync(pathMod.join(booted.cwd, "uploads")));
|
|
assert.ok(fsMod.existsSync(pathMod.join(booted.cwd, ".cache")));
|
|
|
|
// "Survives a dashboard restart": nothing in memory is consulted — the facts
|
|
// are rebuilt from the pid file on disk and a fresh port probe.
|
|
const recomputed = await runtime.runtimeFacts(lanesLib.getLane(lane.id));
|
|
assert.equal(recomputed.up, true);
|
|
assert.equal(recomputed.healthy, true);
|
|
assert.equal(recomputed.services.find((s) => s.name === "web").alive, true);
|
|
|
|
await runtime.downLane(lanesLib.getLane(lane.id));
|
|
assert.equal(await isListening(port), false);
|
|
const afterDown = await runtime.runtimeFacts(lanesLib.getLane(lane.id));
|
|
assert.equal(afterDown.up, false);
|
|
assert.deepEqual(afterDown.services, []);
|
|
|
|
bootedLanes.delete(lane.id);
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
|
|
it("records a failed health check without touching the lane's agent fields", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19200\n", {
|
|
boot: "#!/usr/bin/env bash\ntrue\n",
|
|
health: "#!/usr/bin/env bash\nexit 1\n",
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => runtime.upLane(lanesLib.getLane(lane.id)),
|
|
(err) => err.code === "EUNHEALTHY"
|
|
);
|
|
|
|
const after = lanesLib.getLane(lane.id);
|
|
assert.equal(after.stage, "idle", "runtime must not write stage");
|
|
assert.equal(after.status, "idle", "runtime must not write status");
|
|
assert.equal(after.notes, null, "runtime must not write notes");
|
|
|
|
const facts = await runtime.runtimeFacts(after);
|
|
assert.equal(facts.lastError.code, "EUNHEALTHY");
|
|
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
|
|
it("reports available:false for a lane with no profile", async () => {
|
|
const lane = makeLane();
|
|
const facts = await runtime.runtimeFacts(lane);
|
|
assert.equal(facts.available, false);
|
|
assert.ok(facts.searched.length > 0);
|
|
await assert.rejects(
|
|
() => runtime.upLane(lane),
|
|
(err) => err.code === "ENOPROFILE"
|
|
);
|
|
});
|
|
|
|
it("is a no-op when taking down a lane that was never up", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=web\n", { boot: "#!/usr/bin/env bash\ntrue\n" });
|
|
assert.deepEqual(await runtime.downLane(lane), { killed: [] });
|
|
});
|
|
|
|
it("refuses a LANE_DIRS entry that escapes the working copy", () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, 'PORTS=web\nLANE_DIRS="../escaped"\n');
|
|
assert.throws(
|
|
() => runtime.makeLaneDirs(lane, profileLib.resolveProfile(lane)),
|
|
(err) => err.code === "EBADLANEDIR"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("upLane qc option", () => {
|
|
it("injects QC_BOOT_ENV into the boot hook's environment when qc:true", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19300\nQC_BOOT_ENV=SOME_QC_VAR=from-qc\n", {
|
|
boot: [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'echo "QC=$SOME_QC_VAR" > "$LANE_DIR/qc-marker.txt"',
|
|
'harness_spawn web "$LANE_DIR" python3 -m http.server "$WEB_PORT" --bind 127.0.0.1',
|
|
"",
|
|
].join("\n"),
|
|
health: [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
"for _ in $(seq 1 50); do",
|
|
' if curl -sf "http://127.0.0.1:$WEB_PORT/" >/dev/null; then exit 0; fi',
|
|
" sleep 0.2",
|
|
"done",
|
|
"exit 1",
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
|
|
bootedLanes.add(lane.id);
|
|
await runtime.upLane(lanesLib.getLane(lane.id), { qc: true });
|
|
const booted = lanesLib.getLane(lane.id);
|
|
const markerPath = pathMod.join(booted.cwd, "qc-marker.txt");
|
|
const marker = fsMod.readFileSync(markerPath, "utf8");
|
|
assert.match(marker, /QC=from-qc/);
|
|
|
|
// Stop the stack BEFORE releasing the slot: downLane locates the pid file
|
|
// through the lane's slot directory, so a released slot orphans a running
|
|
// child with no way left to reach it. Asserting the port went quiet is what
|
|
// makes a broken teardown fail HERE instead of leaking a live server into
|
|
// the next run's port pool.
|
|
const bootedPort = booted.ports.web;
|
|
await runtime.downLane(lanesLib.getLane(lane.id));
|
|
assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane");
|
|
bootedLanes.delete(lane.id);
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
|
|
it("does not touch the environment when qc is omitted (default false)", async () => {
|
|
const lane = makeLane();
|
|
writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19400\nQC_BOOT_ENV=SOME_QC_VAR=from-qc\n", {
|
|
boot: [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'echo "QC=${SOME_QC_VAR:-not-set}" > "$LANE_DIR/qc-marker-no-qc.txt"',
|
|
'harness_spawn web "$LANE_DIR" python3 -m http.server "$WEB_PORT" --bind 127.0.0.1',
|
|
"",
|
|
].join("\n"),
|
|
health: [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
"for _ in $(seq 1 50); do",
|
|
' if curl -sf "http://127.0.0.1:$WEB_PORT/" >/dev/null; then exit 0; fi',
|
|
" sleep 0.2",
|
|
"done",
|
|
"exit 1",
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
|
|
bootedLanes.add(lane.id);
|
|
await runtime.upLane(lanesLib.getLane(lane.id));
|
|
const booted = lanesLib.getLane(lane.id);
|
|
const markerPath = pathMod.join(booted.cwd, "qc-marker-no-qc.txt");
|
|
const marker = fsMod.readFileSync(markerPath, "utf8");
|
|
assert.match(marker, /QC=not-set/);
|
|
|
|
const bootedPort = booted.ports.web;
|
|
await runtime.downLane(lanesLib.getLane(lane.id));
|
|
assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane");
|
|
bootedLanes.delete(lane.id);
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
});
|
|
|
|
describe("allocation is not client-patchable", () => {
|
|
it("ignores slot and ports coming through updateLane", () => {
|
|
const lane = makeLane();
|
|
slots.allocateSlot(lane.id);
|
|
const before = lanesLib.getLane(lane.id);
|
|
lanesLib.updateLane(lane.id, { slot: 99, ports: { api: 1 }, title: "renamed" });
|
|
const after = lanesLib.getLane(lane.id);
|
|
assert.equal(after.slot, before.slot);
|
|
assert.deepEqual(after.ports, before.ports);
|
|
assert.equal(after.title, "renamed", "patchable fields still apply");
|
|
slots.releaseSlot(lane.id);
|
|
});
|
|
});
|