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