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 };