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