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