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:
+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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user