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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# db-create.sh – Create this lane's Postgres database inside a docker-compose
|
||||
# service, via `createdb`. Ported verbatim from Shipyard's own lane-bootstrap
|
||||
# / lane-up / lane-reset scripts, so a Postgres-via-compose repo gets parity
|
||||
# by copying this template into its own .ccam/profile/hooks/.
|
||||
#
|
||||
# Called as: db-create.sh <db-name> (also available as $DB_NAME)
|
||||
# Requires, declared in the repo's own profile.env:
|
||||
# COMPOSE_FILE path to the docker-compose file (e.g. "$LANE_DIR/docker-compose.yml")
|
||||
# DB_SERVICE the compose service name running Postgres
|
||||
# Provided by CCAM's hook environment (server/lib/secrets.js):
|
||||
# PG_USER (PG_PASS is deliberately not exported — trust auth inside the
|
||||
# compose network needs no password for this call)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:-$DB_NAME}"
|
||||
[ -n "$NAME" ] || { echo "db-create: no database name given" >&2; exit 1; }
|
||||
|
||||
if docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" \
|
||||
psql -U "$PG_USER" -tAc "SELECT 1 FROM pg_database WHERE datname='$NAME'" | grep -q 1; then
|
||||
echo "db-create: $NAME already exists"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" createdb -U "$PG_USER" "$NAME"
|
||||
echo "db-create: created $NAME"
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# db-drop.sh – Drop this lane's Postgres database inside a docker-compose
|
||||
# service, via `dropdb --if-exists`. Ported verbatim from Shipyard's own
|
||||
# lane-reset / lane-remove scripts. CCAM only ever calls this with a name it
|
||||
# derived itself (server/lib/lane-services.js:dropDatabase), so this script
|
||||
# never needs to re-validate its argument.
|
||||
#
|
||||
# Called as: db-drop.sh <db-name> (also available as $DB_NAME)
|
||||
# Requires/provides: same as db-create.sh in this same directory.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:-$DB_NAME}"
|
||||
[ -n "$NAME" ] || { echo "db-drop: no database name given" >&2; exit 1; }
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" dropdb --if-exists -U "$PG_USER" "$NAME"
|
||||
echo "db-drop: dropped $NAME (if it existed)"
|
||||
@@ -0,0 +1,23 @@
|
||||
# postgres-compose profile template — data-isolation additions (A2).
|
||||
# Copy the declarations you need into your repo's own .ccam/profile/profile.env
|
||||
# alongside db-create.sh / db-drop.sh from this template's hooks/ directory.
|
||||
|
||||
DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB
|
||||
DB_KIND="postgres" # informational
|
||||
DB_URL_SCHEME="postgresql" # DATABASE_URL scheme
|
||||
REDIS=1 # 1 = allocate a logical Redis index = slot
|
||||
# (16 logical DBs by default, 0-15 — keep
|
||||
# LANE_MAX_SLOTS <= 15 if you turn this on)
|
||||
|
||||
ENV_FILES="backend/.env" # file(s) to seed, relative to the lane
|
||||
ENV_SOURCE="backend/.env" # source path in the source repo
|
||||
ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys CCAM overwrites per lane
|
||||
ENV_PRESERVE="JWT_SECRET" # keys kept from the lane's OWN file on --force
|
||||
UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR
|
||||
|
||||
# Read by db-create.sh / db-drop.sh, NOT by CCAM itself:
|
||||
COMPOSE_FILE="docker-compose.yml" # relative to the lane's working copy
|
||||
DB_SERVICE="postgres" # the compose service name running Postgres
|
||||
|
||||
# Machine-level credentials (PG_HOST/PORT/USER/PASS, REDIS_HOST/PORT) come
|
||||
# from ~/.ccam/secrets.env, never from here — see server/lib/secrets.js.
|
||||
@@ -493,6 +493,32 @@ try {
|
||||
db.prepare("ALTER TABLE lanes ADD COLUMN detected_at TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: per-lane runtime allocation. `slot` is the small integer every runtime
|
||||
// fact derives from (ports now; database name and Redis index later) — the
|
||||
// numbering Shipyard gets for free from its fixed lane1..lane9 directories and
|
||||
// CCAM, keyed by cwd, has to allocate. `ports` is a JSON map name -> port,
|
||||
// recording the number a lane ACTUALLY got: the preferred `base + slot` may be
|
||||
// taken by something outside CCAM, so the allocator steps aside and the real
|
||||
// number has to survive a restart or the next boot would move a live lane.
|
||||
//
|
||||
// Allocation is lazy — a lane that is only ever watched never takes a slot — so
|
||||
// the column is nullable and the uniqueness constraint is partial. That index is
|
||||
// the backstop under the lock in lane-slots.js, not a substitute for it.
|
||||
// One probe per column, same reasoning as the detection columns above.
|
||||
try {
|
||||
db.prepare("SELECT slot FROM lanes LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE lanes ADD COLUMN slot INTEGER").run();
|
||||
}
|
||||
try {
|
||||
db.prepare("SELECT ports FROM lanes LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE lanes ADD COLUMN ports TEXT NOT NULL DEFAULT '{}'").run();
|
||||
}
|
||||
db.prepare(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_slot ON lanes(slot) WHERE slot IS NOT NULL"
|
||||
).run();
|
||||
|
||||
// Migrate: link agent rows to a workflow run. Workflow inner-agents are already
|
||||
// ingested as subagents (same subagents/ dir); these columns add the grouping +
|
||||
// phase that the run journal provides. Additive, safe on existing DBs.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file Seed and repair a lane's `.env` file(s): copy the source repository's
|
||||
* real `.env` into the lane on first boot (or on a `--force` refresh) and then
|
||||
* rewrite the per-lane keys (`DATABASE_URL`, `REDIS_URL`, `UPLOAD_DIR`, …) so
|
||||
* the file is correct ON ITS OWN — not merely masked by a hook's runtime
|
||||
* exports. Ports Shipyard's `lane-env-seed.sh`.
|
||||
*
|
||||
* Two hard-won behaviours are preserved verbatim. A `--force` refresh keeps
|
||||
* `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's OWN existing file:
|
||||
* swapping in the source's secret would 401 a running lane's tokens until
|
||||
* reboot. And a missing source `.env` falls back to `.env.example` with a
|
||||
* loud warning rather than a silent, broken seed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { assertManaged } = require("./worktree");
|
||||
const { splitList, parseEnvFile } = require("./lane-profile");
|
||||
const { dataFacts } = require("./lane-slots");
|
||||
|
||||
/** Resolve `relative` against `root`, refusing anything that climbs out of
|
||||
* it — same confinement rule `lane-runtime.js:makeLaneDirs` applies to
|
||||
* `LANE_DIRS`, so a `.` file declared by a repository can only ever touch
|
||||
* its own tree. */
|
||||
function confine(root, relative, code) {
|
||||
const target = path.resolve(root, relative);
|
||||
const rel = path.relative(root, target);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw Object.assign(new Error(`path escapes ${root}: ${relative}`), { code });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite declared keys inside an `.env` file's text, preserving every other
|
||||
* line byte-for-byte. A key not already present is appended, mirroring
|
||||
* Shipyard's python rewriter.
|
||||
*
|
||||
* @param {string} text - The file's current contents.
|
||||
* @param {Record<string,string>} want - Keys to set, already resolved to their final values.
|
||||
* @returns {string}
|
||||
*/
|
||||
function rewriteEnvText(text, want) {
|
||||
const seen = new Set();
|
||||
const lines = text.split("\n").map((line) => {
|
||||
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/.exec(line);
|
||||
if (match && Object.hasOwn(want, match[1])) {
|
||||
seen.add(match[1]);
|
||||
return `${match[1]}=${want[match[1]]}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
for (const [key, value] of Object.entries(want)) {
|
||||
if (!seen.has(key)) lines.push(`${key}=${value}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed or repair a lane's declared `.env` file(s).
|
||||
*
|
||||
* A no-op when the profile declares no `ENV_FILES` — the feature is off by
|
||||
* default, and an adopted repo that never opts in gets no `.env` writes at
|
||||
* all. Refuses on an adopted lane (`assertManaged`): that file is the user's
|
||||
* real working config, not a template CCAM may overwrite.
|
||||
*
|
||||
* @param {object} lane - Lane row; `slot` must already be allocated.
|
||||
* @param {object} profile - Resolved profile.
|
||||
* @param {Record<string,string>} secrets - From `secrets.js:readSecrets()`.
|
||||
* @param {{force?: boolean}} [options] - `force` re-copies from source even when the file exists.
|
||||
* @returns {{skipped: boolean, seeded?: Array<{file: string, fromExample: boolean}>}}
|
||||
*/
|
||||
function seedEnv(lane, profile, secrets, { force = false } = {}) {
|
||||
assertManaged(lane);
|
||||
|
||||
const files = splitList(profile.env.ENV_FILES);
|
||||
if (!files.length) return { skipped: true };
|
||||
|
||||
const sources = splitList(profile.env.ENV_SOURCE || profile.env.ENV_FILES);
|
||||
const rewriteKeys = new Set(splitList(profile.env.ENV_REWRITE));
|
||||
const preserveKeys = splitList(profile.env.ENV_PRESERVE);
|
||||
const sourceRoot = lane.source_repo || lane.cwd;
|
||||
const facts = dataFacts(lane, profile, secrets);
|
||||
const computed = {
|
||||
DATABASE_URL: facts.databaseUrl,
|
||||
REDIS_URL: facts.redisUrl,
|
||||
UPLOAD_DIR: facts.uploadDir,
|
||||
};
|
||||
|
||||
const seeded = [];
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const relFile = files[i];
|
||||
const relSource = sources[i] || relFile;
|
||||
const targetPath = confine(lane.cwd, relFile, "EBADENVFILE");
|
||||
const sourcePath = confine(sourceRoot, relSource, "EBADENVFILE");
|
||||
|
||||
const existed = fs.existsSync(targetPath);
|
||||
const preserved = {};
|
||||
if (existed && force && preserveKeys.length) {
|
||||
try {
|
||||
const current = parseEnvFile(fs.readFileSync(targetPath, "utf8"));
|
||||
for (const key of preserveKeys) {
|
||||
if (current[key] !== undefined) preserved[key] = current[key];
|
||||
}
|
||||
} catch {
|
||||
/* an unreadable existing file has nothing worth preserving */
|
||||
}
|
||||
}
|
||||
|
||||
if (!existed || force) {
|
||||
let content;
|
||||
let fromExample = false;
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
content = fs.readFileSync(sourcePath, "utf8");
|
||||
} else if (fs.existsSync(`${sourcePath}.example`)) {
|
||||
content = fs.readFileSync(`${sourcePath}.example`, "utf8");
|
||||
fromExample = true;
|
||||
console.warn(
|
||||
`[lane-env] lane ${lane.id}: ${relSource} is missing — seeded ${relFile} from ` +
|
||||
`${relSource}.example instead (no real secrets/keys)`
|
||||
);
|
||||
} else {
|
||||
throw Object.assign(
|
||||
new Error(`no ${relSource} (or ${relSource}.example) to seed ${relFile} from`),
|
||||
{ code: "ENOENVSOURCE", relSource }
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content);
|
||||
seeded.push({ file: relFile, fromExample });
|
||||
}
|
||||
|
||||
const want = {};
|
||||
for (const [key, value] of Object.entries(computed)) {
|
||||
if (value != null && rewriteKeys.has(key)) want[key] = value;
|
||||
}
|
||||
Object.assign(want, preserved);
|
||||
if (Object.keys(want).length) {
|
||||
fs.writeFileSync(targetPath, rewriteEnvText(fs.readFileSync(targetPath, "utf8"), want));
|
||||
}
|
||||
}
|
||||
|
||||
if (facts.uploadDir) fs.mkdirSync(facts.uploadDir, { recursive: true });
|
||||
|
||||
return { skipped: false, seeded };
|
||||
}
|
||||
|
||||
module.exports = { seedEnv, rewriteEnvText };
|
||||
@@ -10,6 +10,8 @@ const fs = require("node:fs");
|
||||
const { db } = require("../db");
|
||||
const wt = require("./worktree");
|
||||
const lanesLib = require("./lanes");
|
||||
const { resolveProfile } = require("./lane-profile");
|
||||
const { dbName } = require("./lane-slots");
|
||||
|
||||
/**
|
||||
* Preflight for reset, remove, or purge. Returns an object describing what will happen:
|
||||
@@ -74,6 +76,14 @@ async function preflight(lane, action) {
|
||||
}
|
||||
}
|
||||
|
||||
// The database name this action would drop (reset unless --keep-db,
|
||||
// remove always) — echoed the same way `head`/`dirty` are, so the
|
||||
// confirmation dialog names the destructive fact rather than leaving it a
|
||||
// surprise. Null when the lane has no slot yet or the profile declares no
|
||||
// DB_PREFIX — nothing has been derived to drop.
|
||||
const profile = resolveProfile(lane);
|
||||
const database = profile && lane.slot ? dbName(profile, lane.slot) : null;
|
||||
|
||||
return {
|
||||
action,
|
||||
lane: lane.id,
|
||||
@@ -83,6 +93,7 @@ async function preflight(lane, action) {
|
||||
untracked,
|
||||
unpushed,
|
||||
head: head || null,
|
||||
database,
|
||||
blocked,
|
||||
warnings,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* @file The stack seam. A profile is a repository's own description of how to
|
||||
* build, boot and check its stack: `<repo>/.ccam/profile/` holding a `profile.env`
|
||||
* of declarations plus a `hooks/` directory of shell scripts. CCAM stays
|
||||
* stack-agnostic and calls those hooks with a stable environment contract, which
|
||||
* is the same contract Shipyard's `run_hook` exports so its profiles port over
|
||||
* unchanged.
|
||||
*
|
||||
* Two rules this module exists to enforce. Config is PARSED, never sourced —
|
||||
* sourcing arbitrary shell from a repository into the dashboard process would be
|
||||
* a code-execution path; hooks are executed deliberately, config is only read.
|
||||
* And a hook is always spawned as `bash <hook> <args…>` with a fixed argument
|
||||
* array, never a command string.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const { slotDirs, dataFacts } = require("./lane-slots");
|
||||
|
||||
/** Directory, relative to a repository root, holding its profile. */
|
||||
const PROFILE_SUBDIR = path.join(".ccam", "profile");
|
||||
|
||||
/**
|
||||
* Hook names CCAM will run, ever.
|
||||
*
|
||||
* A fixed allowlist rather than "whatever is in hooks/": `:name` arrives from an
|
||||
* HTTP route, and a name taken from a request is a path taken from a request.
|
||||
* `bootstrap`/`migrate`/`seed`/`ci-gate`/`e2e`/`regen` are not called by A1's
|
||||
* lifecycle but are listed here because `POST /:id/hook/:name` can run them on a
|
||||
* session's behalf, and the driving skill needs that surface stable.
|
||||
*/
|
||||
const HOOKS = Object.freeze([
|
||||
"bootstrap",
|
||||
"boot",
|
||||
"health",
|
||||
"migrate",
|
||||
"seed",
|
||||
"ci-gate",
|
||||
"e2e",
|
||||
"regen",
|
||||
"db-create",
|
||||
"db-drop",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Defaults for every declaration a profile may omit, so a missing key degrades
|
||||
* to something usable rather than breaking a lane. Mirrors the defaults block in
|
||||
* Shipyard's `_common.sh` so a ported profile behaves identically.
|
||||
*/
|
||||
const DEFAULTS = Object.freeze({
|
||||
PORTS: "api fe",
|
||||
PORT_BASE_api: "8000",
|
||||
PORT_BASE_fe: "3000",
|
||||
LANE_DIRS: "",
|
||||
BACKEND_DIR: "backend",
|
||||
FRONTEND_DIR: "frontend",
|
||||
API_PATH: "/api",
|
||||
// A2 data isolation — every one of these empty/0 is "feature off", so an
|
||||
// adopted repo that never declares them gets no per-lane database, no Redis
|
||||
// index, and no .env rewriting: dead code never runs rather than running on
|
||||
// guessed values.
|
||||
DB_PREFIX: "",
|
||||
DB_KIND: "",
|
||||
DB_URL_SCHEME: "postgresql",
|
||||
REDIS: "0",
|
||||
ENV_FILES: "",
|
||||
ENV_SOURCE: "",
|
||||
ENV_REWRITE: "",
|
||||
ENV_PRESERVE: "",
|
||||
UPLOAD_SUBDIR: "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse a `KEY=VALUE` declaration file.
|
||||
*
|
||||
* Not a shell parser and not trying to be: comments and blank lines are skipped,
|
||||
* an `export ` prefix is tolerated (profiles ported from Shipyard have it), and
|
||||
* one layer of matching quotes is stripped. Everything else is taken literally —
|
||||
* `$(id)`, backticks and `${VAR}` stay as written. That literalness IS the
|
||||
* security property; do not add expansion here.
|
||||
*
|
||||
* @param {string} text - File contents.
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
function parseEnvFile(text) {
|
||||
const out = {};
|
||||
for (const rawLine of text.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
||||
if (!match) continue;
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
out[match[1]] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Split a space-separated declaration into a deduplicated list. */
|
||||
function splitList(value) {
|
||||
return [...new Set((value || "").split(/\s+/).filter(Boolean))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and read a lane's profile.
|
||||
*
|
||||
* The lane's OWN working copy is searched first, its source repository second.
|
||||
* The profile lives in the repository, so a worktree already carries the version
|
||||
* belonging to its branch — and a branch that changes a boot command must boot
|
||||
* with the command it changed, not the one on the base branch. The source-repo
|
||||
* fallback covers a profile the user keeps gitignored, which never reaches a
|
||||
* worktree through git.
|
||||
*
|
||||
* @param {object} lane - Lane row (`cwd`, `source_repo`).
|
||||
* @returns {{dir: string, env: Record<string,string>, hooks: Set<string>, ports: string[], laneDirs: string[]}|null}
|
||||
* null when neither location has a profile — a normal state, not a fault.
|
||||
*/
|
||||
function resolveProfile(lane) {
|
||||
const candidates = [lane.cwd, lane.source_repo].filter(Boolean);
|
||||
for (const root of candidates) {
|
||||
const dir = path.join(root, PROFILE_SUBDIR);
|
||||
if (!fs.existsSync(path.join(dir, "profile.env"))) continue;
|
||||
|
||||
let declared = {};
|
||||
try {
|
||||
declared = parseEnvFile(fs.readFileSync(path.join(dir, "profile.env"), "utf8"));
|
||||
} catch {
|
||||
continue; // unreadable profile is the same as no profile
|
||||
}
|
||||
const env = { ...DEFAULTS, ...declared };
|
||||
|
||||
const hooks = new Set();
|
||||
for (const name of HOOKS) {
|
||||
if (fs.existsSync(path.join(dir, "hooks", `${name}.sh`))) hooks.add(name);
|
||||
}
|
||||
|
||||
return {
|
||||
dir,
|
||||
env,
|
||||
hooks,
|
||||
ports: splitList(env.PORTS),
|
||||
laneDirs: splitList(env.LANE_DIRS),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The profile paths searched for a lane, for an ENOPROFILE message. */
|
||||
function profileSearchPaths(lane) {
|
||||
return [lane.cwd, lane.source_repo]
|
||||
.filter(Boolean)
|
||||
.map((root) => path.join(root, PROFILE_SUBDIR));
|
||||
}
|
||||
|
||||
/**
|
||||
* A `harness_spawn` shell function, injected into every hook.
|
||||
*
|
||||
* Same name and signature as Shipyard's so ported hooks work untouched:
|
||||
* `harness_spawn <name> <workdir> <cmd…>` backgrounds a long-lived service and
|
||||
* records its pid where downLane looks.
|
||||
*
|
||||
* The stdio detachment is not optional. A child that inherits the caller's stdout
|
||||
* holds that pipe open, so a caller reading to EOF never returns — the failure
|
||||
* that stalled Shipyard's boot stage until it was fixed the same way. `nohup` plus
|
||||
* a closed stdin plus redirected output is what lets a lane's stack outlive both
|
||||
* the hook and the dashboard.
|
||||
*/
|
||||
const HARNESS_SPAWN = `
|
||||
harness_spawn() {
|
||||
local name="$1" wd="$2"; shift 2
|
||||
( cd "$wd" || exit 1
|
||||
nohup "$@" >"$LOG_DIR/$name.log" 2>&1 </dev/null &
|
||||
echo $! >"$RUN_DIR/$name.pid"
|
||||
) </dev/null >/dev/null 2>&1
|
||||
}
|
||||
die() { echo "profile: $*" >&2; exit 1; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* The environment contract every hook can rely on.
|
||||
*
|
||||
* Deliberately identical to Shipyard's `run_hook` exports where the concept
|
||||
* survives the port, so a profile written for the harness runs here unchanged.
|
||||
* `LANE` is the SLOT, not the lane id — Shipyard hooks use it to derive per-lane
|
||||
* names, and the slot is what carries that meaning.
|
||||
*
|
||||
* Git variables are scrubbed for the same reason `worktree.js:git()` scrubs them:
|
||||
* a hook that shells out to git must not inherit a git context pointing at the
|
||||
* dashboard's own repository, and `GIT_CONFIG_*` can inject `core.hooksPath` into
|
||||
* every git call the hook makes.
|
||||
*
|
||||
* `require("./secrets")` is deferred to the function body rather than hoisted
|
||||
* to the top of the file: `secrets.js` itself requires this module for
|
||||
* `parseEnvFile`, and a top-level require here would complete the cycle while
|
||||
* this file's own `module.exports` is still empty, handing `secrets.js` an
|
||||
* `undefined` parser. Deferring past module-load time breaks the cycle.
|
||||
*/
|
||||
function hookEnv(lane, profile) {
|
||||
const { readSecrets } = require("./secrets");
|
||||
const dirs = slotDirs(lane.slot);
|
||||
const secrets = readSecrets();
|
||||
const facts = dataFacts(lane, profile, secrets);
|
||||
const env = { ...process.env };
|
||||
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
delete env.GIT_COMMON_DIR;
|
||||
delete env.GIT_OBJECT_DIRECTORY;
|
||||
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
|
||||
delete env.GIT_PREFIX;
|
||||
delete env.GIT_NAMESPACE;
|
||||
delete env.GIT_CONFIG_PARAMETERS;
|
||||
for (const name of Object.keys(env)) {
|
||||
if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete env[name];
|
||||
}
|
||||
env.GIT_TERMINAL_PROMPT = "0";
|
||||
|
||||
Object.assign(env, profile.env, {
|
||||
LANE: String(lane.slot),
|
||||
LANE_ID: String(lane.id),
|
||||
LANE_DIR: lane.cwd,
|
||||
SOURCE_REPO: lane.source_repo || lane.cwd,
|
||||
PROFILE_DIR: profile.dir,
|
||||
RUN_DIR: dirs.runDir,
|
||||
LOG_DIR: dirs.logDir,
|
||||
});
|
||||
|
||||
// A2 data-isolation facts, present only when their owning declaration is —
|
||||
// a profile with no DB_PREFIX sees no DB_NAME/DATABASE_URL at all, so a
|
||||
// db-create.sh hook that forgot to check DB_PREFIX fails loudly (unset var
|
||||
// under `set -u`) instead of quietly touching a database named "undefined".
|
||||
if (facts.dbName) {
|
||||
env.DB_NAME = facts.dbName;
|
||||
env.DATABASE_URL = facts.databaseUrl;
|
||||
env.TEST_DATABASE_URL = facts.testDatabaseUrl;
|
||||
// Raw connection settings, not just the assembled URL: Shipyard's own
|
||||
// db-create/db-drop hooks call `createdb -U "$PG_USER"` directly (trust
|
||||
// auth inside the compose network, no password needed), and a ported
|
||||
// profile expects these names verbatim. PG_PASS is deliberately withheld —
|
||||
// nothing in the ported hooks needs it, and every value that DOES reach a
|
||||
// hook's environment is a value that could end up in an echoed debug line.
|
||||
env.PG_HOST = secrets.PG_HOST;
|
||||
env.PG_PORT = secrets.PG_PORT;
|
||||
env.PG_USER = secrets.PG_USER;
|
||||
}
|
||||
if (facts.redisUrl) {
|
||||
env.REDIS_URL = facts.redisUrl;
|
||||
env.REDIS_HOST = secrets.REDIS_HOST;
|
||||
env.REDIS_PORT = secrets.REDIS_PORT;
|
||||
}
|
||||
if (facts.uploadDir) env.UPLOAD_DIR = facts.uploadDir;
|
||||
|
||||
// <NAME>_PORT for every declared port, upper-cased: PORTS="api fe" -> API_PORT, FE_PORT.
|
||||
for (const [name, port] of Object.entries(lane.ports || {})) {
|
||||
env[`${name.toUpperCase()}_PORT`] = String(port);
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one of the profile's hooks.
|
||||
*
|
||||
* Spawned through a one-line bash wrapper that defines the shell helpers,
|
||||
* `export -f`s them (exactly as Shipyard's `run_hook` does) and then `exec`s the
|
||||
* hook, so the hook runs as its own script with its own `set -e` while still
|
||||
* seeing `harness_spawn`. The hook path and its arguments travel as an argument
|
||||
* ARRAY appended after the wrapper — never interpolated into the script text, so
|
||||
* a lane directory containing a quote or a space is a path, not a command.
|
||||
*
|
||||
* Output is streamed line by line to `onLine` (the caller broadcasts it) and
|
||||
* appended to `$LOG_DIR/<name>.log`, so a boot is watchable live and readable
|
||||
* afterwards. Any value that came from `secrets.js` (currently `PG_PASS`, and
|
||||
* therefore the password segment of `DATABASE_URL`/`TEST_DATABASE_URL`) is
|
||||
* redacted from that stream first: `runHook`'s output reaches a browser tab
|
||||
* over the `lane_hook_output` websocket, and a hook that echoes its own
|
||||
* environment (common while debugging a failing migration) must not publish a
|
||||
* database password to everyone watching.
|
||||
*
|
||||
* @param {object} lane - Lane row with an allocated slot and resolved ports.
|
||||
* @param {object} profile - From resolveProfile().
|
||||
* @param {string} name - Hook name; must be in HOOKS.
|
||||
* @param {string[]} [args] - Extra arguments passed to the hook.
|
||||
* @param {{onLine?: (line: string, stream: "stdout"|"stderr") => void, timeoutMs?: number}} [options]
|
||||
* @returns {Promise<{code: number, output: string}>} Resolves even on a non-zero exit.
|
||||
*/
|
||||
function runHook(lane, profile, name, args = [], options = {}) {
|
||||
if (!HOOKS.includes(name)) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error(`unknown hook: ${name}`), { code: "ENOHOOK", hook: name })
|
||||
);
|
||||
}
|
||||
const hookPath = path.join(profile.dir, "hooks", `${name}.sh`);
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error(`profile has no hook "${name}.sh"`), {
|
||||
code: "ENOHOOK",
|
||||
hook: name,
|
||||
path: hookPath,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const dirs = slotDirs(lane.slot);
|
||||
fs.mkdirSync(dirs.runDir, { recursive: true });
|
||||
fs.mkdirSync(dirs.logDir, { recursive: true });
|
||||
const logPath = path.join(dirs.logDir, `${name}.log`);
|
||||
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
||||
|
||||
// Deferred for the same reason as inside hookEnv(): secrets.js requires this
|
||||
// module, so a top-level require here would complete the load cycle early.
|
||||
// Only the password is redacted — host/port/user are not secret on their
|
||||
// own, and treating them as such would mangle unrelated numbers in output.
|
||||
// Both forms: DATABASE_URL embeds the URL-encoded password, so a raw echo
|
||||
// of the password and an echo of DATABASE_URL need separate substrings.
|
||||
const pgPass = require("./secrets").readSecrets().PG_PASS;
|
||||
const secretValues = [pgPass, pgPass && encodeURIComponent(pgPass)].filter(Boolean);
|
||||
const redact = (text) => secretValues.reduce((s, v) => s.split(v).join("[REDACTED]"), text);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`${HARNESS_SPAWN}\nexport -f harness_spawn die\nexec bash "$@"`,
|
||||
"bash",
|
||||
hookPath,
|
||||
...args.map(String),
|
||||
],
|
||||
{
|
||||
cwd: lane.cwd,
|
||||
env: hookEnv(lane, profile),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
|
||||
let output = "";
|
||||
let settled = false;
|
||||
const timer = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, options.timeoutMs)
|
||||
: null;
|
||||
|
||||
const consume = (stream, which) => {
|
||||
let buffer = "";
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (raw) => {
|
||||
const chunk = secretValues.length ? redact(raw) : raw;
|
||||
output += chunk;
|
||||
logStream.write(chunk);
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) options.onLine?.(line, which);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
if (buffer) options.onLine?.(buffer, which);
|
||||
});
|
||||
};
|
||||
consume(child.stdout, "stdout");
|
||||
consume(child.stderr, "stderr");
|
||||
|
||||
const finish = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
logStream.end();
|
||||
fn(value);
|
||||
};
|
||||
|
||||
child.on("error", (err) => finish(reject, err));
|
||||
child.on("close", (code) => finish(resolve, { code: code ?? 1, output, logPath }));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOOKS,
|
||||
DEFAULTS,
|
||||
PROFILE_SUBDIR,
|
||||
parseEnvFile,
|
||||
splitList,
|
||||
resolveProfile,
|
||||
profileSearchPaths,
|
||||
hookEnv,
|
||||
runHook,
|
||||
};
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* @file Lane stack lifecycle: bring a lane's services up through its profile's
|
||||
* hooks, take them down, and report what is actually running. Also owns the
|
||||
* data-isolation lifecycle (A2): `provisionLane`/`resetLaneData`/`removeLaneData`
|
||||
* seed `.env`, create/drop the lane's own database, and run `bootstrap`/
|
||||
* `migrate`/`seed` at the points Shipyard's `lane-bootstrap.sh`/`lane-up.sh`/
|
||||
* `lane-reset.sh`/`lane-remove.sh` do — CCAM's version of "ports came from A1,
|
||||
* everything else a lane needs to run its own stack comes from here."
|
||||
*
|
||||
* Two properties shape everything here.
|
||||
*
|
||||
* Services are FULLY DETACHED (see `harness_spawn` in lane-profile.js), so a
|
||||
* lane's stack outlives both the hook that started it and the dashboard itself —
|
||||
* restarting or updating CCAM must never kill work in progress.
|
||||
*
|
||||
* Liveness is COMPUTED, never stored. Whether a stack is up is not a fact CCAM
|
||||
* controls: a process dies to OOM, to a stray `kill`, to a reboot. A cached
|
||||
* "running" flag would be wrong from that moment until something noticed, so
|
||||
* `runtimeFacts` re-derives it from pid files and port probes on every read. The
|
||||
* payoff is that adopting a stack after a dashboard restart needs no code at all.
|
||||
*
|
||||
* This module writes `slot` and `ports` and nothing else on the lane row. It never
|
||||
* writes `stage`, `status` or `notes`: in CCAM those describe the AGENT's work, not
|
||||
* the stack's state, and conflating "a server is listening" with "an agent is
|
||||
* running" would corrupt lane liveness. Boot failures live in `last-error.json`
|
||||
* and surface through `runtimeFacts`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const lanesLib = require("./lanes");
|
||||
const {
|
||||
allocateSlot,
|
||||
resolvePorts,
|
||||
slotDirs,
|
||||
portsSteppedAside,
|
||||
portBase,
|
||||
dbName,
|
||||
} = require("./lane-slots");
|
||||
const { resolveProfile, profileSearchPaths, runHook } = require("./lane-profile");
|
||||
const { isListening, listenerPids } = require("./ports");
|
||||
const { seedEnv } = require("./lane-env");
|
||||
const { ensureDatabase, dropDatabase } = require("./lane-services");
|
||||
const { readSecrets } = require("./secrets");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** A health hook that never returns is a failed boot, not an eternal wait. */
|
||||
const HEALTH_TIMEOUT_MS = Number(process.env.LANE_HEALTH_TIMEOUT_MS) || 180_000;
|
||||
const BOOT_TIMEOUT_MS = Number(process.env.LANE_BOOT_TIMEOUT_MS) || 900_000;
|
||||
|
||||
/** Resolve a lane's profile or throw the error a route turns into a 400. */
|
||||
function requireProfile(lane) {
|
||||
const profile = resolveProfile(lane);
|
||||
if (!profile) {
|
||||
throw Object.assign(
|
||||
new Error(`lane has no .ccam/profile — looked in: ${profileSearchPaths(lane).join(", ")}`),
|
||||
{ code: "ENOPROFILE", searched: profileSearchPaths(lane) }
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** Direct children of a pid. Empty when pgrep is unavailable — the parent still dies. */
|
||||
async function childPids(pid) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("pgrep", ["-P", String(pid)]);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^\d+$/.test(line))
|
||||
.map(Number);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process and every descendant, deepest first.
|
||||
*
|
||||
* Bottom-up matters: killing the parent first reparents its children to init,
|
||||
* where nothing knows to look for them. This is the failure `lane-down.sh`'s
|
||||
* `kill_tree` was written for — a uvicorn reloader or a celery prefork pool whose
|
||||
* workers survived a kill aimed at the recorded pid, kept the port bound, and made
|
||||
* the next boot fail for a reason nothing reported.
|
||||
*
|
||||
* @param {number} pid - Root of the tree.
|
||||
* @returns {Promise<number[]>} Pids signalled.
|
||||
*/
|
||||
async function killTree(pid) {
|
||||
const killed = [];
|
||||
for (const child of await childPids(pid)) {
|
||||
killed.push(...(await killTree(child)));
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
killed.push(pid);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
/** True when a pid exists and we may signal it. */
|
||||
function isAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means it exists but belongs to someone else — still alive.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
/** Recorded services: every `<name>.pid` the boot hook left in the run directory. */
|
||||
function readPidFiles(runDir) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(runDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".pid")) continue;
|
||||
const raw = fs.readFileSync(path.join(runDir, entry), "utf8").trim();
|
||||
if (!/^\d+$/.test(raw)) continue;
|
||||
out.push({ name: entry.slice(0, -4), pid: Number(raw), file: path.join(runDir, entry) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Persist why a boot failed, where runtimeFacts can find it. */
|
||||
function recordError(lane, error) {
|
||||
const { stateDir, errorFile } = slotDirs(lane.slot);
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
errorFile,
|
||||
JSON.stringify(
|
||||
{ at: new Date().toISOString(), code: error.code || null, message: error.message },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop a stale failure once a boot succeeds. */
|
||||
function clearError(lane) {
|
||||
try {
|
||||
fs.rmSync(slotDirs(lane.slot).errorFile, { force: true });
|
||||
} catch {
|
||||
/* nothing recorded */
|
||||
}
|
||||
}
|
||||
|
||||
function readError(slot) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(slotDirs(slot).errorFile, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one `LANE_DIRS` entry against the lane's working copy, refusing
|
||||
* anything that escapes it (an absolute path, or one climbing out with `..`)
|
||||
* rather than silently touching a directory somewhere else on the machine.
|
||||
*/
|
||||
function resolveLaneDirEntry(lane, entry) {
|
||||
const target = path.resolve(lane.cwd, entry);
|
||||
const relative = path.relative(lane.cwd, target);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw Object.assign(new Error(`LANE_DIRS entry escapes the lane: ${entry}`), {
|
||||
code: "EBADLANEDIR",
|
||||
entry,
|
||||
});
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Create the per-lane directories the profile declares. */
|
||||
function makeLaneDirs(lane, profile) {
|
||||
for (const entry of profile.laneDirs) {
|
||||
fs.mkdirSync(resolveLaneDirEntry(lane, entry), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty and recreate the per-lane directories a `reset` should start fresh —
|
||||
* Shipyard's `rm -rf "$(lane_upload_dir "$N")"/*` generalized to every
|
||||
* declared `LANE_DIRS` entry, since a fresh feature should not inherit a
|
||||
* previous one's uploaded files.
|
||||
*/
|
||||
function clearLaneDirs(lane, profile) {
|
||||
for (const entry of profile.laneDirs) {
|
||||
const target = resolveLaneDirEntry(lane, entry);
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a hook only when the profile declares it, throwing `errCode` on a
|
||||
* non-zero exit. Shared by every lifecycle step below (`bootstrap`, `migrate`,
|
||||
* `seed`) so "the profile never declared this" and "the hook failed" stay two
|
||||
* distinct, consistently-coded outcomes everywhere they're checked.
|
||||
*/
|
||||
async function runRequiredHook(lane, profile, name, options, errCode) {
|
||||
if (!profile.hooks.has(name)) return null;
|
||||
const result = await runHook(lane, profile, name, [], options);
|
||||
if (result.code !== 0) {
|
||||
throw Object.assign(new Error(`${name} hook exited ${result.code}`), {
|
||||
code: errCode,
|
||||
logPath: result.logPath,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a freshly-created managed lane's data isolation: seed its `.env`,
|
||||
* run `bootstrap`/`migrate`/`seed`, and create its database. Runs once, right
|
||||
* after the worktree itself exists — the other half of isolation A1 did not
|
||||
* cover (ports and directories came from A1; database, `.env` and uploads
|
||||
* come from here). Every step degrades to nothing when the profile never
|
||||
* declares the underlying feature: no `bootstrap` hook, no `DB_PREFIX`, no
|
||||
* `ENV_FILES` are all normal, not partial failures.
|
||||
*
|
||||
* @param {object} lane - Lane row, freshly worktree-provisioned (kind "managed").
|
||||
* @param {{onLine?: Function}} [options]
|
||||
* @returns {Promise<object>} The lane row after provisioning.
|
||||
*/
|
||||
async function provisionLane(lane, options = {}) {
|
||||
const profile = requireProfile(lane);
|
||||
const { onLine } = options;
|
||||
|
||||
let current = lane;
|
||||
if (!current.slot) {
|
||||
allocateSlot(current.id);
|
||||
current = lanesLib.getLane(current.id);
|
||||
}
|
||||
|
||||
seedEnv(current, profile, readSecrets());
|
||||
await runRequiredHook(
|
||||
current,
|
||||
profile,
|
||||
"bootstrap",
|
||||
{ onLine, timeoutMs: BOOT_TIMEOUT_MS },
|
||||
"EBOOTSTRAPFAILED"
|
||||
);
|
||||
await ensureDatabase(current, profile, { onLine });
|
||||
await runRequiredHook(current, profile, "migrate", { onLine }, "EMIGRATEFAILED");
|
||||
await runRequiredHook(current, profile, "seed", { onLine }, "ESEEDFAILED");
|
||||
|
||||
return lanesLib.getLane(current.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a lane's data isolation after `resetWorktree` has already put its
|
||||
* working copy back on the base branch: refresh `.env` (a `--force` refresh,
|
||||
* so it tracks whatever the base branch's source `.env` now says), re-run
|
||||
* `bootstrap` (a reset can land on a branch with different dependencies —
|
||||
* Shipyard's own comment: "deps move under node_modules/venv"), clear the
|
||||
* declared `LANE_DIRS`, and — unless `keepDb` — drop, recreate, migrate and
|
||||
* reseed the database. `keepDb` skips that whole block, not just the drop:
|
||||
* a caller who wants to keep their data wants it left alone, migrations
|
||||
* included.
|
||||
*
|
||||
* @param {object} lane - Lane row, after `resetWorktree`.
|
||||
* @param {object} profile - Resolved profile.
|
||||
* @param {{keepDb?: boolean, onLine?: Function}} [options]
|
||||
*/
|
||||
async function resetLaneData(lane, profile, options = {}) {
|
||||
const { keepDb = false, onLine } = options;
|
||||
|
||||
seedEnv(lane, profile, readSecrets(), { force: true });
|
||||
await runRequiredHook(
|
||||
lane,
|
||||
profile,
|
||||
"bootstrap",
|
||||
{ onLine, timeoutMs: BOOT_TIMEOUT_MS },
|
||||
"EBOOTSTRAPFAILED"
|
||||
);
|
||||
clearLaneDirs(lane, profile);
|
||||
|
||||
if (keepDb) return;
|
||||
if (!dbName(profile, lane.slot)) return;
|
||||
await dropDatabase(lane, profile, dbName(profile, lane.slot), { onLine });
|
||||
await ensureDatabase(lane, profile, { onLine });
|
||||
await runRequiredHook(lane, profile, "migrate", { onLine }, "EMIGRATEFAILED");
|
||||
await runRequiredHook(lane, profile, "seed", { onLine }, "ESEEDFAILED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a managed lane's database and its `_test` sibling before the rest of
|
||||
* removal tears down the worktree and state directory.
|
||||
*
|
||||
* A no-op for an adopted lane — its data was never CCAM's to create, so it is
|
||||
* never CCAM's to destroy either, the same invariant `assertDestroyable`
|
||||
* enforces for the worktree itself. Best-effort per database: mirrors
|
||||
* Shipyard's `dropdb --if-exists ... || true` — a failed drop is logged, not
|
||||
* thrown, because it must never block removing the lane's dashboard record.
|
||||
*
|
||||
* @param {object} lane - Lane row.
|
||||
* @param {object} profile - Resolved profile.
|
||||
* @param {{onLine?: Function}} [options]
|
||||
*/
|
||||
async function removeLaneData(lane, profile, options = {}) {
|
||||
if (lane.kind !== "managed") return;
|
||||
const name = dbName(profile, lane.slot);
|
||||
if (!name) return;
|
||||
for (const target of [name, `${name}_test`]) {
|
||||
try {
|
||||
await dropDatabase(lane, profile, target, options);
|
||||
} catch (err) {
|
||||
console.warn(`[lane-runtime] lane ${lane.id}: db-drop ${target} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a lane's stack.
|
||||
*
|
||||
* Caller MUST hold the lane lock: slot allocation reads-then-writes across the
|
||||
* `await` in port resolution, and two concurrent ups would otherwise both believe
|
||||
* they own the slot.
|
||||
*
|
||||
* Runs `boot` then `health`; deliberately NOT `bootstrap`, which belongs to
|
||||
* provisioning and reset (Shipyard's `lane-up.sh` draws the same line — installing
|
||||
* dependencies on every boot would make a routine restart minutes long).
|
||||
*
|
||||
* A failing health check leaves the processes running. They are the evidence: the
|
||||
* logs of a half-booted stack are what tells the user which service never came up,
|
||||
* and killing them to report a tidy failure destroys exactly that.
|
||||
*
|
||||
* @param {object} lane - Lane row.
|
||||
* @param {{build?: boolean, onLine?: Function}} [options]
|
||||
* @returns {Promise<object>} Runtime facts after the attempt.
|
||||
*/
|
||||
async function upLane(lane, options = {}) {
|
||||
const profile = requireProfile(lane);
|
||||
const { build = true, onLine } = options;
|
||||
|
||||
let current = lane;
|
||||
if (!current.slot) {
|
||||
allocateSlot(current.id);
|
||||
current = lanesLib.getLane(current.id);
|
||||
}
|
||||
|
||||
const ports = await resolvePorts(current, profile);
|
||||
lanesLib.setProvisioningFacts(current.id, { ports });
|
||||
current = lanesLib.getLane(current.id);
|
||||
|
||||
const dirs = slotDirs(current.slot);
|
||||
fs.mkdirSync(dirs.runDir, { recursive: true });
|
||||
fs.mkdirSync(dirs.logDir, { recursive: true });
|
||||
makeLaneDirs(current, profile);
|
||||
|
||||
// Defensive: a re-run on a live lane must not double-start services that then
|
||||
// fight over the same ports.
|
||||
await downLane(current, { profile });
|
||||
|
||||
try {
|
||||
// Repair .env on every boot (a lane whose file was hand-edited or never
|
||||
// seeded gets fixed here), then ensure the database exists — cheap when it
|
||||
// already does — migrate on every boot (schemas drift while a lane sits
|
||||
// idle), and seed only when this boot is the one that created the database.
|
||||
if (current.kind === "managed") seedEnv(current, profile, readSecrets());
|
||||
const db = await ensureDatabase(current, profile, { onLine });
|
||||
await runRequiredHook(current, profile, "migrate", { onLine }, "EMIGRATEFAILED");
|
||||
if (db.created) await runRequiredHook(current, profile, "seed", { onLine }, "ESEEDFAILED");
|
||||
|
||||
const boot = await runHook(current, profile, "boot", build ? [] : ["--no-build"], {
|
||||
onLine,
|
||||
timeoutMs: BOOT_TIMEOUT_MS,
|
||||
});
|
||||
if (boot.code !== 0) {
|
||||
throw Object.assign(new Error(`boot hook exited ${boot.code}`), {
|
||||
code: "EBOOTFAILED",
|
||||
logPath: boot.logPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.hooks.has("health")) {
|
||||
const health = await runHook(current, profile, "health", [], {
|
||||
onLine,
|
||||
timeoutMs: HEALTH_TIMEOUT_MS,
|
||||
});
|
||||
if (health.code !== 0) {
|
||||
throw Object.assign(new Error(`health check failed (exit ${health.code})`), {
|
||||
code: "EUNHEALTHY",
|
||||
logPath: health.logPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clearError(current);
|
||||
} catch (err) {
|
||||
recordError(current, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return runtimeFacts(lanesLib.getLane(current.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a lane's stack. Idempotent, and safe on a lane that was never up.
|
||||
*
|
||||
* Kills each recorded pid tree, then — only when there WAS something recorded —
|
||||
* sweeps any listener still holding the lane's ports. That condition is a
|
||||
* deliberate departure from Shipyard, which always sweeps: a lane whose stack is
|
||||
* already down still owns its port numbers, and if the user has since started
|
||||
* their own server on one, an unconditional sweep would kill it. Requiring a pid
|
||||
* file keeps the backstop for the case it exists to cover — a detached child that
|
||||
* outlived the parent we recorded — without ever reaching a stranger's process.
|
||||
*
|
||||
* @param {object} lane - Lane row.
|
||||
* @param {{profile?: object}} [options]
|
||||
* @returns {Promise<{killed: number[]}>}
|
||||
*/
|
||||
async function downLane(lane, options = {}) {
|
||||
if (!lane.slot) return { killed: [] };
|
||||
const { runDir } = slotDirs(lane.slot);
|
||||
const recorded = readPidFiles(runDir);
|
||||
const killed = [];
|
||||
|
||||
for (const service of recorded) {
|
||||
killed.push(...(await killTree(service.pid)));
|
||||
fs.rmSync(service.file, { force: true });
|
||||
}
|
||||
|
||||
if (recorded.length) {
|
||||
for (const port of Object.values(lane.ports || {})) {
|
||||
for (const pid of await listenerPids(port)) {
|
||||
if (!killed.includes(pid)) killed.push(...(await killTree(pid)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { killed };
|
||||
}
|
||||
|
||||
/**
|
||||
* What is actually running for this lane, computed fresh on every call.
|
||||
*
|
||||
* Follows the contract of `GET /api/lanes/:id/git`: a lane with no profile is a
|
||||
* normal state, reported as `{available: false}` rather than an error. Callers
|
||||
* probe ports and stat pid files here, which is why this is its own endpoint and
|
||||
* not part of the polled lane list.
|
||||
*
|
||||
* @param {object} lane - Lane row.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function runtimeFacts(lane) {
|
||||
const profile = resolveProfile(lane);
|
||||
if (!profile) return { available: false, searched: profileSearchPaths(lane) };
|
||||
if (!lane.slot) {
|
||||
return { available: true, provisioned: false, hooks: [...profile.hooks], ports: {} };
|
||||
}
|
||||
|
||||
const dirs = slotDirs(lane.slot);
|
||||
const services = readPidFiles(dirs.runDir).map((service) => ({
|
||||
name: service.name,
|
||||
pid: service.pid,
|
||||
alive: isAlive(service.pid),
|
||||
}));
|
||||
|
||||
const ports = {};
|
||||
for (const name of profile.ports) {
|
||||
const port = lane.ports?.[name] ?? null;
|
||||
ports[name] = {
|
||||
port,
|
||||
expected: portBase(profile, name) + lane.slot,
|
||||
listening: port ? await isListening(port) : false,
|
||||
};
|
||||
}
|
||||
|
||||
let logs = [];
|
||||
try {
|
||||
logs = fs.readdirSync(dirs.logDir).filter((entry) => entry.endsWith(".log"));
|
||||
} catch {
|
||||
/* never booted */
|
||||
}
|
||||
|
||||
// Names and an index, never a connection string: DATABASE_URL/REDIS_URL embed
|
||||
// the secrets.env password, and this object is exactly what GET /runtime
|
||||
// returns to a browser tab.
|
||||
const name = dbName(profile, lane.slot);
|
||||
const database = name ? { name, testName: `${name}_test` } : null;
|
||||
const redisIndex = profile.env.REDIS === "1" ? lane.slot : null;
|
||||
|
||||
return {
|
||||
available: true,
|
||||
provisioned: true,
|
||||
slot: lane.slot,
|
||||
kind: lane.kind,
|
||||
hooks: [...profile.hooks],
|
||||
profileDir: profile.dir,
|
||||
services,
|
||||
ports,
|
||||
database,
|
||||
redisIndex,
|
||||
steppedAside: portsSteppedAside(lane, profile),
|
||||
up: services.some((service) => service.alive),
|
||||
healthy:
|
||||
Object.values(ports).length > 0 && Object.values(ports).every((entry) => entry.listening),
|
||||
logs,
|
||||
logDir: dirs.logDir,
|
||||
lastError: readError(lane.slot),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upLane,
|
||||
downLane,
|
||||
runtimeFacts,
|
||||
requireProfile,
|
||||
provisionLane,
|
||||
resetLaneData,
|
||||
removeLaneData,
|
||||
killTree,
|
||||
isAlive,
|
||||
readPidFiles,
|
||||
makeLaneDirs,
|
||||
clearLaneDirs,
|
||||
HEALTH_TIMEOUT_MS,
|
||||
BOOT_TIMEOUT_MS,
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file A lane's own database: create it once at provisioning and boot, drop
|
||||
* it on remove. CCAM stays stack-agnostic here — `createdb` vs `mysqladmin
|
||||
* create` vs `touch foo.db` genuinely differ, so the actual command lives in
|
||||
* the profile's `db-create.sh`/`db-drop.sh` hooks (already in the A1
|
||||
* allowlist); this module only decides WHEN to call them and guards WHICH
|
||||
* name a drop may ever target.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { assertManaged } = require("./worktree");
|
||||
const { dbName, slotDirs } = require("./lane-slots");
|
||||
const { runHook } = require("./lane-profile");
|
||||
|
||||
/**
|
||||
* Where CCAM records that a slot's database has already been created.
|
||||
*
|
||||
* A hook can't be trusted to know this on its own without being stack-aware
|
||||
* (a plain `createdb` errors on a database that already exists; `touch`
|
||||
* wouldn't), so CCAM tracks it itself with one flag file per database name,
|
||||
* beside the rest of a slot's runtime bookkeeping. This is also how `upLane`
|
||||
* tells "freshly created" from "already existed" to decide whether to seed.
|
||||
*/
|
||||
function markerPath(slot, name) {
|
||||
return path.join(slotDirs(slot).stateDir, `db-created-${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a lane's database exists, creating it via the profile's `db-create`
|
||||
* hook the first time only.
|
||||
*
|
||||
* A no-op when the profile declares no `DB_PREFIX` — no name is derived, so
|
||||
* no hook is ever called and nothing is created.
|
||||
*
|
||||
* @param {object} lane - Lane row; `slot` must already be allocated.
|
||||
* @param {object} profile - Resolved profile.
|
||||
* @param {{onLine?: Function, timeoutMs?: number}} [options]
|
||||
* @returns {Promise<{name: string|null, created: boolean}>}
|
||||
*/
|
||||
async function ensureDatabase(lane, profile, options = {}) {
|
||||
const name = dbName(profile, lane.slot);
|
||||
if (!name) return { name: null, created: false };
|
||||
|
||||
const marker = markerPath(lane.slot, name);
|
||||
if (fs.existsSync(marker)) return { name, created: false };
|
||||
|
||||
const result = await runHook(lane, profile, "db-create", [name], options);
|
||||
if (result.code !== 0) {
|
||||
throw Object.assign(new Error(`db-create hook exited ${result.code}`), {
|
||||
code: "EDBCREATE",
|
||||
logPath: result.logPath,
|
||||
});
|
||||
}
|
||||
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
||||
fs.writeFileSync(marker, new Date().toISOString());
|
||||
return { name, created: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a database via the profile's `db-drop` hook.
|
||||
*
|
||||
* Two checks run before anything is spawned, and neither trusts the caller:
|
||||
* `assertManaged` (an adopted lane's data is the user's own, never CCAM's to
|
||||
* destroy) and a name check against what THIS lane's slot actually derives —
|
||||
* the lane's own database or its `_test` sibling, and nothing else. Only a
|
||||
* name CCAM itself computed can ever reach the hook.
|
||||
*
|
||||
* @param {object} lane - Lane row; `slot` must already be allocated.
|
||||
* @param {object} profile - Resolved profile.
|
||||
* @param {string} name - The database to drop; must be `dbName` or `${dbName}_test`.
|
||||
* @param {{onLine?: Function, timeoutMs?: number}} [options]
|
||||
* @returns {Promise<{name: string}>}
|
||||
*/
|
||||
async function dropDatabase(lane, profile, name, options = {}) {
|
||||
assertManaged(lane);
|
||||
const derived = dbName(profile, lane.slot);
|
||||
const allowed = derived && (name === derived || name === `${derived}_test`);
|
||||
if (!allowed) {
|
||||
throw Object.assign(new Error(`refusing to drop undeclared database: ${name}`), {
|
||||
code: "EBADDBNAME",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runHook(lane, profile, "db-drop", [name], options);
|
||||
if (result.code !== 0) {
|
||||
throw Object.assign(new Error(`db-drop hook exited ${result.code}`), {
|
||||
code: "EDBDROP",
|
||||
logPath: result.logPath,
|
||||
});
|
||||
}
|
||||
fs.rmSync(markerPath(lane.slot, name), { force: true });
|
||||
return { name };
|
||||
}
|
||||
|
||||
module.exports = { ensureDatabase, dropDatabase, markerPath };
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @file Slot and port allocation for lane runtimes. A slot is the small integer
|
||||
* every per-lane runtime fact derives from — the numbering Shipyard gets for free
|
||||
* from its fixed `lane1..lane9` directories, and CCAM, whose lanes are keyed by
|
||||
* `cwd`, has to allocate. Ports come from `PORT_BASE_<name> + slot`, stepping
|
||||
* aside when something outside CCAM already holds the number. Database name,
|
||||
* Redis logical index, and upload directory are the same idea one layer up
|
||||
* (`dataFacts`) — every fact a lane's slot number determines, in one place.
|
||||
*
|
||||
* Allocation is the one runtime fact CCAM fully controls, which is why it lives
|
||||
* in the database (transactional, uniquely indexed) while liveness does not.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const lanesLib = require("./lanes");
|
||||
const { isListening, listenerPids } = require("./ports");
|
||||
const { LANES_ROOT } = require("./worktree");
|
||||
|
||||
/**
|
||||
* How many lanes may hold a runtime at once.
|
||||
*
|
||||
* Nine by default, which is Shipyard's ceiling and worth keeping as a default:
|
||||
* a single decimal digit keeps `base + slot` readable (`:8003` is lane 3), and
|
||||
* Redis ships 16 logical databases, so A2's per-lane index stays in range. It is
|
||||
* configurable because CCAM, unlike Shipyard, has no structural reason to stop at
|
||||
* nine — a machine that can run twenty stacks may raise it, at the cost of ports
|
||||
* that no longer read as a slot number.
|
||||
*
|
||||
* Read per call so a test can change it without reloading the module.
|
||||
*/
|
||||
function maxSlots() {
|
||||
const raw = Number(process.env.LANE_MAX_SLOTS);
|
||||
return Number.isInteger(raw) && raw > 0 ? raw : 9;
|
||||
}
|
||||
|
||||
/** How many `+100` steps to try before giving up on a port name. */
|
||||
const PORT_STEP = 100;
|
||||
const PORT_MAX_STEPS = 10;
|
||||
|
||||
/**
|
||||
* Claim the lowest free slot for a lane.
|
||||
*
|
||||
* Lowest-free rather than next-highest so a released slot is reused and the
|
||||
* numbers stay small and readable. MUST be called inside `withLaneLock` — the
|
||||
* read and the write are separated by nothing here, but the caller's subsequent
|
||||
* port resolution is async, and two provisions interleaving there would both
|
||||
* believe they own the number. The partial unique index on `lanes.slot` is the
|
||||
* backstop that turns a missed lock into a loud constraint error rather than two
|
||||
* lanes silently sharing a runtime.
|
||||
*
|
||||
* @param {number} laneId - Lane to assign the slot to.
|
||||
* @returns {number} The claimed slot.
|
||||
* @throws {Error} ESLOTS when every slot is taken.
|
||||
*/
|
||||
function allocateSlot(laneId) {
|
||||
const used = new Set(lanesLib.usedSlots());
|
||||
const limit = maxSlots();
|
||||
for (let slot = 1; slot <= limit; slot += 1) {
|
||||
if (used.has(slot)) continue;
|
||||
lanesLib.setProvisioningFacts(laneId, { slot });
|
||||
return slot;
|
||||
}
|
||||
throw Object.assign(new Error(`all ${limit} lane slots are in use`), { code: "ESLOTS" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a lane's slot and ports back to the pool.
|
||||
*
|
||||
* Called on `remove` only. A `reset` deliberately keeps them: Shipyard preserves
|
||||
* a lane's identity across resets, and moving a lane's ports (and, at A2, its
|
||||
* database) out from under a session that is mid-feature would be a silent,
|
||||
* confusing failure rather than a fresh start.
|
||||
*
|
||||
* @param {number} laneId - Lane to release.
|
||||
*/
|
||||
function releaseSlot(laneId) {
|
||||
lanesLib.setProvisioningFacts(laneId, { slot: null, ports: {} });
|
||||
}
|
||||
|
||||
/**
|
||||
* The ports a lane should bind, by declared name.
|
||||
*
|
||||
* For each name the profile declares, prefer `PORT_BASE_<name> + slot`, then
|
||||
* `+100`, `+200`… The step keeps the last digit equal to the slot, so a
|
||||
* stepped-aside port still reads as "lane 3" — the property that makes the whole
|
||||
* `base + slot` scheme worth having.
|
||||
*
|
||||
* A number is rejected when anything is listening on it, when another lane has
|
||||
* recorded it (a lane whose stack is down still owns its number), or when an
|
||||
* earlier name in this same call already took it.
|
||||
*
|
||||
* Previously-recorded ports for THIS lane are reused as-is when still free, so a
|
||||
* lane that stepped aside once keeps the number its `.env`, bookmarks and any
|
||||
* seeded browser session already point at.
|
||||
*
|
||||
* @param {object} lane - Lane row; `slot` must already be allocated.
|
||||
* @param {object} profile - Resolved profile (supplies `ports` and `env`).
|
||||
* @returns {Promise<Record<string, number>>} Map of port name to port number.
|
||||
* @throws {Error} EPORTBUSY when a name exhausts its candidates.
|
||||
*/
|
||||
async function resolvePorts(lane, profile) {
|
||||
const reserved = lanesLib.reservedPorts(lane.id);
|
||||
const previous = lane.ports || {};
|
||||
const assigned = {};
|
||||
const takenHere = new Set();
|
||||
|
||||
for (const name of profile.ports) {
|
||||
const base = portBase(profile, name);
|
||||
const candidates = [];
|
||||
// The number this lane used last time comes first: stability beats tidiness.
|
||||
if (Number.isInteger(previous[name])) candidates.push(previous[name]);
|
||||
for (let step = 0; step < PORT_MAX_STEPS; step += 1) {
|
||||
const candidate = base + step * PORT_STEP + lane.slot;
|
||||
if (!candidates.includes(candidate)) candidates.push(candidate);
|
||||
}
|
||||
|
||||
let chosen = null;
|
||||
for (const candidate of candidates) {
|
||||
if (takenHere.has(candidate) || reserved.has(candidate)) continue;
|
||||
if (await isListening(candidate)) continue;
|
||||
chosen = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
if (chosen === null) {
|
||||
const preferred = base + lane.slot;
|
||||
const pids = await listenerPids(preferred);
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`no free port for "${name}": tried ${candidates.join(", ")}` +
|
||||
(pids.length ? ` (${preferred} held by pid ${pids.join(", ")})` : "")
|
||||
),
|
||||
{ code: "EPORTBUSY", portName: name, preferred, pids }
|
||||
);
|
||||
}
|
||||
|
||||
assigned[name] = chosen;
|
||||
takenHere.add(chosen);
|
||||
}
|
||||
|
||||
return assigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured base for a port name, defaulting to 8000 so a profile that adds
|
||||
* a service without declaring its base still gets a usable (if unsurprising)
|
||||
* number rather than NaN.
|
||||
*/
|
||||
function portBase(profile, name) {
|
||||
const raw = Number(profile.env[`PORT_BASE_${name}`]);
|
||||
return Number.isInteger(raw) ? raw : 8000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a lane's runtime bookkeeping lives: pid files, hook logs, the last boot
|
||||
* error.
|
||||
*
|
||||
* Under `LANES_ROOT/.state/`, deliberately OUTSIDE the worktree. `lane reset`
|
||||
* runs `git clean -fd`, which would sweep pid files out from under a running
|
||||
* stack and leave processes nobody can find to kill. `.state/` also sits outside
|
||||
* every path the three-check destroy guard reasons about, so runtime bookkeeping
|
||||
* can never be mistaken for a lane's working copy.
|
||||
*
|
||||
* @param {number} slot - Allocated slot.
|
||||
* @returns {{stateDir: string, runDir: string, logDir: string, errorFile: string}}
|
||||
*/
|
||||
function slotDirs(slot) {
|
||||
const stateDir = path.join(LANES_ROOT, ".state", `lane${slot}`);
|
||||
return {
|
||||
stateDir,
|
||||
runDir: path.join(stateDir, "run"),
|
||||
logDir: path.join(stateDir, "logs"),
|
||||
errorFile: path.join(stateDir, "last-error.json"),
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a lane's ports differ from `base + slot` — the UI flags this. */
|
||||
function portsSteppedAside(lane, profile) {
|
||||
if (!lane.slot) return false;
|
||||
return profile.ports.some(
|
||||
(name) => lane.ports[name] && lane.ports[name] !== portBase(profile, name) + lane.slot
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The database name a slot derives, or null when the profile has no
|
||||
* `DB_PREFIX` — the one gate every A2 data-isolation feature reads, so "off"
|
||||
* means no name is ever allocated rather than an empty-prefix name like `"3"`.
|
||||
*
|
||||
* @param {object} profile - Resolved profile.
|
||||
* @param {number} slot - Allocated slot.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function dbName(profile, slot) {
|
||||
return profile.env.DB_PREFIX ? `${profile.env.DB_PREFIX}${slot}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every slot-derived data-isolation fact a lane can have, in the one place
|
||||
* every other slot-derived fact (ports, directories) already lives. Each
|
||||
* field is null when its owning declaration is absent, so a caller can test
|
||||
* "is this feature on" with a single truthiness check instead of re-reading
|
||||
* profile.env itself.
|
||||
*
|
||||
* @param {object} lane - Lane row; `slot` must already be allocated.
|
||||
* @param {object} profile - Resolved profile.
|
||||
* @param {Record<string,string>} secrets - From `secrets.js:readSecrets()`.
|
||||
* @returns {{dbName: string|null, databaseUrl: string|null, testDatabaseUrl: string|null, redisUrl: string|null, uploadDir: string|null}}
|
||||
*/
|
||||
function dataFacts(lane, profile, secrets) {
|
||||
const name = dbName(profile, lane.slot);
|
||||
const scheme = profile.env.DB_URL_SCHEME || "postgresql";
|
||||
// encodeURIComponent on user/pass: a real password containing @, #, or %
|
||||
// would otherwise produce a URL the DB client parses wrong or rejects.
|
||||
const urlFor = (n) =>
|
||||
n
|
||||
? `${scheme}://${encodeURIComponent(secrets.PG_USER)}:${encodeURIComponent(secrets.PG_PASS)}@${secrets.PG_HOST}:${secrets.PG_PORT}/${n}`
|
||||
: null;
|
||||
return {
|
||||
dbName: name,
|
||||
databaseUrl: urlFor(name),
|
||||
testDatabaseUrl: urlFor(name ? `${name}_test` : null),
|
||||
redisUrl:
|
||||
profile.env.REDIS === "1"
|
||||
? `redis://${secrets.REDIS_HOST}:${secrets.REDIS_PORT}/${lane.slot}`
|
||||
: null,
|
||||
uploadDir: profile.env.UPLOAD_SUBDIR ? path.join(lane.cwd, profile.env.UPLOAD_SUBDIR) : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
allocateSlot,
|
||||
releaseSlot,
|
||||
resolvePorts,
|
||||
portBase,
|
||||
portsSteppedAside,
|
||||
slotDirs,
|
||||
dbName,
|
||||
dataFacts,
|
||||
maxSlots,
|
||||
PORT_STEP,
|
||||
PORT_MAX_STEPS,
|
||||
};
|
||||
+67
-11
@@ -46,11 +46,13 @@ const WATCH_STAGE_RE = /watch|poll/i;
|
||||
/**
|
||||
* Fields a client may change through `PATCH /api/lanes/:id`.
|
||||
*
|
||||
* `kind`, `source_repo`, `slug` and `base_branch` are deliberately ABSENT: they
|
||||
* are provisioning-time facts, and `kind` is check 1 of the destroy guard. A
|
||||
* client that could flip `kind` to "managed" at runtime could point the guard at
|
||||
* a directory the user owns. Provisioning writes them through
|
||||
* setProvisioningFacts instead.
|
||||
* `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are
|
||||
* deliberately ABSENT: they are provisioning-time facts, and `kind` is check 1 of
|
||||
* the destroy guard. A client that could flip `kind` to "managed" at runtime could
|
||||
* point the guard at a directory the user owns; a client that could set `slot`
|
||||
* could move every slot-derived runtime fact (the ports a lane binds, and later
|
||||
* the database name a drop targets) onto another lane's resources. Provisioning
|
||||
* writes them through setProvisioningFacts instead.
|
||||
*/
|
||||
const PATCHABLE = new Set([
|
||||
"title",
|
||||
@@ -67,7 +69,14 @@ const PATCHABLE = new Set([
|
||||
]);
|
||||
|
||||
/** Provisioning-time facts, writable only by this module's internal setter. */
|
||||
const PROVISIONING_FIELDS = new Set(["kind", "source_repo", "base_branch", "slug"]);
|
||||
const PROVISIONING_FIELDS = new Set([
|
||||
"kind",
|
||||
"source_repo",
|
||||
"base_branch",
|
||||
"slug",
|
||||
"slot",
|
||||
"ports",
|
||||
]);
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
@@ -83,6 +92,7 @@ function hydrate(row) {
|
||||
if (!row) return null;
|
||||
let stages = {};
|
||||
let links = {};
|
||||
let ports = {};
|
||||
try {
|
||||
stages = JSON.parse(row.stages || "{}");
|
||||
} catch {
|
||||
@@ -93,7 +103,12 @@ function hydrate(row) {
|
||||
} catch {
|
||||
/* corrupt blob -> empty */
|
||||
}
|
||||
return { ...row, stages, links };
|
||||
try {
|
||||
ports = JSON.parse(row.ports || "{}");
|
||||
} catch {
|
||||
/* corrupt blob -> empty */
|
||||
}
|
||||
return { ...row, stages, links, ports };
|
||||
}
|
||||
|
||||
function createLane({
|
||||
@@ -157,9 +172,10 @@ function updateLane(id, patch = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach —
|
||||
* today only `base_branch`, resolved after `git worktree add` succeeds. Server
|
||||
* -internal: no route passes user input here.
|
||||
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach:
|
||||
* `base_branch`, resolved after `git worktree add` succeeds, and the runtime
|
||||
* allocation (`slot`, `ports`) the lane earns on its first boot. Server-internal:
|
||||
* no route passes user input here.
|
||||
*
|
||||
* @param {number} id - The lane id.
|
||||
* @param {object} facts - Subset of PROVISIONING_FIELDS to write.
|
||||
@@ -171,7 +187,7 @@ function setProvisioningFacts(id, facts = {}) {
|
||||
if (!PROVISIONING_FIELDS.has(k)) continue;
|
||||
if (k === "kind") validateKind(v);
|
||||
cols.push(`${k} = ?`);
|
||||
vals.push(v);
|
||||
vals.push(k === "ports" && typeof v === "object" ? JSON.stringify(v) : v);
|
||||
}
|
||||
if (cols.length) {
|
||||
cols.push("updated_at = ?");
|
||||
@@ -185,6 +201,44 @@ function deleteLane(id) {
|
||||
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
|
||||
}
|
||||
|
||||
/** Slots currently held by a lane, ascending. Input to the slot allocator. */
|
||||
function usedSlots() {
|
||||
return db
|
||||
.prepare("SELECT slot FROM lanes WHERE slot IS NOT NULL ORDER BY slot ASC")
|
||||
.all()
|
||||
.map((row) => row.slot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every port already recorded by another lane.
|
||||
*
|
||||
* A live listener is not the only claim on a port: a lane whose stack is
|
||||
* currently down still owns the number it booted on, and handing that number to
|
||||
* a second lane would make the two fight the moment the first comes back up.
|
||||
* Deriving ports from `base + slot` keeps lanes of ONE repo apart on its own, but
|
||||
* two repos with different `PORT_BASE_*` values can still land on the same
|
||||
* number — so the allocator subtracts this set as well as what is listening.
|
||||
*
|
||||
* @param {number} [excludeLaneId] - Lane being allocated for; its own reservation is not a conflict.
|
||||
* @returns {Set<number>}
|
||||
*/
|
||||
function reservedPorts(excludeLaneId = null) {
|
||||
const taken = new Set();
|
||||
for (const row of db.prepare("SELECT id, ports FROM lanes").all()) {
|
||||
if (excludeLaneId !== null && Number(row.id) === Number(excludeLaneId)) continue;
|
||||
let ports;
|
||||
try {
|
||||
ports = JSON.parse(row.ports || "{}");
|
||||
} catch {
|
||||
continue; // corrupt blob claims nothing
|
||||
}
|
||||
for (const port of Object.values(ports)) {
|
||||
if (Number.isInteger(port)) taken.add(port);
|
||||
}
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a stage transition. `stage_since` moves ONLY when the stage value
|
||||
* actually changes, so the UI's time-on-phase is real; a re-report of the same
|
||||
@@ -523,4 +577,6 @@ module.exports = {
|
||||
hasActiveLaneSession,
|
||||
purgeLaneSessions,
|
||||
setProvisioningFacts,
|
||||
usedSlots,
|
||||
reservedPorts,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @file TCP port probing for lane runtime allocation. Two questions the runtime
|
||||
* layer needs answered about a local port: is anything listening on it, and if so
|
||||
* which processes. `isListening` decides whether a lane's preferred port is free
|
||||
* and whether its stack actually came up; `listenerPids` names the occupier in an
|
||||
* EPORTBUSY error and backs up `downLane`'s pid-tree kill when a detached child
|
||||
* outlives its recorded parent.
|
||||
*
|
||||
* Deliberately has no opinion about lanes — it takes a port number and returns a
|
||||
* fact, so it can be tested against a throwaway server with no database.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const net = require("node:net");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** How long to wait for a connect before calling the port free. */
|
||||
const PROBE_TIMEOUT_MS = Number(process.env.LANE_PORT_PROBE_MS) || 300;
|
||||
|
||||
/**
|
||||
* Is something accepting TCP connections on this port?
|
||||
*
|
||||
* Connect-based rather than bind-based on purpose: binding to test a port races
|
||||
* with the thing we are about to start (we would have to release the socket
|
||||
* before the hook binds it, and a sibling lane could take it in between), and on
|
||||
* some platforms a successful bind says nothing about a listener already held by
|
||||
* another user. A refused connection is unambiguous — nobody is serving there.
|
||||
*
|
||||
* A timeout counts as "listening": a port that accepts the TCP handshake but
|
||||
* never responds is occupied, and treating it as free would hand a lane a port it
|
||||
* cannot bind.
|
||||
*
|
||||
* @param {number} port - TCP port on the loopback interface.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function isListening(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let settled = false;
|
||||
const done = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
socket.setTimeout(PROBE_TIMEOUT_MS);
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("timeout", () => done(true));
|
||||
socket.once("error", () => done(false));
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Warned once per process when neither `lsof` nor `ss` exists, so a container or
|
||||
* a minimal image does not print the same line on every probe.
|
||||
*/
|
||||
let warnedNoTool = false;
|
||||
|
||||
/** Parse a newline-separated list of pids, dropping anything non-numeric. */
|
||||
function parsePidList(stdout) {
|
||||
return [
|
||||
...new Set(
|
||||
stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^\d+$/.test(line))
|
||||
.map(Number)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which processes are listening on this port.
|
||||
*
|
||||
* Best-effort by design: this only ever enriches an error message or adds a
|
||||
* backstop to a kill that has already been attempted through the recorded pid
|
||||
* files. A missing tool must never fail a lane operation, so every failure path
|
||||
* returns an empty array rather than throwing.
|
||||
*
|
||||
* `lsof` first (present on macOS and most Linux installs), then `ss` from
|
||||
* iproute2 (present on minimal Linux images where lsof is not).
|
||||
*
|
||||
* @param {number} port - TCP port on the loopback interface.
|
||||
* @returns {Promise<number[]>} Listening pids, or [] when unknown.
|
||||
*/
|
||||
async function listenerPids(port) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
|
||||
return parsePidList(stdout);
|
||||
} catch (err) {
|
||||
// lsof exits 1 when nothing matches — that is an answer, not a missing tool.
|
||||
if (err.code === 1) return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync("ss", ["-lptnH", `sport = :${port}`]);
|
||||
return [...new Set([...stdout.matchAll(/pid=(\d+)/g)].map((m) => Number(m[1])))];
|
||||
} catch {
|
||||
/* fall through to the warning */
|
||||
}
|
||||
|
||||
if (!warnedNoTool) {
|
||||
warnedNoTool = true;
|
||||
console.warn(
|
||||
"[ports] neither lsof nor ss is available — port occupants cannot be named, " +
|
||||
"and lane down falls back to the recorded pid files alone"
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = { isListening, listenerPids, PROBE_TIMEOUT_MS };
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file Machine-level lane secrets: the database and Redis connection settings
|
||||
* shared by every lane on this host. Deliberately NOT part of a repository's
|
||||
* `.ccam/profile/` — a profile is committed and read by anyone who clones the
|
||||
* repo, and a database password does not belong there. Lives instead at
|
||||
* `~/.ccam/secrets.env`, parsed with the same literal `KEY=VALUE` reader
|
||||
* `lane-profile.js` uses for `profile.env` (config is parsed, never sourced).
|
||||
*
|
||||
* Never returned by any route: `GET /runtime` may report which keys are
|
||||
* present, never their values.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { parseEnvFile } = require("./lane-profile");
|
||||
|
||||
const SECRETS_PATH =
|
||||
process.env.CCAM_SECRETS_PATH || path.join(os.homedir(), ".ccam", "secrets.env");
|
||||
|
||||
/** Every declaration a lane's database/Redis facts can rely on when the file
|
||||
* is absent or unreadable — a local default stack, not a guess. */
|
||||
const DEFAULTS = Object.freeze({
|
||||
PG_HOST: "127.0.0.1",
|
||||
PG_PORT: "5432",
|
||||
PG_USER: "postgres",
|
||||
PG_PASS: "postgres",
|
||||
REDIS_HOST: "127.0.0.1",
|
||||
REDIS_PORT: "6379",
|
||||
});
|
||||
|
||||
let warnedMissing = false;
|
||||
let warnedPerms = false;
|
||||
|
||||
/**
|
||||
* Read `~/.ccam/secrets.env`, merged over DEFAULTS.
|
||||
*
|
||||
* Never throws: a missing file warns once and falls back to DEFAULTS (a lane
|
||||
* with no secrets file still gets a usable local Postgres/Redis target), and a
|
||||
* file readable by group or world is refused outright rather than trusted —
|
||||
* loading it would make CCAM the thing that taught a shared machine's other
|
||||
* users the database password.
|
||||
*
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
function readSecrets() {
|
||||
if (!fs.existsSync(SECRETS_PATH)) {
|
||||
if (!warnedMissing) {
|
||||
warnedMissing = true;
|
||||
console.warn(
|
||||
`[secrets] no ${SECRETS_PATH} — per-lane databases use built-in defaults ` +
|
||||
`(${DEFAULTS.PG_HOST}:${DEFAULTS.PG_PORT})`
|
||||
);
|
||||
}
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
|
||||
const mode = fs.statSync(SECRETS_PATH).mode & 0o777;
|
||||
if (mode & 0o077) {
|
||||
if (!warnedPerms) {
|
||||
warnedPerms = true;
|
||||
console.warn(
|
||||
`[secrets] ${SECRETS_PATH} is readable by group or world (mode ${mode.toString(8)}) ` +
|
||||
`— refusing to load it. Fix with: chmod 600 ${SECRETS_PATH}`
|
||||
);
|
||||
}
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
|
||||
try {
|
||||
return { ...DEFAULTS, ...parseEnvFile(fs.readFileSync(SECRETS_PATH, "utf8")) };
|
||||
} catch {
|
||||
return { ...DEFAULTS }; // unreadable file is the same as no file
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SECRETS_PATH, DEFAULTS, readSecrets };
|
||||
@@ -280,6 +280,150 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/runtime": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "A lane's own application stack",
|
||||
description:
|
||||
"Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .ccam/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.",
|
||||
operationId: "getLaneRuntime",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"available:false (no profile); available:true with provisioned:false (no slot yet); or the full facts with slot, ports, services, up, healthy, steppedAside, logs and lastError.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/up": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Boot a lane's stack",
|
||||
description:
|
||||
"Runs the profile's boot then health hooks. Returns 202 and finishes in the background because a build can take minutes; progress streams as lane_hook_output and the attempt ends with a lane_runtime message. Does NOT run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running, because their logs are what identify the service that never came up. Writes only slot and ports on the lane row: never stage, status or notes.",
|
||||
operationId: "upLane",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
build: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description:
|
||||
"false passes --no-build to the boot hook, reusing an existing build.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
202: { description: "Accepted; the boot runs in the background." },
|
||||
400: { description: "ENOPROFILE — no .ccam/profile, with the paths searched." },
|
||||
403: { description: "The browser request was not same-origin/loopback." },
|
||||
404: { description: "Lane not found." },
|
||||
409: { description: "ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/down": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Stop a lane's stack",
|
||||
description:
|
||||
"Kills each recorded pid tree bottom-up (killing a parent first reparents its children to init, where nothing knows to look for them), then sweeps listeners on the lane's ports ONLY when a pid file existed — a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server the user started there. Idempotent, and a no-op for a lane that was never up.",
|
||||
operationId: "downLane",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "{ok, killed: number[], runtime}." },
|
||||
403: { description: "The browser request was not same-origin/loopback." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/hook/{name}": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Run one of the lane profile's hooks",
|
||||
description:
|
||||
"The surface a driving session uses for ci-gate, e2e, migrate and friends. Returns 202; output streams as lane_hook_output and completion arrives as lane_hook_result with the exit code. The name is checked against a fixed allowlist BEFORE anything is spawned, and args travels as an array of strings straight into argv — neither is ever joined into a command string.",
|
||||
operationId: "runLaneHook",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{
|
||||
name: "name",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"bootstrap",
|
||||
"boot",
|
||||
"health",
|
||||
"migrate",
|
||||
"seed",
|
||||
"ci-gate",
|
||||
"e2e",
|
||||
"regen",
|
||||
"db-create",
|
||||
"db-drop",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { args: { type: "array", items: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
202: { description: "Accepted; the hook runs in the background." },
|
||||
400: { description: "ENOHOOK (name outside the allowlist) or ENOPROFILE." },
|
||||
403: { description: "The browser request was not same-origin/loopback." },
|
||||
404: { description: "Lane not found." },
|
||||
409: { description: "ENOSLOT — the lane has no runtime yet; bring it up first." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/logs/{svc}": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Tail a lane's hook or service log",
|
||||
description:
|
||||
"The resolved path is confined to the lane's log directory after realpath, so a name from the request can never escape it. Read-only, so no same-origin guard.",
|
||||
operationId: "getLaneLog",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "svc", in: "path", required: true, schema: { type: "string" } },
|
||||
{
|
||||
name: "tail",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "integer", default: 65536, maximum: 1048576 },
|
||||
description: "Trailing bytes to return; capped at 1 MiB.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, svc, size, truncated, text}, or {available:false} for a lane with no slot.",
|
||||
},
|
||||
404: { description: "Lane not found, or ENOLOG — no such log for this lane." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
+266
-1
@@ -30,10 +30,25 @@ const {
|
||||
slugify,
|
||||
} = require("../lib/worktree");
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
const { HOOKS, runHook, resolveProfile } = require("../lib/lane-profile");
|
||||
const { slotDirs } = require("../lib/lane-slots");
|
||||
const {
|
||||
upLane,
|
||||
downLane,
|
||||
runtimeFacts,
|
||||
requireProfile,
|
||||
provisionLane,
|
||||
resetLaneData,
|
||||
removeLaneData,
|
||||
} = require("../lib/lane-runtime");
|
||||
|
||||
const router = Router();
|
||||
const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;
|
||||
|
||||
/** Bytes of a hook log returned by default — enough to see a failure's tail. */
|
||||
const LOG_TAIL_DEFAULT = 64 * 1024;
|
||||
const LOG_TAIL_MAX = 1024 * 1024;
|
||||
|
||||
/** Seconds since this lane's session last emitted an event; null if never. */
|
||||
function lastEventAge(lane) {
|
||||
if (!lane.session_id) return null;
|
||||
@@ -317,6 +332,18 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
await addWorktree({ sourceRepo: resolvedSourceRepo, dir, branch, base: baseBranch });
|
||||
// base_branch is a provisioning fact, not a patchable field — see PATCHABLE.
|
||||
lanesLib.setProvisioningFacts(lane.id, { base_branch: baseBranch });
|
||||
|
||||
// A2 data isolation: only when the repo actually declares a profile — a
|
||||
// worktree lane with none is a normal state (nothing about A1 required
|
||||
// one either), so this is a no-op rather than a provisioning failure.
|
||||
const worktreeLane = lanesLib.getLane(lane.id);
|
||||
const profile = resolveProfile(worktreeLane);
|
||||
if (profile) {
|
||||
const onLine = (line, stream) =>
|
||||
broadcast("lane_hook_output", { laneId: lane.id, hook: "provision", stream, line });
|
||||
await provisionLane(worktreeLane, { onLine });
|
||||
}
|
||||
|
||||
lanesLib.updateLane(lane.id, { status: "idle", notes: null });
|
||||
} catch (err) {
|
||||
lanesLib.updateLane(lane.id, {
|
||||
@@ -419,6 +446,216 @@ function sendLifecycleError(res, err) {
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Runtime: a lane's own stack, isolated by slot-derived ports and directories.
|
||||
*
|
||||
* These routes are registered BEFORE the "/:id/:action" catch-all below, which
|
||||
* would otherwise swallow "up" and "down" as unknown actions. They are also
|
||||
* deliberately NOT folded into that catch-all: it drives a lane's Claude RUN,
|
||||
* while these drive the application the lane is working on — two different
|
||||
* lifecycles that happen to share a lane id.
|
||||
*
|
||||
* None of them writes `stage`, `status` or `notes`. A booted stack is not an
|
||||
* agent at work, and only `slot`/`ports` describe the runtime.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
/** Map a runtime error onto its status code. */
|
||||
function sendRuntimeError(res, err) {
|
||||
const badRequest = ["ENOPROFILE", "ENOHOOK", "EBADLANEDIR", "EBADSVC"];
|
||||
if (badRequest.includes(err.code)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
...(err.searched ? { searched: err.searched } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (err.code === "ESLOTS") {
|
||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code === "EPORTBUSY") {
|
||||
return res.status(409).json({
|
||||
error: { code: err.code, message: err.message, port: err.preferred, pids: err.pids },
|
||||
});
|
||||
}
|
||||
return res.status(500).json({
|
||||
error: { code: err.code || "ERUNTIME", message: err.message, logPath: err.logPath },
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
|
||||
function laneOr404(req, res) {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) {
|
||||
res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
return null;
|
||||
}
|
||||
return lane;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is running for this lane, computed fresh.
|
||||
*
|
||||
* Follows `GET /:id/git`'s contract: a lane with no profile answers
|
||||
* `{available:false}` with HTTP 200, because that is a normal state and not a
|
||||
* fault. It probes ports and stats pid files, which is why it is its own endpoint
|
||||
* rather than a field on the polled lane list.
|
||||
*/
|
||||
router.get("/:id/runtime", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
res.json(await runtimeFacts(lane));
|
||||
} catch (err) {
|
||||
sendRuntimeError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Tail one of the lane's hook or service logs.
|
||||
*
|
||||
* `:svc` is resolved against the log directory's real contents and the result is
|
||||
* confined to that directory after `realpath`, so a name from the request can
|
||||
* never escape it.
|
||||
*/
|
||||
router.get("/:id/logs/:svc", (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
if (!lane.slot) return res.json({ available: false });
|
||||
|
||||
const { logDir } = slotDirs(lane.slot);
|
||||
const file = path.resolve(logDir, `${req.params.svc}.log`);
|
||||
let real;
|
||||
try {
|
||||
real = fs.realpathSync(file);
|
||||
const relative = path.relative(fs.realpathSync(logDir), real);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("escapes log dir");
|
||||
} catch {
|
||||
return res.status(404).json({ error: { code: "ENOLOG", message: "no such log" } });
|
||||
}
|
||||
|
||||
const requested = Number(req.query.tail);
|
||||
const tail = Math.min(
|
||||
Number.isInteger(requested) && requested > 0 ? requested : LOG_TAIL_DEFAULT,
|
||||
LOG_TAIL_MAX
|
||||
);
|
||||
const { size } = fs.statSync(real);
|
||||
const start = Math.max(0, size - tail);
|
||||
const fd = fs.openSync(real, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(size - start);
|
||||
fs.readSync(fd, buffer, 0, buffer.length, start);
|
||||
res.json({
|
||||
available: true,
|
||||
svc: req.params.svc,
|
||||
size,
|
||||
truncated: start > 0,
|
||||
text: buffer.toString("utf8"),
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Boot the lane's stack. 202 + background, like `POST /worktree`: a build can run
|
||||
* for minutes and the caller should not hold a socket open for it. Progress
|
||||
* streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports`
|
||||
* the boot may have changed.
|
||||
*/
|
||||
router.post("/:id/up", sameOriginGuard, (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
requireProfile(lane);
|
||||
} catch (err) {
|
||||
return sendRuntimeError(res, err);
|
||||
}
|
||||
|
||||
const build = req.body?.build !== false;
|
||||
res.status(202).json({ ok: true, laneId: lane.id });
|
||||
|
||||
void withLaneLock(lane.id, async () => {
|
||||
const onLine = (line, stream) =>
|
||||
broadcast("lane_hook_output", { laneId: lane.id, hook: "up", stream, line });
|
||||
try {
|
||||
const facts = await upLane(lanesLib.getLane(lane.id), { build, onLine });
|
||||
broadcast("lane_runtime", { laneId: lane.id, runtime: facts });
|
||||
} catch (err) {
|
||||
broadcast("lane_runtime", {
|
||||
laneId: lane.id,
|
||||
error: { code: err.code || "ERUNTIME", message: err.message },
|
||||
});
|
||||
}
|
||||
broadcastLane(lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
/** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */
|
||||
router.post("/:id/down", sameOriginGuard, async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const result = await withLaneLock(lane.id, () => downLane(lanesLib.getLane(lane.id)));
|
||||
const facts = await runtimeFacts(lanesLib.getLane(lane.id));
|
||||
broadcast("lane_runtime", { laneId: lane.id, runtime: facts });
|
||||
res.json({ ok: true, killed: result.killed, runtime: facts });
|
||||
} catch (err) {
|
||||
sendRuntimeError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run one of the profile's hooks on the lane — the surface a driving session uses
|
||||
* for `ci-gate`, `e2e`, `migrate` and friends.
|
||||
*
|
||||
* `:name` is checked against the hook allowlist BEFORE anything is spawned, and
|
||||
* `args` travels as an array of strings straight into argv. Neither is ever
|
||||
* joined into a command string.
|
||||
*/
|
||||
router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
const { name } = req.params;
|
||||
if (!HOOKS.includes(name)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOHOOK", message: `unknown hook ${name}`, allowed: HOOKS } });
|
||||
}
|
||||
const args = Array.isArray(req.body?.args) ? req.body.args.map(String) : [];
|
||||
|
||||
let profile;
|
||||
try {
|
||||
profile = requireProfile(lane);
|
||||
} catch (err) {
|
||||
return sendRuntimeError(res, err);
|
||||
}
|
||||
if (!lane.slot) {
|
||||
return res.status(409).json({
|
||||
error: { code: "ENOSLOT", message: "lane has no runtime yet — bring it up first" },
|
||||
});
|
||||
}
|
||||
|
||||
res.status(202).json({ ok: true, laneId: lane.id, hook: name });
|
||||
|
||||
void withLaneLock(lane.id, async () => {
|
||||
const onLine = (line, stream) =>
|
||||
broadcast("lane_hook_output", { laneId: lane.id, hook: name, stream, line });
|
||||
try {
|
||||
const result = await runHook(lanesLib.getLane(lane.id), profile, name, args, { onLine });
|
||||
broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: result.code });
|
||||
} catch (err) {
|
||||
broadcast("lane_hook_result", {
|
||||
laneId: lane.id,
|
||||
hook: name,
|
||||
code: null,
|
||||
error: { code: err.code || "ERUNTIME", message: err.message },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Lane control. Deliberately thin: every action maps onto one existing
|
||||
* run-spawner call. There is no queue, no chaining, no gate evaluation — the
|
||||
@@ -447,6 +684,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
|
||||
|
||||
await stopLaneRun(lockedLane);
|
||||
// A running stack holds files open in the very directory reset and remove
|
||||
// are about to rewrite or delete, and its processes would outlive the lane
|
||||
// still bound to its ports. Stop it before touching git. Idempotent and a
|
||||
// no-op for a lane that was never brought up.
|
||||
if (action === "reset" || action === "remove") {
|
||||
await downLane(lanesLib.getLane(lane.id));
|
||||
}
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const facts = await preflight(current, action);
|
||||
assertExpectedPreflight(action, facts, body.expect);
|
||||
@@ -463,13 +707,34 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
|
||||
if (action === "reset") {
|
||||
await resetWorktree(current);
|
||||
return { lane: lanesLib.clearLane(current.id) };
|
||||
const resetLane = lanesLib.clearLane(current.id);
|
||||
// A2 data isolation, only when the lane actually has both a profile
|
||||
// and an allocated slot — a lane that was never brought up has
|
||||
// nothing of this kind to reset.
|
||||
const profile = resolveProfile(resetLane);
|
||||
if (profile && resetLane.slot) {
|
||||
const onLine = (line, stream) =>
|
||||
broadcast("lane_hook_output", { laneId: resetLane.id, hook: "reset", stream, line });
|
||||
await resetLaneData(resetLane, profile, { keepDb: body.keepDb === true, onLine });
|
||||
}
|
||||
return { lane: lanesLib.getLane(current.id) };
|
||||
}
|
||||
if (action === "remove") {
|
||||
// Drop the lane's own database(s) before anything else — its state
|
||||
// directory (the drop-created marker) is about to be deleted too.
|
||||
const profile = resolveProfile(current);
|
||||
if (profile && current.slot) await removeLaneData(current, profile);
|
||||
// Forgetting an adopted lane only removes dashboard metadata. The
|
||||
// filesystem destroy guard is deliberately reached only for managed
|
||||
// worktrees, where removal can actually touch a directory.
|
||||
if (current.kind === "managed") await removeWorktree(current);
|
||||
// Runtime bookkeeping outlives the row otherwise: pid files and hook
|
||||
// logs under .state/lane<slot>/ would be inherited by whichever lane
|
||||
// claims that slot next. Deleting the row is what frees the slot —
|
||||
// usedSlots() reads the table, so there is nothing else to release.
|
||||
if (current.slot) {
|
||||
fs.rmSync(slotDirs(current.slot).stateDir, { recursive: true, force: true });
|
||||
}
|
||||
lanesLib.deleteLane(current.id);
|
||||
return { removed: current.id };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user