9d145865dd
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.
246 lines
9.2 KiB
JavaScript
246 lines
9.2 KiB
JavaScript
/**
|
|
* @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,
|
|
};
|