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,370 @@
|
||||
/**
|
||||
* @file Tests for A2 data isolation: machine-level secrets, per-lane database
|
||||
* lifecycle (create once / reset / drop), and `.env` seeding.
|
||||
*
|
||||
* The database lifecycle tests use a SQLite-shaped fixture hook (write a file,
|
||||
* remove a file) rather than a real Postgres — the property worth proving is
|
||||
* CCAM's own orchestration (when a hook runs, what name it is trusted with,
|
||||
* that a repeat call is a no-op), not any particular database engine.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const pathMod = require("node:path");
|
||||
const fsMod = require("node:fs");
|
||||
|
||||
const SUITE_ROOT = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "ccam-data-"));
|
||||
process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db");
|
||||
process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes");
|
||||
process.env.CCAM_SECRETS_PATH = pathMod.join(SUITE_ROOT, "secrets.env");
|
||||
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const slots = require("../lib/lane-slots");
|
||||
const profileLib = require("../lib/lane-profile");
|
||||
const runtime = require("../lib/lane-runtime");
|
||||
const envLib = require("../lib/lane-env");
|
||||
const servicesLib = require("../lib/lane-services");
|
||||
const secretsLib = require("../lib/secrets");
|
||||
|
||||
after(() => fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
let laneSeq = 0;
|
||||
/** A managed lane row backed by a real directory. */
|
||||
function makeLane(over = {}) {
|
||||
laneSeq += 1;
|
||||
const cwd = pathMod.join(SUITE_ROOT, `data-lane-cwd-${laneSeq}`);
|
||||
fsMod.mkdirSync(cwd, { recursive: true });
|
||||
return lanesLib.createLane({
|
||||
title: `data-lane ${laneSeq}`,
|
||||
cwd,
|
||||
kind: "managed",
|
||||
source_repo: cwd,
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
/** Write a profile into a repo directory. `hooks` maps name -> script body. */
|
||||
function writeProfile(root, envText, hooks = {}) {
|
||||
const dir = pathMod.join(root, ".ccam", "profile");
|
||||
fsMod.mkdirSync(pathMod.join(dir, "hooks"), { recursive: true });
|
||||
fsMod.writeFileSync(pathMod.join(dir, "profile.env"), envText);
|
||||
for (const [name, body] of Object.entries(hooks)) {
|
||||
const file = pathMod.join(dir, "hooks", `${name}.sh`);
|
||||
fsMod.writeFileSync(file, body);
|
||||
fsMod.chmodSync(file, 0o755);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// A file-based "database" so the create/drop lifecycle is provable without a
|
||||
// real engine: db-create WRITES (so a wrongly-repeated call would clobber
|
||||
// existing content, proving the once-only marker actually matters), db-drop
|
||||
// removes.
|
||||
const SQLITE_DB_HOOKS = {
|
||||
"db-create":
|
||||
'#!/usr/bin/env bash\nset -euo pipefail\necho created > "$LANE_DIR/${1:-$DB_NAME}.db"\n',
|
||||
"db-drop": '#!/usr/bin/env bash\nset -euo pipefail\nrm -f "$LANE_DIR/${1:-$DB_NAME}.db"\n',
|
||||
};
|
||||
|
||||
describe("secrets", () => {
|
||||
const secretsPath = process.env.CCAM_SECRETS_PATH;
|
||||
|
||||
it("falls back to defaults when the file is absent", () => {
|
||||
assert.equal(fsMod.existsSync(secretsPath), false);
|
||||
const s = secretsLib.readSecrets();
|
||||
assert.equal(s.PG_HOST, "127.0.0.1");
|
||||
assert.equal(s.PG_USER, "postgres");
|
||||
});
|
||||
|
||||
it("refuses a group/world-readable secrets file, falling back to defaults", () => {
|
||||
fsMod.writeFileSync(secretsPath, "PG_USER=leaked-user\n");
|
||||
fsMod.chmodSync(secretsPath, 0o644);
|
||||
const s = secretsLib.readSecrets();
|
||||
assert.equal(s.PG_USER, "postgres");
|
||||
});
|
||||
|
||||
it("loads a mode-0600 file", () => {
|
||||
fsMod.writeFileSync(secretsPath, "PG_USER=custom-user\nPG_PASS=custom-pass\n");
|
||||
fsMod.chmodSync(secretsPath, 0o600);
|
||||
const s = secretsLib.readSecrets();
|
||||
assert.equal(s.PG_USER, "custom-user");
|
||||
assert.equal(s.PG_PASS, "custom-pass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lane-env seedEnv", () => {
|
||||
it("rewrites ENV_REWRITE keys, keeping every other line byte-identical", () => {
|
||||
const source = pathMod.join(SUITE_ROOT, "env-source-repo");
|
||||
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
|
||||
fsMod.writeFileSync(
|
||||
pathMod.join(source, "backend", ".env"),
|
||||
"FOO=bar\nDATABASE_URL=stale\nBAZ=qux\n"
|
||||
);
|
||||
const lane = makeLane({ source_repo: source });
|
||||
writeProfile(
|
||||
lane.cwd,
|
||||
["DB_PREFIX=envtest_l", "ENV_FILES=backend/.env", "ENV_REWRITE=DATABASE_URL"].join("\n")
|
||||
);
|
||||
slots.allocateSlot(lane.id);
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const profile = profileLib.resolveProfile(current);
|
||||
|
||||
envLib.seedEnv(current, profile, secretsLib.readSecrets());
|
||||
|
||||
const lines = fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8").split("\n");
|
||||
assert.equal(lines[0], "FOO=bar");
|
||||
assert.equal(lines[2], "BAZ=qux");
|
||||
assert.match(lines[1], /^DATABASE_URL=postgresql:\/\//);
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("--force preserves ENV_PRESERVE keys from the lane's own existing file", () => {
|
||||
const source = pathMod.join(SUITE_ROOT, "env-source-repo-2");
|
||||
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
|
||||
fsMod.writeFileSync(
|
||||
pathMod.join(source, "backend", ".env"),
|
||||
"JWT_SECRET=source-secret\nOTHER=from-source\n"
|
||||
);
|
||||
const lane = makeLane({ source_repo: source });
|
||||
writeProfile(lane.cwd, ["ENV_FILES=backend/.env", "ENV_PRESERVE=JWT_SECRET"].join("\n"));
|
||||
slots.allocateSlot(lane.id);
|
||||
let current = lanesLib.getLane(lane.id);
|
||||
const profile = profileLib.resolveProfile(current);
|
||||
|
||||
envLib.seedEnv(current, profile, secretsLib.readSecrets());
|
||||
fsMod.writeFileSync(
|
||||
pathMod.join(current.cwd, "backend/.env"),
|
||||
"JWT_SECRET=lane-own-secret\nOTHER=from-source\n"
|
||||
);
|
||||
|
||||
envLib.seedEnv(current, profile, secretsLib.readSecrets(), { force: true });
|
||||
const text = fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8");
|
||||
assert.match(text, /JWT_SECRET=lane-own-secret/);
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("falls back to .env.example when the source .env is missing", () => {
|
||||
const source = pathMod.join(SUITE_ROOT, "env-source-repo-3");
|
||||
fsMod.mkdirSync(pathMod.join(source, "backend"), { recursive: true });
|
||||
fsMod.writeFileSync(pathMod.join(source, "backend", ".env.example"), "TEMPLATE=1\n");
|
||||
const lane = makeLane({ source_repo: source });
|
||||
writeProfile(lane.cwd, "ENV_FILES=backend/.env\n");
|
||||
slots.allocateSlot(lane.id);
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const profile = profileLib.resolveProfile(current);
|
||||
|
||||
const result = envLib.seedEnv(current, profile, secretsLib.readSecrets());
|
||||
assert.equal(result.seeded[0].fromExample, true);
|
||||
assert.match(
|
||||
fsMod.readFileSync(pathMod.join(current.cwd, "backend/.env"), "utf8"),
|
||||
/TEMPLATE=1/
|
||||
);
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("throws ENOTMANAGED for an adopted lane", () => {
|
||||
const lane = makeLane({ kind: "adopted" });
|
||||
writeProfile(lane.cwd, "ENV_FILES=backend/.env\n");
|
||||
assert.throws(
|
||||
() =>
|
||||
envLib.seedEnv(
|
||||
lanesLib.getLane(lane.id),
|
||||
profileLib.resolveProfile(lanesLib.getLane(lane.id)),
|
||||
secretsLib.readSecrets()
|
||||
),
|
||||
(err) => err.code === "ENOTMANAGED"
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op when ENV_FILES is not declared", () => {
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "DB_PREFIX=noenv_l\n");
|
||||
const result = envLib.seedEnv(lane, profileLib.resolveProfile(lane), secretsLib.readSecrets());
|
||||
assert.equal(result.skipped, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lane-services database guard", () => {
|
||||
it("refuses to drop a name that isn't this lane's own derived one, before spawning anything", async () => {
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "DB_PREFIX=guard_l\n", SQLITE_DB_HOOKS);
|
||||
slots.allocateSlot(lane.id);
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const profile = profileLib.resolveProfile(current);
|
||||
|
||||
await assert.rejects(
|
||||
() => servicesLib.dropDatabase(current, profile, "someone-elses-db"),
|
||||
(err) => err.code === "EBADDBNAME"
|
||||
);
|
||||
assert.equal(fsMod.existsSync(pathMod.join(current.cwd, "someone-elses-db.db")), false);
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("throws ENOTMANAGED for an adopted lane's own derived name", async () => {
|
||||
const lane = makeLane({ kind: "adopted" });
|
||||
writeProfile(lane.cwd, "DB_PREFIX=guard2_l\n", SQLITE_DB_HOOKS);
|
||||
slots.allocateSlot(lane.id);
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const profile = profileLib.resolveProfile(current);
|
||||
|
||||
await assert.rejects(
|
||||
() => servicesLib.dropDatabase(current, profile, slots.dbName(profile, current.slot)),
|
||||
(err) => err.code === "ENOTMANAGED"
|
||||
);
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("creates a database once, tracked by a marker so a repeat call is a no-op", async () => {
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "DB_PREFIX=once_l\n", SQLITE_DB_HOOKS);
|
||||
slots.allocateSlot(lane.id);
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const profile = profileLib.resolveProfile(current);
|
||||
|
||||
const first = await servicesLib.ensureDatabase(current, profile, {});
|
||||
assert.equal(first.created, true);
|
||||
const dbFile = pathMod.join(current.cwd, `once_l${current.slot}.db`);
|
||||
fsMod.writeFileSync(dbFile, "sentinel");
|
||||
|
||||
const second = await servicesLib.ensureDatabase(current, profile, {});
|
||||
assert.equal(second.created, false);
|
||||
assert.equal(fsMod.readFileSync(dbFile, "utf8"), "sentinel", "hook must not re-run");
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("is a no-op when DB_PREFIX is not declared", async () => {
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "", SQLITE_DB_HOOKS);
|
||||
slots.allocateSlot(lane.id);
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const result = await servicesLib.ensureDatabase(
|
||||
current,
|
||||
profileLib.resolveProfile(current),
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(result, { name: null, created: false });
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lane data lifecycle: provision / reset / remove", () => {
|
||||
it("provision creates the database; reset recreates it; remove drops it", async () => {
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "DB_PREFIX=lifecycle_l\n", SQLITE_DB_HOOKS);
|
||||
|
||||
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
|
||||
const dbFile = pathMod.join(provisioned.cwd, `lifecycle_l${provisioned.slot}.db`);
|
||||
assert.ok(fsMod.existsSync(dbFile), "provision creates the database");
|
||||
|
||||
fsMod.writeFileSync(dbFile, "old-feature-data");
|
||||
const profile = profileLib.resolveProfile(provisioned);
|
||||
await runtime.resetLaneData(lanesLib.getLane(lane.id), profile, {});
|
||||
assert.equal(
|
||||
fsMod.readFileSync(dbFile, "utf8").trim(),
|
||||
"created",
|
||||
"reset drops and recreates, losing the old content"
|
||||
);
|
||||
|
||||
await runtime.removeLaneData(lanesLib.getLane(lane.id), profile, {});
|
||||
assert.equal(fsMod.existsSync(dbFile), false, "remove drops the database");
|
||||
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("--keep-db leaves the database untouched across reset", async () => {
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "DB_PREFIX=keepdb_l\n", SQLITE_DB_HOOKS);
|
||||
|
||||
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
|
||||
const dbFile = pathMod.join(provisioned.cwd, `keepdb_l${provisioned.slot}.db`);
|
||||
fsMod.writeFileSync(dbFile, "must-survive");
|
||||
|
||||
const profile = profileLib.resolveProfile(provisioned);
|
||||
await runtime.resetLaneData(lanesLib.getLane(lane.id), profile, { keepDb: true });
|
||||
assert.equal(fsMod.readFileSync(dbFile, "utf8"), "must-survive");
|
||||
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("removeLaneData never touches an adopted lane's database", async () => {
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "DB_PREFIX=adopted_l\n", SQLITE_DB_HOOKS);
|
||||
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
|
||||
const dbFile = pathMod.join(provisioned.cwd, `adopted_l${provisioned.slot}.db`);
|
||||
assert.ok(fsMod.existsSync(dbFile));
|
||||
|
||||
lanesLib.setProvisioningFacts(lane.id, { kind: "adopted" });
|
||||
const adoptedLane = lanesLib.getLane(lane.id);
|
||||
const profile = profileLib.resolveProfile(adoptedLane);
|
||||
await runtime.removeLaneData(adoptedLane, profile, {});
|
||||
assert.ok(fsMod.existsSync(dbFile), "an adopted lane's data is never CCAM's to destroy");
|
||||
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("isolates two lanes' databases from each other", async () => {
|
||||
const a = makeLane();
|
||||
const b = makeLane();
|
||||
writeProfile(a.cwd, "DB_PREFIX=iso_l\n", SQLITE_DB_HOOKS);
|
||||
writeProfile(b.cwd, "DB_PREFIX=iso_l\n", SQLITE_DB_HOOKS);
|
||||
|
||||
const pa = await runtime.provisionLane(lanesLib.getLane(a.id));
|
||||
const pb = await runtime.provisionLane(lanesLib.getLane(b.id));
|
||||
assert.notEqual(pa.slot, pb.slot);
|
||||
|
||||
const dbA = pathMod.join(pa.cwd, `iso_l${pa.slot}.db`);
|
||||
const dbB = pathMod.join(pb.cwd, `iso_l${pb.slot}.db`);
|
||||
fsMod.writeFileSync(dbA, "rows-for-a");
|
||||
fsMod.writeFileSync(dbB, "rows-for-b");
|
||||
assert.notEqual(dbA, dbB);
|
||||
assert.notEqual(fsMod.readFileSync(dbA, "utf8"), fsMod.readFileSync(dbB, "utf8"));
|
||||
|
||||
slots.releaseSlot(a.id);
|
||||
slots.releaseSlot(b.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtimeFacts never leaks a secret value", () => {
|
||||
it("reports only names and an index, never a connection string", async () => {
|
||||
fsMod.writeFileSync(process.env.CCAM_SECRETS_PATH, "PG_PASS=super-secret-password\n");
|
||||
fsMod.chmodSync(process.env.CCAM_SECRETS_PATH, 0o600);
|
||||
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "DB_PREFIX=leak_l\nREDIS=1\n", SQLITE_DB_HOOKS);
|
||||
const provisioned = await runtime.provisionLane(lanesLib.getLane(lane.id));
|
||||
|
||||
const facts = await runtime.runtimeFacts(lanesLib.getLane(provisioned.id));
|
||||
assert.equal(facts.database.name, `leak_l${provisioned.slot}`);
|
||||
assert.equal(facts.redisIndex, provisioned.slot);
|
||||
assert.doesNotMatch(JSON.stringify(facts), /super-secret-password/);
|
||||
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
|
||||
it("redacts a hook echoing $DATABASE_URL, including its URL-encoded form", async () => {
|
||||
// A password with a character that changes shape once URL-encoded, so
|
||||
// both the raw and encoded substrings actually get exercised.
|
||||
fsMod.writeFileSync(process.env.CCAM_SECRETS_PATH, "PG_PASS=p@ss/word\n");
|
||||
fsMod.chmodSync(process.env.CCAM_SECRETS_PATH, 0o600);
|
||||
|
||||
const lane = makeLane();
|
||||
writeProfile(lane.cwd, "DB_PREFIX=echo_l\n", {
|
||||
"db-create": '#!/usr/bin/env bash\nset -euo pipefail\necho "connecting to $DATABASE_URL"\n',
|
||||
});
|
||||
slots.allocateSlot(lane.id);
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const profile = profileLib.resolveProfile(current);
|
||||
|
||||
const result = await profileLib.runHook(current, profile, "db-create", [
|
||||
slots.dbName(profile, current.slot),
|
||||
]);
|
||||
assert.doesNotMatch(result.output, /p@ss\/word/);
|
||||
assert.match(result.output, /\[REDACTED\]/);
|
||||
assert.doesNotMatch(fsMod.readFileSync(result.logPath, "utf8"), /p@ss\/word/);
|
||||
|
||||
slots.releaseSlot(lane.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user