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
+245
View File
@@ -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,
};