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
+150
View File
@@ -0,0 +1,150 @@
/**
* @file Seed and repair a lane's `.env` file(s): copy the source repository's
* real `.env` into the lane on first boot (or on a `--force` refresh) and then
* rewrite the per-lane keys (`DATABASE_URL`, `REDIS_URL`, `UPLOAD_DIR`, …) so
* the file is correct ON ITS OWN — not merely masked by a hook's runtime
* exports. Ports Shipyard's `lane-env-seed.sh`.
*
* Two hard-won behaviours are preserved verbatim. A `--force` refresh keeps
* `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's OWN existing file:
* swapping in the source's secret would 401 a running lane's tokens until
* reboot. And a missing source `.env` falls back to `.env.example` with a
* loud warning rather than a silent, broken seed.
* @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 { splitList, parseEnvFile } = require("./lane-profile");
const { dataFacts } = require("./lane-slots");
/** Resolve `relative` against `root`, refusing anything that climbs out of
* it — same confinement rule `lane-runtime.js:makeLaneDirs` applies to
* `LANE_DIRS`, so a `.` file declared by a repository can only ever touch
* its own tree. */
function confine(root, relative, code) {
const target = path.resolve(root, relative);
const rel = path.relative(root, target);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
throw Object.assign(new Error(`path escapes ${root}: ${relative}`), { code });
}
return target;
}
/**
* Rewrite declared keys inside an `.env` file's text, preserving every other
* line byte-for-byte. A key not already present is appended, mirroring
* Shipyard's python rewriter.
*
* @param {string} text - The file's current contents.
* @param {Record<string,string>} want - Keys to set, already resolved to their final values.
* @returns {string}
*/
function rewriteEnvText(text, want) {
const seen = new Set();
const lines = text.split("\n").map((line) => {
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/.exec(line);
if (match && Object.hasOwn(want, match[1])) {
seen.add(match[1]);
return `${match[1]}=${want[match[1]]}`;
}
return line;
});
for (const [key, value] of Object.entries(want)) {
if (!seen.has(key)) lines.push(`${key}=${value}`);
}
return lines.join("\n");
}
/**
* Seed or repair a lane's declared `.env` file(s).
*
* A no-op when the profile declares no `ENV_FILES` — the feature is off by
* default, and an adopted repo that never opts in gets no `.env` writes at
* all. Refuses on an adopted lane (`assertManaged`): that file is the user's
* real working config, not a template CCAM may overwrite.
*
* @param {object} lane - Lane row; `slot` must already be allocated.
* @param {object} profile - Resolved profile.
* @param {Record<string,string>} secrets - From `secrets.js:readSecrets()`.
* @param {{force?: boolean}} [options] - `force` re-copies from source even when the file exists.
* @returns {{skipped: boolean, seeded?: Array<{file: string, fromExample: boolean}>}}
*/
function seedEnv(lane, profile, secrets, { force = false } = {}) {
assertManaged(lane);
const files = splitList(profile.env.ENV_FILES);
if (!files.length) return { skipped: true };
const sources = splitList(profile.env.ENV_SOURCE || profile.env.ENV_FILES);
const rewriteKeys = new Set(splitList(profile.env.ENV_REWRITE));
const preserveKeys = splitList(profile.env.ENV_PRESERVE);
const sourceRoot = lane.source_repo || lane.cwd;
const facts = dataFacts(lane, profile, secrets);
const computed = {
DATABASE_URL: facts.databaseUrl,
REDIS_URL: facts.redisUrl,
UPLOAD_DIR: facts.uploadDir,
};
const seeded = [];
for (let i = 0; i < files.length; i += 1) {
const relFile = files[i];
const relSource = sources[i] || relFile;
const targetPath = confine(lane.cwd, relFile, "EBADENVFILE");
const sourcePath = confine(sourceRoot, relSource, "EBADENVFILE");
const existed = fs.existsSync(targetPath);
const preserved = {};
if (existed && force && preserveKeys.length) {
try {
const current = parseEnvFile(fs.readFileSync(targetPath, "utf8"));
for (const key of preserveKeys) {
if (current[key] !== undefined) preserved[key] = current[key];
}
} catch {
/* an unreadable existing file has nothing worth preserving */
}
}
if (!existed || force) {
let content;
let fromExample = false;
if (fs.existsSync(sourcePath)) {
content = fs.readFileSync(sourcePath, "utf8");
} else if (fs.existsSync(`${sourcePath}.example`)) {
content = fs.readFileSync(`${sourcePath}.example`, "utf8");
fromExample = true;
console.warn(
`[lane-env] lane ${lane.id}: ${relSource} is missing — seeded ${relFile} from ` +
`${relSource}.example instead (no real secrets/keys)`
);
} else {
throw Object.assign(
new Error(`no ${relSource} (or ${relSource}.example) to seed ${relFile} from`),
{ code: "ENOENVSOURCE", relSource }
);
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, content);
seeded.push({ file: relFile, fromExample });
}
const want = {};
for (const [key, value] of Object.entries(computed)) {
if (value != null && rewriteKeys.has(key)) want[key] = value;
}
Object.assign(want, preserved);
if (Object.keys(want).length) {
fs.writeFileSync(targetPath, rewriteEnvText(fs.readFileSync(targetPath, "utf8"), want));
}
}
if (facts.uploadDir) fs.mkdirSync(facts.uploadDir, { recursive: true });
return { skipped: false, seeded };
}
module.exports = { seedEnv, rewriteEnvText };
+11
View File
@@ -10,6 +10,8 @@ const fs = require("node:fs");
const { db } = require("../db");
const wt = require("./worktree");
const lanesLib = require("./lanes");
const { resolveProfile } = require("./lane-profile");
const { dbName } = require("./lane-slots");
/**
* Preflight for reset, remove, or purge. Returns an object describing what will happen:
@@ -74,6 +76,14 @@ async function preflight(lane, action) {
}
}
// The database name this action would drop (reset unless --keep-db,
// remove always) — echoed the same way `head`/`dirty` are, so the
// confirmation dialog names the destructive fact rather than leaving it a
// surprise. Null when the lane has no slot yet or the profile declares no
// DB_PREFIX — nothing has been derived to drop.
const profile = resolveProfile(lane);
const database = profile && lane.slot ? dbName(profile, lane.slot) : null;
return {
action,
lane: lane.id,
@@ -83,6 +93,7 @@ async function preflight(lane, action) {
untracked,
unpushed,
head: head || null,
database,
blocked,
warnings,
};
+397
View File
@@ -0,0 +1,397 @@
/**
* @file The stack seam. A profile is a repository's own description of how to
* build, boot and check its stack: `<repo>/.ccam/profile/` holding a `profile.env`
* of declarations plus a `hooks/` directory of shell scripts. CCAM stays
* stack-agnostic and calls those hooks with a stable environment contract, which
* is the same contract Shipyard's `run_hook` exports so its profiles port over
* unchanged.
*
* Two rules this module exists to enforce. Config is PARSED, never sourced —
* sourcing arbitrary shell from a repository into the dashboard process would be
* a code-execution path; hooks are executed deliberately, config is only read.
* And a hook is always spawned as `bash <hook> <args…>` with a fixed argument
* array, never a command string.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { slotDirs, dataFacts } = require("./lane-slots");
/** Directory, relative to a repository root, holding its profile. */
const PROFILE_SUBDIR = path.join(".ccam", "profile");
/**
* Hook names CCAM will run, ever.
*
* A fixed allowlist rather than "whatever is in hooks/": `:name` arrives from an
* HTTP route, and a name taken from a request is a path taken from a request.
* `bootstrap`/`migrate`/`seed`/`ci-gate`/`e2e`/`regen` are not called by A1's
* lifecycle but are listed here because `POST /:id/hook/:name` can run them on a
* session's behalf, and the driving skill needs that surface stable.
*/
const HOOKS = Object.freeze([
"bootstrap",
"boot",
"health",
"migrate",
"seed",
"ci-gate",
"e2e",
"regen",
"db-create",
"db-drop",
]);
/**
* Defaults for every declaration a profile may omit, so a missing key degrades
* to something usable rather than breaking a lane. Mirrors the defaults block in
* Shipyard's `_common.sh` so a ported profile behaves identically.
*/
const DEFAULTS = Object.freeze({
PORTS: "api fe",
PORT_BASE_api: "8000",
PORT_BASE_fe: "3000",
LANE_DIRS: "",
BACKEND_DIR: "backend",
FRONTEND_DIR: "frontend",
API_PATH: "/api",
// A2 data isolation — every one of these empty/0 is "feature off", so an
// adopted repo that never declares them gets no per-lane database, no Redis
// index, and no .env rewriting: dead code never runs rather than running on
// guessed values.
DB_PREFIX: "",
DB_KIND: "",
DB_URL_SCHEME: "postgresql",
REDIS: "0",
ENV_FILES: "",
ENV_SOURCE: "",
ENV_REWRITE: "",
ENV_PRESERVE: "",
UPLOAD_SUBDIR: "",
});
/**
* Parse a `KEY=VALUE` declaration file.
*
* Not a shell parser and not trying to be: comments and blank lines are skipped,
* an `export ` prefix is tolerated (profiles ported from Shipyard have it), and
* one layer of matching quotes is stripped. Everything else is taken literally —
* `$(id)`, backticks and `${VAR}` stay as written. That literalness IS the
* security property; do not add expansion here.
*
* @param {string} text - File contents.
* @returns {Record<string, string>}
*/
function parseEnvFile(text) {
const out = {};
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
if (!match) continue;
let value = match[2].trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
out[match[1]] = value;
}
return out;
}
/** Split a space-separated declaration into a deduplicated list. */
function splitList(value) {
return [...new Set((value || "").split(/\s+/).filter(Boolean))];
}
/**
* Find and read a lane's profile.
*
* The lane's OWN working copy is searched first, its source repository second.
* The profile lives in the repository, so a worktree already carries the version
* belonging to its branch — and a branch that changes a boot command must boot
* with the command it changed, not the one on the base branch. The source-repo
* fallback covers a profile the user keeps gitignored, which never reaches a
* worktree through git.
*
* @param {object} lane - Lane row (`cwd`, `source_repo`).
* @returns {{dir: string, env: Record<string,string>, hooks: Set<string>, ports: string[], laneDirs: string[]}|null}
* null when neither location has a profile — a normal state, not a fault.
*/
function resolveProfile(lane) {
const candidates = [lane.cwd, lane.source_repo].filter(Boolean);
for (const root of candidates) {
const dir = path.join(root, PROFILE_SUBDIR);
if (!fs.existsSync(path.join(dir, "profile.env"))) continue;
let declared = {};
try {
declared = parseEnvFile(fs.readFileSync(path.join(dir, "profile.env"), "utf8"));
} catch {
continue; // unreadable profile is the same as no profile
}
const env = { ...DEFAULTS, ...declared };
const hooks = new Set();
for (const name of HOOKS) {
if (fs.existsSync(path.join(dir, "hooks", `${name}.sh`))) hooks.add(name);
}
return {
dir,
env,
hooks,
ports: splitList(env.PORTS),
laneDirs: splitList(env.LANE_DIRS),
};
}
return null;
}
/** The profile paths searched for a lane, for an ENOPROFILE message. */
function profileSearchPaths(lane) {
return [lane.cwd, lane.source_repo]
.filter(Boolean)
.map((root) => path.join(root, PROFILE_SUBDIR));
}
/**
* A `harness_spawn` shell function, injected into every hook.
*
* Same name and signature as Shipyard's so ported hooks work untouched:
* `harness_spawn <name> <workdir> <cmd…>` backgrounds a long-lived service and
* records its pid where downLane looks.
*
* The stdio detachment is not optional. A child that inherits the caller's stdout
* holds that pipe open, so a caller reading to EOF never returns — the failure
* that stalled Shipyard's boot stage until it was fixed the same way. `nohup` plus
* a closed stdin plus redirected output is what lets a lane's stack outlive both
* the hook and the dashboard.
*/
const HARNESS_SPAWN = `
harness_spawn() {
local name="$1" wd="$2"; shift 2
( cd "$wd" || exit 1
nohup "$@" >"$LOG_DIR/$name.log" 2>&1 </dev/null &
echo $! >"$RUN_DIR/$name.pid"
) </dev/null >/dev/null 2>&1
}
die() { echo "profile: $*" >&2; exit 1; }
`;
/**
* The environment contract every hook can rely on.
*
* Deliberately identical to Shipyard's `run_hook` exports where the concept
* survives the port, so a profile written for the harness runs here unchanged.
* `LANE` is the SLOT, not the lane id — Shipyard hooks use it to derive per-lane
* names, and the slot is what carries that meaning.
*
* Git variables are scrubbed for the same reason `worktree.js:git()` scrubs them:
* a hook that shells out to git must not inherit a git context pointing at the
* dashboard's own repository, and `GIT_CONFIG_*` can inject `core.hooksPath` into
* every git call the hook makes.
*
* `require("./secrets")` is deferred to the function body rather than hoisted
* to the top of the file: `secrets.js` itself requires this module for
* `parseEnvFile`, and a top-level require here would complete the cycle while
* this file's own `module.exports` is still empty, handing `secrets.js` an
* `undefined` parser. Deferring past module-load time breaks the cycle.
*/
function hookEnv(lane, profile) {
const { readSecrets } = require("./secrets");
const dirs = slotDirs(lane.slot);
const secrets = readSecrets();
const facts = dataFacts(lane, profile, secrets);
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_COMMON_DIR;
delete env.GIT_OBJECT_DIRECTORY;
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete env.GIT_PREFIX;
delete env.GIT_NAMESPACE;
delete env.GIT_CONFIG_PARAMETERS;
for (const name of Object.keys(env)) {
if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete env[name];
}
env.GIT_TERMINAL_PROMPT = "0";
Object.assign(env, profile.env, {
LANE: String(lane.slot),
LANE_ID: String(lane.id),
LANE_DIR: lane.cwd,
SOURCE_REPO: lane.source_repo || lane.cwd,
PROFILE_DIR: profile.dir,
RUN_DIR: dirs.runDir,
LOG_DIR: dirs.logDir,
});
// A2 data-isolation facts, present only when their owning declaration is —
// a profile with no DB_PREFIX sees no DB_NAME/DATABASE_URL at all, so a
// db-create.sh hook that forgot to check DB_PREFIX fails loudly (unset var
// under `set -u`) instead of quietly touching a database named "undefined".
if (facts.dbName) {
env.DB_NAME = facts.dbName;
env.DATABASE_URL = facts.databaseUrl;
env.TEST_DATABASE_URL = facts.testDatabaseUrl;
// Raw connection settings, not just the assembled URL: Shipyard's own
// db-create/db-drop hooks call `createdb -U "$PG_USER"` directly (trust
// auth inside the compose network, no password needed), and a ported
// profile expects these names verbatim. PG_PASS is deliberately withheld —
// nothing in the ported hooks needs it, and every value that DOES reach a
// hook's environment is a value that could end up in an echoed debug line.
env.PG_HOST = secrets.PG_HOST;
env.PG_PORT = secrets.PG_PORT;
env.PG_USER = secrets.PG_USER;
}
if (facts.redisUrl) {
env.REDIS_URL = facts.redisUrl;
env.REDIS_HOST = secrets.REDIS_HOST;
env.REDIS_PORT = secrets.REDIS_PORT;
}
if (facts.uploadDir) env.UPLOAD_DIR = facts.uploadDir;
// <NAME>_PORT for every declared port, upper-cased: PORTS="api fe" -> API_PORT, FE_PORT.
for (const [name, port] of Object.entries(lane.ports || {})) {
env[`${name.toUpperCase()}_PORT`] = String(port);
}
return env;
}
/**
* Run one of the profile's hooks.
*
* Spawned through a one-line bash wrapper that defines the shell helpers,
* `export -f`s them (exactly as Shipyard's `run_hook` does) and then `exec`s the
* hook, so the hook runs as its own script with its own `set -e` while still
* seeing `harness_spawn`. The hook path and its arguments travel as an argument
* ARRAY appended after the wrapper — never interpolated into the script text, so
* a lane directory containing a quote or a space is a path, not a command.
*
* Output is streamed line by line to `onLine` (the caller broadcasts it) and
* appended to `$LOG_DIR/<name>.log`, so a boot is watchable live and readable
* afterwards. Any value that came from `secrets.js` (currently `PG_PASS`, and
* therefore the password segment of `DATABASE_URL`/`TEST_DATABASE_URL`) is
* redacted from that stream first: `runHook`'s output reaches a browser tab
* over the `lane_hook_output` websocket, and a hook that echoes its own
* environment (common while debugging a failing migration) must not publish a
* database password to everyone watching.
*
* @param {object} lane - Lane row with an allocated slot and resolved ports.
* @param {object} profile - From resolveProfile().
* @param {string} name - Hook name; must be in HOOKS.
* @param {string[]} [args] - Extra arguments passed to the hook.
* @param {{onLine?: (line: string, stream: "stdout"|"stderr") => void, timeoutMs?: number}} [options]
* @returns {Promise<{code: number, output: string}>} Resolves even on a non-zero exit.
*/
function runHook(lane, profile, name, args = [], options = {}) {
if (!HOOKS.includes(name)) {
return Promise.reject(
Object.assign(new Error(`unknown hook: ${name}`), { code: "ENOHOOK", hook: name })
);
}
const hookPath = path.join(profile.dir, "hooks", `${name}.sh`);
if (!fs.existsSync(hookPath)) {
return Promise.reject(
Object.assign(new Error(`profile has no hook "${name}.sh"`), {
code: "ENOHOOK",
hook: name,
path: hookPath,
})
);
}
const dirs = slotDirs(lane.slot);
fs.mkdirSync(dirs.runDir, { recursive: true });
fs.mkdirSync(dirs.logDir, { recursive: true });
const logPath = path.join(dirs.logDir, `${name}.log`);
const logStream = fs.createWriteStream(logPath, { flags: "a" });
// Deferred for the same reason as inside hookEnv(): secrets.js requires this
// module, so a top-level require here would complete the load cycle early.
// Only the password is redacted — host/port/user are not secret on their
// own, and treating them as such would mangle unrelated numbers in output.
// Both forms: DATABASE_URL embeds the URL-encoded password, so a raw echo
// of the password and an echo of DATABASE_URL need separate substrings.
const pgPass = require("./secrets").readSecrets().PG_PASS;
const secretValues = [pgPass, pgPass && encodeURIComponent(pgPass)].filter(Boolean);
const redact = (text) => secretValues.reduce((s, v) => s.split(v).join("[REDACTED]"), text);
return new Promise((resolve, reject) => {
const child = spawn(
"bash",
[
"-c",
`${HARNESS_SPAWN}\nexport -f harness_spawn die\nexec bash "$@"`,
"bash",
hookPath,
...args.map(String),
],
{
cwd: lane.cwd,
env: hookEnv(lane, profile),
stdio: ["ignore", "pipe", "pipe"],
}
);
let output = "";
let settled = false;
const timer = options.timeoutMs
? setTimeout(() => {
child.kill("SIGKILL");
}, options.timeoutMs)
: null;
const consume = (stream, which) => {
let buffer = "";
stream.setEncoding("utf8");
stream.on("data", (raw) => {
const chunk = secretValues.length ? redact(raw) : raw;
output += chunk;
logStream.write(chunk);
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) options.onLine?.(line, which);
});
stream.on("end", () => {
if (buffer) options.onLine?.(buffer, which);
});
};
consume(child.stdout, "stdout");
consume(child.stderr, "stderr");
const finish = (fn, value) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
logStream.end();
fn(value);
};
child.on("error", (err) => finish(reject, err));
child.on("close", (code) => finish(resolve, { code: code ?? 1, output, logPath }));
});
}
module.exports = {
HOOKS,
DEFAULTS,
PROFILE_SUBDIR,
parseEnvFile,
splitList,
resolveProfile,
profileSearchPaths,
hookEnv,
runHook,
};
+532
View File
@@ -0,0 +1,532 @@
/**
* @file Lane stack lifecycle: bring a lane's services up through its profile's
* hooks, take them down, and report what is actually running. Also owns the
* data-isolation lifecycle (A2): `provisionLane`/`resetLaneData`/`removeLaneData`
* seed `.env`, create/drop the lane's own database, and run `bootstrap`/
* `migrate`/`seed` at the points Shipyard's `lane-bootstrap.sh`/`lane-up.sh`/
* `lane-reset.sh`/`lane-remove.sh` do — CCAM's version of "ports came from A1,
* everything else a lane needs to run its own stack comes from here."
*
* Two properties shape everything here.
*
* Services are FULLY DETACHED (see `harness_spawn` in lane-profile.js), so a
* lane's stack outlives both the hook that started it and the dashboard itself —
* restarting or updating CCAM must never kill work in progress.
*
* Liveness is COMPUTED, never stored. Whether a stack is up is not a fact CCAM
* controls: a process dies to OOM, to a stray `kill`, to a reboot. A cached
* "running" flag would be wrong from that moment until something noticed, so
* `runtimeFacts` re-derives it from pid files and port probes on every read. The
* payoff is that adopting a stack after a dashboard restart needs no code at all.
*
* This module writes `slot` and `ports` and nothing else on the lane row. It never
* writes `stage`, `status` or `notes`: in CCAM those describe the AGENT's work, not
* the stack's state, and conflating "a server is listening" with "an agent is
* running" would corrupt lane liveness. Boot failures live in `last-error.json`
* and surface through `runtimeFacts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const lanesLib = require("./lanes");
const {
allocateSlot,
resolvePorts,
slotDirs,
portsSteppedAside,
portBase,
dbName,
} = require("./lane-slots");
const { resolveProfile, profileSearchPaths, runHook } = require("./lane-profile");
const { isListening, listenerPids } = require("./ports");
const { seedEnv } = require("./lane-env");
const { ensureDatabase, dropDatabase } = require("./lane-services");
const { readSecrets } = require("./secrets");
const execFileAsync = promisify(execFile);
/** A health hook that never returns is a failed boot, not an eternal wait. */
const HEALTH_TIMEOUT_MS = Number(process.env.LANE_HEALTH_TIMEOUT_MS) || 180_000;
const BOOT_TIMEOUT_MS = Number(process.env.LANE_BOOT_TIMEOUT_MS) || 900_000;
/** Resolve a lane's profile or throw the error a route turns into a 400. */
function requireProfile(lane) {
const profile = resolveProfile(lane);
if (!profile) {
throw Object.assign(
new Error(`lane has no .ccam/profile — looked in: ${profileSearchPaths(lane).join(", ")}`),
{ code: "ENOPROFILE", searched: profileSearchPaths(lane) }
);
}
return profile;
}
/** Direct children of a pid. Empty when pgrep is unavailable — the parent still dies. */
async function childPids(pid) {
try {
const { stdout } = await execFileAsync("pgrep", ["-P", String(pid)]);
return stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => /^\d+$/.test(line))
.map(Number);
} catch {
return [];
}
}
/**
* Kill a process and every descendant, deepest first.
*
* Bottom-up matters: killing the parent first reparents its children to init,
* where nothing knows to look for them. This is the failure `lane-down.sh`'s
* `kill_tree` was written for — a uvicorn reloader or a celery prefork pool whose
* workers survived a kill aimed at the recorded pid, kept the port bound, and made
* the next boot fail for a reason nothing reported.
*
* @param {number} pid - Root of the tree.
* @returns {Promise<number[]>} Pids signalled.
*/
async function killTree(pid) {
const killed = [];
for (const child of await childPids(pid)) {
killed.push(...(await killTree(child)));
}
try {
process.kill(pid, "SIGKILL");
killed.push(pid);
} catch {
/* already gone */
}
return killed;
}
/** True when a pid exists and we may signal it. */
function isAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means it exists but belongs to someone else — still alive.
return err.code === "EPERM";
}
}
/** Recorded services: every `<name>.pid` the boot hook left in the run directory. */
function readPidFiles(runDir) {
let entries = [];
try {
entries = fs.readdirSync(runDir);
} catch {
return [];
}
const out = [];
for (const entry of entries) {
if (!entry.endsWith(".pid")) continue;
const raw = fs.readFileSync(path.join(runDir, entry), "utf8").trim();
if (!/^\d+$/.test(raw)) continue;
out.push({ name: entry.slice(0, -4), pid: Number(raw), file: path.join(runDir, entry) });
}
return out;
}
/** Persist why a boot failed, where runtimeFacts can find it. */
function recordError(lane, error) {
const { stateDir, errorFile } = slotDirs(lane.slot);
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
errorFile,
JSON.stringify(
{ at: new Date().toISOString(), code: error.code || null, message: error.message },
null,
2
)
);
}
/** Drop a stale failure once a boot succeeds. */
function clearError(lane) {
try {
fs.rmSync(slotDirs(lane.slot).errorFile, { force: true });
} catch {
/* nothing recorded */
}
}
function readError(slot) {
try {
return JSON.parse(fs.readFileSync(slotDirs(slot).errorFile, "utf8"));
} catch {
return null;
}
}
/**
* Resolve one `LANE_DIRS` entry against the lane's working copy, refusing
* anything that escapes it (an absolute path, or one climbing out with `..`)
* rather than silently touching a directory somewhere else on the machine.
*/
function resolveLaneDirEntry(lane, entry) {
const target = path.resolve(lane.cwd, entry);
const relative = path.relative(lane.cwd, target);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw Object.assign(new Error(`LANE_DIRS entry escapes the lane: ${entry}`), {
code: "EBADLANEDIR",
entry,
});
}
return target;
}
/** Create the per-lane directories the profile declares. */
function makeLaneDirs(lane, profile) {
for (const entry of profile.laneDirs) {
fs.mkdirSync(resolveLaneDirEntry(lane, entry), { recursive: true });
}
}
/**
* Empty and recreate the per-lane directories a `reset` should start fresh —
* Shipyard's `rm -rf "$(lane_upload_dir "$N")"/*` generalized to every
* declared `LANE_DIRS` entry, since a fresh feature should not inherit a
* previous one's uploaded files.
*/
function clearLaneDirs(lane, profile) {
for (const entry of profile.laneDirs) {
const target = resolveLaneDirEntry(lane, entry);
fs.rmSync(target, { recursive: true, force: true });
fs.mkdirSync(target, { recursive: true });
}
}
/**
* Run a hook only when the profile declares it, throwing `errCode` on a
* non-zero exit. Shared by every lifecycle step below (`bootstrap`, `migrate`,
* `seed`) so "the profile never declared this" and "the hook failed" stay two
* distinct, consistently-coded outcomes everywhere they're checked.
*/
async function runRequiredHook(lane, profile, name, options, errCode) {
if (!profile.hooks.has(name)) return null;
const result = await runHook(lane, profile, name, [], options);
if (result.code !== 0) {
throw Object.assign(new Error(`${name} hook exited ${result.code}`), {
code: errCode,
logPath: result.logPath,
});
}
return result;
}
/**
* Provision a freshly-created managed lane's data isolation: seed its `.env`,
* run `bootstrap`/`migrate`/`seed`, and create its database. Runs once, right
* after the worktree itself exists — the other half of isolation A1 did not
* cover (ports and directories came from A1; database, `.env` and uploads
* come from here). Every step degrades to nothing when the profile never
* declares the underlying feature: no `bootstrap` hook, no `DB_PREFIX`, no
* `ENV_FILES` are all normal, not partial failures.
*
* @param {object} lane - Lane row, freshly worktree-provisioned (kind "managed").
* @param {{onLine?: Function}} [options]
* @returns {Promise<object>} The lane row after provisioning.
*/
async function provisionLane(lane, options = {}) {
const profile = requireProfile(lane);
const { onLine } = options;
let current = lane;
if (!current.slot) {
allocateSlot(current.id);
current = lanesLib.getLane(current.id);
}
seedEnv(current, profile, readSecrets());
await runRequiredHook(
current,
profile,
"bootstrap",
{ onLine, timeoutMs: BOOT_TIMEOUT_MS },
"EBOOTSTRAPFAILED"
);
await ensureDatabase(current, profile, { onLine });
await runRequiredHook(current, profile, "migrate", { onLine }, "EMIGRATEFAILED");
await runRequiredHook(current, profile, "seed", { onLine }, "ESEEDFAILED");
return lanesLib.getLane(current.id);
}
/**
* Reset a lane's data isolation after `resetWorktree` has already put its
* working copy back on the base branch: refresh `.env` (a `--force` refresh,
* so it tracks whatever the base branch's source `.env` now says), re-run
* `bootstrap` (a reset can land on a branch with different dependencies —
* Shipyard's own comment: "deps move under node_modules/venv"), clear the
* declared `LANE_DIRS`, and — unless `keepDb` — drop, recreate, migrate and
* reseed the database. `keepDb` skips that whole block, not just the drop:
* a caller who wants to keep their data wants it left alone, migrations
* included.
*
* @param {object} lane - Lane row, after `resetWorktree`.
* @param {object} profile - Resolved profile.
* @param {{keepDb?: boolean, onLine?: Function}} [options]
*/
async function resetLaneData(lane, profile, options = {}) {
const { keepDb = false, onLine } = options;
seedEnv(lane, profile, readSecrets(), { force: true });
await runRequiredHook(
lane,
profile,
"bootstrap",
{ onLine, timeoutMs: BOOT_TIMEOUT_MS },
"EBOOTSTRAPFAILED"
);
clearLaneDirs(lane, profile);
if (keepDb) return;
if (!dbName(profile, lane.slot)) return;
await dropDatabase(lane, profile, dbName(profile, lane.slot), { onLine });
await ensureDatabase(lane, profile, { onLine });
await runRequiredHook(lane, profile, "migrate", { onLine }, "EMIGRATEFAILED");
await runRequiredHook(lane, profile, "seed", { onLine }, "ESEEDFAILED");
}
/**
* Drop a managed lane's database and its `_test` sibling before the rest of
* removal tears down the worktree and state directory.
*
* A no-op for an adopted lane — its data was never CCAM's to create, so it is
* never CCAM's to destroy either, the same invariant `assertDestroyable`
* enforces for the worktree itself. Best-effort per database: mirrors
* Shipyard's `dropdb --if-exists ... || true` — a failed drop is logged, not
* thrown, because it must never block removing the lane's dashboard record.
*
* @param {object} lane - Lane row.
* @param {object} profile - Resolved profile.
* @param {{onLine?: Function}} [options]
*/
async function removeLaneData(lane, profile, options = {}) {
if (lane.kind !== "managed") return;
const name = dbName(profile, lane.slot);
if (!name) return;
for (const target of [name, `${name}_test`]) {
try {
await dropDatabase(lane, profile, target, options);
} catch (err) {
console.warn(`[lane-runtime] lane ${lane.id}: db-drop ${target} failed: ${err.message}`);
}
}
}
/**
* Boot a lane's stack.
*
* Caller MUST hold the lane lock: slot allocation reads-then-writes across the
* `await` in port resolution, and two concurrent ups would otherwise both believe
* they own the slot.
*
* Runs `boot` then `health`; deliberately NOT `bootstrap`, which belongs to
* provisioning and reset (Shipyard's `lane-up.sh` draws the same line — installing
* dependencies on every boot would make a routine restart minutes long).
*
* A failing health check leaves the processes running. They are the evidence: the
* logs of a half-booted stack are what tells the user which service never came up,
* and killing them to report a tidy failure destroys exactly that.
*
* @param {object} lane - Lane row.
* @param {{build?: boolean, onLine?: Function}} [options]
* @returns {Promise<object>} Runtime facts after the attempt.
*/
async function upLane(lane, options = {}) {
const profile = requireProfile(lane);
const { build = true, onLine } = options;
let current = lane;
if (!current.slot) {
allocateSlot(current.id);
current = lanesLib.getLane(current.id);
}
const ports = await resolvePorts(current, profile);
lanesLib.setProvisioningFacts(current.id, { ports });
current = lanesLib.getLane(current.id);
const dirs = slotDirs(current.slot);
fs.mkdirSync(dirs.runDir, { recursive: true });
fs.mkdirSync(dirs.logDir, { recursive: true });
makeLaneDirs(current, profile);
// Defensive: a re-run on a live lane must not double-start services that then
// fight over the same ports.
await downLane(current, { profile });
try {
// Repair .env on every boot (a lane whose file was hand-edited or never
// seeded gets fixed here), then ensure the database exists — cheap when it
// already does — migrate on every boot (schemas drift while a lane sits
// idle), and seed only when this boot is the one that created the database.
if (current.kind === "managed") seedEnv(current, profile, readSecrets());
const db = await ensureDatabase(current, profile, { onLine });
await runRequiredHook(current, profile, "migrate", { onLine }, "EMIGRATEFAILED");
if (db.created) await runRequiredHook(current, profile, "seed", { onLine }, "ESEEDFAILED");
const boot = await runHook(current, profile, "boot", build ? [] : ["--no-build"], {
onLine,
timeoutMs: BOOT_TIMEOUT_MS,
});
if (boot.code !== 0) {
throw Object.assign(new Error(`boot hook exited ${boot.code}`), {
code: "EBOOTFAILED",
logPath: boot.logPath,
});
}
if (profile.hooks.has("health")) {
const health = await runHook(current, profile, "health", [], {
onLine,
timeoutMs: HEALTH_TIMEOUT_MS,
});
if (health.code !== 0) {
throw Object.assign(new Error(`health check failed (exit ${health.code})`), {
code: "EUNHEALTHY",
logPath: health.logPath,
});
}
}
clearError(current);
} catch (err) {
recordError(current, err);
throw err;
}
return runtimeFacts(lanesLib.getLane(current.id));
}
/**
* Stop a lane's stack. Idempotent, and safe on a lane that was never up.
*
* Kills each recorded pid tree, then — only when there WAS something recorded —
* sweeps any listener still holding the lane's ports. That condition is a
* deliberate departure from Shipyard, which always sweeps: a lane whose stack is
* already down still owns its port numbers, and if the user has since started
* their own server on one, an unconditional sweep would kill it. Requiring a pid
* file keeps the backstop for the case it exists to cover — a detached child that
* outlived the parent we recorded — without ever reaching a stranger's process.
*
* @param {object} lane - Lane row.
* @param {{profile?: object}} [options]
* @returns {Promise<{killed: number[]}>}
*/
async function downLane(lane, options = {}) {
if (!lane.slot) return { killed: [] };
const { runDir } = slotDirs(lane.slot);
const recorded = readPidFiles(runDir);
const killed = [];
for (const service of recorded) {
killed.push(...(await killTree(service.pid)));
fs.rmSync(service.file, { force: true });
}
if (recorded.length) {
for (const port of Object.values(lane.ports || {})) {
for (const pid of await listenerPids(port)) {
if (!killed.includes(pid)) killed.push(...(await killTree(pid)));
}
}
}
return { killed };
}
/**
* What is actually running for this lane, computed fresh on every call.
*
* Follows the contract of `GET /api/lanes/:id/git`: a lane with no profile is a
* normal state, reported as `{available: false}` rather than an error. Callers
* probe ports and stat pid files here, which is why this is its own endpoint and
* not part of the polled lane list.
*
* @param {object} lane - Lane row.
* @returns {Promise<object>}
*/
async function runtimeFacts(lane) {
const profile = resolveProfile(lane);
if (!profile) return { available: false, searched: profileSearchPaths(lane) };
if (!lane.slot) {
return { available: true, provisioned: false, hooks: [...profile.hooks], ports: {} };
}
const dirs = slotDirs(lane.slot);
const services = readPidFiles(dirs.runDir).map((service) => ({
name: service.name,
pid: service.pid,
alive: isAlive(service.pid),
}));
const ports = {};
for (const name of profile.ports) {
const port = lane.ports?.[name] ?? null;
ports[name] = {
port,
expected: portBase(profile, name) + lane.slot,
listening: port ? await isListening(port) : false,
};
}
let logs = [];
try {
logs = fs.readdirSync(dirs.logDir).filter((entry) => entry.endsWith(".log"));
} catch {
/* never booted */
}
// Names and an index, never a connection string: DATABASE_URL/REDIS_URL embed
// the secrets.env password, and this object is exactly what GET /runtime
// returns to a browser tab.
const name = dbName(profile, lane.slot);
const database = name ? { name, testName: `${name}_test` } : null;
const redisIndex = profile.env.REDIS === "1" ? lane.slot : null;
return {
available: true,
provisioned: true,
slot: lane.slot,
kind: lane.kind,
hooks: [...profile.hooks],
profileDir: profile.dir,
services,
ports,
database,
redisIndex,
steppedAside: portsSteppedAside(lane, profile),
up: services.some((service) => service.alive),
healthy:
Object.values(ports).length > 0 && Object.values(ports).every((entry) => entry.listening),
logs,
logDir: dirs.logDir,
lastError: readError(lane.slot),
};
}
module.exports = {
upLane,
downLane,
runtimeFacts,
requireProfile,
provisionLane,
resetLaneData,
removeLaneData,
killTree,
isAlive,
readPidFiles,
makeLaneDirs,
clearLaneDirs,
HEALTH_TIMEOUT_MS,
BOOT_TIMEOUT_MS,
};
+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 };
+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,
};
+67 -11
View File
@@ -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,
};
+116
View File
@@ -0,0 +1,116 @@
/**
* @file TCP port probing for lane runtime allocation. Two questions the runtime
* layer needs answered about a local port: is anything listening on it, and if so
* which processes. `isListening` decides whether a lane's preferred port is free
* and whether its stack actually came up; `listenerPids` names the occupier in an
* EPORTBUSY error and backs up `downLane`'s pid-tree kill when a detached child
* outlives its recorded parent.
*
* Deliberately has no opinion about lanes — it takes a port number and returns a
* fact, so it can be tested against a throwaway server with no database.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const net = require("node:net");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
/** How long to wait for a connect before calling the port free. */
const PROBE_TIMEOUT_MS = Number(process.env.LANE_PORT_PROBE_MS) || 300;
/**
* Is something accepting TCP connections on this port?
*
* Connect-based rather than bind-based on purpose: binding to test a port races
* with the thing we are about to start (we would have to release the socket
* before the hook binds it, and a sibling lane could take it in between), and on
* some platforms a successful bind says nothing about a listener already held by
* another user. A refused connection is unambiguous — nobody is serving there.
*
* A timeout counts as "listening": a port that accepts the TCP handshake but
* never responds is occupied, and treating it as free would hand a lane a port it
* cannot bind.
*
* @param {number} port - TCP port on the loopback interface.
* @returns {Promise<boolean>}
*/
function isListening(port) {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const done = (result) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(result);
};
socket.setTimeout(PROBE_TIMEOUT_MS);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(true));
socket.once("error", () => done(false));
socket.connect(port, "127.0.0.1");
});
}
/**
* Warned once per process when neither `lsof` nor `ss` exists, so a container or
* a minimal image does not print the same line on every probe.
*/
let warnedNoTool = false;
/** Parse a newline-separated list of pids, dropping anything non-numeric. */
function parsePidList(stdout) {
return [
...new Set(
stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => /^\d+$/.test(line))
.map(Number)
),
];
}
/**
* Which processes are listening on this port.
*
* Best-effort by design: this only ever enriches an error message or adds a
* backstop to a kill that has already been attempted through the recorded pid
* files. A missing tool must never fail a lane operation, so every failure path
* returns an empty array rather than throwing.
*
* `lsof` first (present on macOS and most Linux installs), then `ss` from
* iproute2 (present on minimal Linux images where lsof is not).
*
* @param {number} port - TCP port on the loopback interface.
* @returns {Promise<number[]>} Listening pids, or [] when unknown.
*/
async function listenerPids(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
return parsePidList(stdout);
} catch (err) {
// lsof exits 1 when nothing matches — that is an answer, not a missing tool.
if (err.code === 1) return [];
}
try {
const { stdout } = await execFileAsync("ss", ["-lptnH", `sport = :${port}`]);
return [...new Set([...stdout.matchAll(/pid=(\d+)/g)].map((m) => Number(m[1])))];
} catch {
/* fall through to the warning */
}
if (!warnedNoTool) {
warnedNoTool = true;
console.warn(
"[ports] neither lsof nor ss is available — port occupants cannot be named, " +
"and lane down falls back to the recorded pid files alone"
);
}
return [];
}
module.exports = { isListening, listenerPids, PROBE_TIMEOUT_MS };
+79
View File
@@ -0,0 +1,79 @@
/**
* @file Machine-level lane secrets: the database and Redis connection settings
* shared by every lane on this host. Deliberately NOT part of a repository's
* `.ccam/profile/` — a profile is committed and read by anyone who clones the
* repo, and a database password does not belong there. Lives instead at
* `~/.ccam/secrets.env`, parsed with the same literal `KEY=VALUE` reader
* `lane-profile.js` uses for `profile.env` (config is parsed, never sourced).
*
* Never returned by any route: `GET /runtime` may report which keys are
* present, never their values.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { parseEnvFile } = require("./lane-profile");
const SECRETS_PATH =
process.env.CCAM_SECRETS_PATH || path.join(os.homedir(), ".ccam", "secrets.env");
/** Every declaration a lane's database/Redis facts can rely on when the file
* is absent or unreadable — a local default stack, not a guess. */
const DEFAULTS = Object.freeze({
PG_HOST: "127.0.0.1",
PG_PORT: "5432",
PG_USER: "postgres",
PG_PASS: "postgres",
REDIS_HOST: "127.0.0.1",
REDIS_PORT: "6379",
});
let warnedMissing = false;
let warnedPerms = false;
/**
* Read `~/.ccam/secrets.env`, merged over DEFAULTS.
*
* Never throws: a missing file warns once and falls back to DEFAULTS (a lane
* with no secrets file still gets a usable local Postgres/Redis target), and a
* file readable by group or world is refused outright rather than trusted —
* loading it would make CCAM the thing that taught a shared machine's other
* users the database password.
*
* @returns {Record<string,string>}
*/
function readSecrets() {
if (!fs.existsSync(SECRETS_PATH)) {
if (!warnedMissing) {
warnedMissing = true;
console.warn(
`[secrets] no ${SECRETS_PATH} — per-lane databases use built-in defaults ` +
`(${DEFAULTS.PG_HOST}:${DEFAULTS.PG_PORT})`
);
}
return { ...DEFAULTS };
}
const mode = fs.statSync(SECRETS_PATH).mode & 0o777;
if (mode & 0o077) {
if (!warnedPerms) {
warnedPerms = true;
console.warn(
`[secrets] ${SECRETS_PATH} is readable by group or world (mode ${mode.toString(8)}) ` +
`— refusing to load it. Fix with: chmod 600 ${SECRETS_PATH}`
);
}
return { ...DEFAULTS };
}
try {
return { ...DEFAULTS, ...parseEnvFile(fs.readFileSync(SECRETS_PATH, "utf8")) };
} catch {
return { ...DEFAULTS }; // unreadable file is the same as no file
}
}
module.exports = { SECRETS_PATH, DEFAULTS, readSecrets };