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

Gives each lane its own slot-derived runtime (ports, detached process
lifecycle, profile-driven hooks) and its own database/Redis logical
index/.env file, so two lanes running the same repo's stack at once no
longer share state. Machine-level DB/Redis credentials live at
~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's
output is redacted of that password (raw and URL-encoded forms) before
it reaches a log file or the lane_hook_output websocket broadcast.
Wired into provision/up/reset/remove; reset accepts --keep-db to skip
the drop/recreate/migrate/reseed block entirely.
This commit is contained in:
2026-08-04 10:03:40 +07:00
parent d71086f677
commit 9d145865dd
19 changed files with 3265 additions and 12 deletions
+98
View File
@@ -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 };