feat(lanes): per-lane database, Redis, and .env isolation (A1+A2)

Gives each lane its own slot-derived runtime (ports, detached process
lifecycle, profile-driven hooks) and its own database/Redis logical
index/.env file, so two lanes running the same repo's stack at once no
longer share state. Machine-level DB/Redis credentials live at
~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's
output is redacted of that password (raw and URL-encoded forms) before
it reaches a log file or the lane_hook_output websocket broadcast.
Wired into provision/up/reset/remove; reset accepts --keep-db to skip
the drop/recreate/migrate/reseed block entirely.
This commit is contained in:
2026-08-04 10:03:40 +07:00
parent d71086f677
commit 9d145865dd
19 changed files with 3265 additions and 12 deletions
+370
View File
@@ -0,0 +1,370 @@
/**
* @file Tests for A2 data isolation: machine-level secrets, per-lane database
* lifecycle (create once / reset / drop), and `.env` seeding.
*
* The database lifecycle tests use a SQLite-shaped fixture hook (write a file,
* remove a file) rather than a real Postgres — the property worth proving is
* CCAM's own orchestration (when a hook runs, what name it is trusted with,
* that a repeat call is a no-op), not any particular database engine.
* @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-data-"));
process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes");
process.env.CCAM_SECRETS_PATH = pathMod.join(SUITE_ROOT, "secrets.env");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const lanesLib = require("../lib/lanes");
const slots = require("../lib/lane-slots");
const profileLib = require("../lib/lane-profile");
const runtime = require("../lib/lane-runtime");
const envLib = require("../lib/lane-env");
const servicesLib = require("../lib/lane-services");
const secretsLib = require("../lib/secrets");
after(() => fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true }));
let laneSeq = 0;
/** A managed lane row backed by a real directory. */
function makeLane(over = {}) {
laneSeq += 1;
const cwd = pathMod.join(SUITE_ROOT, `data-lane-cwd-${laneSeq}`);
fsMod.mkdirSync(cwd, { recursive: true });
return lanesLib.createLane({
title: `data-lane ${laneSeq}`,
cwd,
kind: "managed",
source_repo: cwd,
...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;
}
// A file-based "database" so the create/drop lifecycle is provable without a
// real engine: db-create WRITES (so a wrongly-repeated call would clobber
// existing content, proving the once-only marker actually matters), db-drop
// removes.
const SQLITE_DB_HOOKS = {
"db-create":
'#!/usr/bin/env bash\nset -euo pipefail\necho created > "$LANE_DIR/${1:-$DB_NAME}.db"\n',
"db-drop": '#!/usr/bin/env bash\nset -euo pipefail\nrm -f "$LANE_DIR/${1:-$DB_NAME}.db"\n',
};
describe("secrets", () => {
const secretsPath = process.env.CCAM_SECRETS_PATH;
it("falls back to defaults when the file is absent", () => {
assert.equal(fsMod.existsSync(secretsPath), false);
const s = secretsLib.readSecrets();
assert.equal(s.PG_HOST, "127.0.0.1");
assert.equal(s.PG_USER, "postgres");
});
it("refuses a group/world-readable secrets file, falling back to defaults", () => {
fsMod.writeFileSync(secretsPath, "PG_USER=leaked-user\n");
fsMod.chmodSync(secretsPath, 0o644);
const s = secretsLib.readSecrets();
assert.equal(s.PG_USER, "postgres");
});
it("loads a mode-0600 file", () => {
fsMod.writeFileSync(secretsPath, "PG_USER=custom-user\nPG_PASS=custom-pass\n");
fsMod.chmodSync(secretsPath, 0o600);
const s = secretsLib.readSecrets();
assert.equal(s.PG_USER, "custom-user");
assert.equal(s.PG_PASS, "custom-pass");
});
});
describe("lane-env seedEnv", () => {
it("rewrites ENV_REWRITE keys, keeping every other line byte-identical", () => {
const source = pathMod.join(SUITE_ROOT, "env-source-repo");
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
fsMod.writeFileSync(
pathMod.join(source, "backend", ".env"),
"FOO=bar\nDATABASE_URL=stale\nBAZ=qux\n"
);
const lane = makeLane({ source_repo: source });
writeProfile(
lane.cwd,
["DB_PREFIX=envtest_l", "ENV_FILES=backend/.env", "ENV_REWRITE=DATABASE_URL"].join("\n")
);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
envLib.seedEnv(current, profile, secretsLib.readSecrets());
const lines = fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8").split("\n");
assert.equal(lines[0], "FOO=bar");
assert.equal(lines[2], "BAZ=qux");
assert.match(lines[1], /^DATABASE_URL=postgresql:\/\//);
slots.releaseSlot(lane.id);
});
it("--force preserves ENV_PRESERVE keys from the lane's own existing file", () => {
const source = pathMod.join(SUITE_ROOT, "env-source-repo-2");
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
fsMod.writeFileSync(
pathMod.join(source, "backend", ".env"),
"JWT_SECRET=source-secret\nOTHER=from-source\n"
);
const lane = makeLane({ source_repo: source });
writeProfile(lane.cwd, ["ENV_FILES=backend/.env", "ENV_PRESERVE=JWT_SECRET"].join("\n"));
slots.allocateSlot(lane.id);
let current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
envLib.seedEnv(current, profile, secretsLib.readSecrets());
fsMod.writeFileSync(
pathMod.join(current.cwd, "backend/.env"),
"JWT_SECRET=lane-own-secret\nOTHER=from-source\n"
);
envLib.seedEnv(current, profile, secretsLib.readSecrets(), { force: true });
const text = fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8");
assert.match(text, /JWT_SECRET=lane-own-secret/);
slots.releaseSlot(lane.id);
});
it("falls back to .env.example when the source .env is missing", () => {
const source = pathMod.join(SUITE_ROOT, "env-source-repo-3");
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
fsMod.writeFileSync(pathMod.join(source, "backend", ".env.example"), "TEMPLATE=1\n");
const lane = makeLane({ source_repo: source });
writeProfile(lane.cwd, "ENV_FILES=backend/.env\n");
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
const result = envLib.seedEnv(current, profile, secretsLib.readSecrets());
assert.equal(result.seeded[0].fromExample, true);
assert.match(
fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8"),
/TEMPLATE=1/
);
slots.releaseSlot(lane.id);
});
it("throws ENOTMANAGED for an adopted lane", () => {
const lane = makeLane({ kind: "adopted" });
writeProfile(lane.cwd, "ENV_FILES=backend/.env\n");
assert.throws(
() =>
envLib.seedEnv(
lanesLib.getLane(lane.id),
profileLib.resolveProfile(lanesLib.getLane(lane.id)),
secretsLib.readSecrets()
),
(err) => err.code === "ENOTMANAGED"
);
});
it("is a no-op when ENV_FILES is not declared", () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=noenv_l\n");
const result = envLib.seedEnv(lane, profileLib.resolveProfile(lane), secretsLib.readSecrets());
assert.equal(result.skipped, true);
});
});
describe("lane-services database guard", () => {
it("refuses to drop a name that isn't this lane's own derived one, before spawning anything", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=guard_l\n", SQLITE_DB_HOOKS);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
await assert.rejects(
() => servicesLib.dropDatabase(current, profile, "someone-elses-db"),
(err) => err.code === "EBADDBNAME"
);
assert.equal(fsMod.existsSync(pathMod.join(current.cwd, "someone-elses-db.db")), false);
slots.releaseSlot(lane.id);
});
it("throws ENOTMANAGED for an adopted lane's own derived name", async () => {
const lane = makeLane({ kind: "adopted" });
writeProfile(lane.cwd, "DB_PREFIX=guard2_l\n", SQLITE_DB_HOOKS);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
await assert.rejects(
() => servicesLib.dropDatabase(current, profile, slots.dbName(profile, current.slot)),
(err) => err.code === "ENOTMANAGED"
);
slots.releaseSlot(lane.id);
});
it("creates a database once, tracked by a marker so a repeat call is a no-op", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=once_l\n", SQLITE_DB_HOOKS);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
const first = await servicesLib.ensureDatabase(current, profile, {});
assert.equal(first.created, true);
const dbFile = pathMod.join(current.cwd, `once_l${current.slot}.db`);
fsMod.writeFileSync(dbFile, "sentinel");
const second = await servicesLib.ensureDatabase(current, profile, {});
assert.equal(second.created, false);
assert.equal(fsMod.readFileSync(dbFile, "utf8"), "sentinel", "hook must not re-run");
slots.releaseSlot(lane.id);
});
it("is a no-op when DB_PREFIX is not declared", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "", SQLITE_DB_HOOKS);
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const result = await servicesLib.ensureDatabase(
current,
profileLib.resolveProfile(current),
{}
);
assert.deepEqual(result, { name: null, created: false });
slots.releaseSlot(lane.id);
});
});
describe("lane data lifecycle: provision / reset / remove", () => {
it("provision creates the database; reset recreates it; remove drops it", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=lifecycle_l\n", SQLITE_DB_HOOKS);
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
const dbFile = pathMod.join(provisioned.cwd, `lifecycle_l${provisioned.slot}.db`);
assert.ok(fsMod.existsSync(dbFile), "provision creates the database");
fsMod.writeFileSync(dbFile, "old-feature-data");
const profile = profileLib.resolveProfile(provisioned);
await runtime.resetLaneData(lanesLib.getLane(lane.id), profile, {});
assert.equal(
fsMod.readFileSync(dbFile, "utf8").trim(),
"created",
"reset drops and recreates, losing the old content"
);
await runtime.removeLaneData(lanesLib.getLane(lane.id), profile, {});
assert.equal(fsMod.existsSync(dbFile), false, "remove drops the database");
slots.releaseSlot(lane.id);
});
it("--keep-db leaves the database untouched across reset", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=keepdb_l\n", SQLITE_DB_HOOKS);
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
const dbFile = pathMod.join(provisioned.cwd, `keepdb_l${provisioned.slot}.db`);
fsMod.writeFileSync(dbFile, "must-survive");
const profile = profileLib.resolveProfile(provisioned);
await runtime.resetLaneData(lanesLib.getLane(lane.id), profile, { keepDb: true });
assert.equal(fsMod.readFileSync(dbFile, "utf8"), "must-survive");
slots.releaseSlot(lane.id);
});
it("removeLaneData never touches an adopted lane's database", async () => {
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=adopted_l\n", SQLITE_DB_HOOKS);
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
const dbFile = pathMod.join(provisioned.cwd, `adopted_l${provisioned.slot}.db`);
assert.ok(fsMod.existsSync(dbFile));
lanesLib.setProvisioningFacts(lane.id, { kind: "adopted" });
const adoptedLane = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(adoptedLane);
await runtime.removeLaneData(adoptedLane, profile, {});
assert.ok(fsMod.existsSync(dbFile), "an adopted lane's data is never CCAM's to destroy");
slots.releaseSlot(lane.id);
});
it("isolates two lanes' databases from each other", async () => {
const a = makeLane();
const b = makeLane();
writeProfile(a.cwd, "DB_PREFIX=iso_l\n", SQLITE_DB_HOOKS);
writeProfile(b.cwd, "DB_PREFIX=iso_l\n", SQLITE_DB_HOOKS);
const pa = await runtime.provisionLane(lanesLib.getLane(a.id));
const pb = await runtime.provisionLane(lanesLib.getLane(b.id));
assert.notEqual(pa.slot, pb.slot);
const dbA = pathMod.join(pa.cwd, `iso_l${pa.slot}.db`);
const dbB = pathMod.join(pb.cwd, `iso_l${pb.slot}.db`);
fsMod.writeFileSync(dbA, "rows-for-a");
fsMod.writeFileSync(dbB, "rows-for-b");
assert.notEqual(dbA, dbB);
assert.notEqual(fsMod.readFileSync(dbA, "utf8"), fsMod.readFileSync(dbB, "utf8"));
slots.releaseSlot(a.id);
slots.releaseSlot(b.id);
});
});
describe("runtimeFacts never leaks a secret value", () => {
it("reports only names and an index, never a connection string", async () => {
fsMod.writeFileSync(process.env.CCAM_SECRETS_PATH, "PG_PASS=super-secret-password\n");
fsMod.chmodSync(process.env.CCAM_SECRETS_PATH, 0o600);
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=leak_l\nREDIS=1\n", SQLITE_DB_HOOKS);
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
const facts = await runtime.runtimeFacts(lanesLib.getLane(provisioned.id));
assert.equal(facts.database.name, `leak_l${provisioned.slot}`);
assert.equal(facts.redisIndex, provisioned.slot);
assert.doesNotMatch(JSON.stringify(facts), /super-secret-password/);
slots.releaseSlot(lane.id);
});
it("redacts a hook echoing $DATABASE_URL, including its URL-encoded form", async () => {
// A password with a character that changes shape once URL-encoded, so
// both the raw and encoded substrings actually get exercised.
fsMod.writeFileSync(process.env.CCAM_SECRETS_PATH, "PG_PASS=p@ss/word\n");
fsMod.chmodSync(process.env.CCAM_SECRETS_PATH, 0o600);
const lane = makeLane();
writeProfile(lane.cwd, "DB_PREFIX=echo_l\n", {
"db-create": '#!/usr/bin/env bash\nset -euo pipefail\necho "connecting to $DATABASE_URL"\n',
});
slots.allocateSlot(lane.id);
const current = lanesLib.getLane(lane.id);
const profile = profileLib.resolveProfile(current);
const result = await profileLib.runHook(current, profile, "db-create", [
slots.dbName(profile, current.slot),
]);
assert.doesNotMatch(result.output, /p@ss\/word/);
assert.match(result.output, /\[REDACTED\]/);
assert.doesNotMatch(fsMod.readFileSync(result.logPath, "utf8"), /p@ss\/word/);
slots.releaseSlot(lane.id);
});
});
+369
View File
@@ -0,0 +1,369 @@
/**
* @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");
after(() => 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);
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, []);
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("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);
});
});