From 31f984c750fe278486c7f1316ae39df6d64bebb0 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 17:51:03 +0700 Subject: [PATCH] feat(lanes): add --qc boot flag + QC_BOOT_ENV for deterministic QC stacks (E1) --- bin/ccam.js | 8 ++- server/__tests__/lane-profile.test.js | 92 +++++++++++++++++++++++++++ server/__tests__/lane-runtime.test.js | 64 +++++++++++++++++++ server/lib/lane-profile.js | 30 ++++++++- server/lib/lane-runtime.js | 6 +- server/routes/lanes.js | 3 +- 6 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 server/__tests__/lane-profile.test.js diff --git a/bin/ccam.js b/bin/ccam.js index d592711..ebb3155 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -1813,7 +1813,9 @@ async function cmdLanesRuntime(sub, args) { } if (sub === "up") { - const body = laneArgs.includes("--no-build") ? { build: false } : {}; + const body = {}; + if (laneArgs.includes("--no-build")) body.build = false; + if (laneArgs.includes("--qc")) body.qc = true; const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true }); if (result.status) { console.error(`✖ up lane #${laneId} → ${result.data?.error?.message || result.status}`); @@ -2258,8 +2260,8 @@ const COMMAND_GROUPS = [ ], [ "lanes up|down", - "[] [--no-build]", - "Boot or stop the lane's own app stack (--no-build: up only; id defaults to the lane owning this directory)", + "[] [--no-build] [--qc]", + "Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)", ], ["lanes runtime", "[]", "Slot, ports, service health and the last boot error"], ["lanes logs", "[] [--tail N]", "Tail one of the lane's hook or service logs"], diff --git a/server/__tests__/lane-profile.test.js b/server/__tests__/lane-profile.test.js new file mode 100644 index 0000000..78ae281 --- /dev/null +++ b/server/__tests__/lane-profile.test.js @@ -0,0 +1,92 @@ +/** + * @file Tests for profile parsing and hook environment setup. + * @author Nguyễn Ngọc Trí Vĩ + */ + +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-profile-")); +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"); + +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, `profile-lane-cwd-${laneSeq}`); + fsMod.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ + title: `profile-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; +} + +describe("parseQcBootEnv", () => { + it("parses space-separated KEY=value pairs", () => { + assert.deepEqual(profileLib.parseQcBootEnv("MOCK_PAYMENTS=1 STUB_EMAIL=1"), { + MOCK_PAYMENTS: "1", + STUB_EMAIL: "1", + }); + }); + + it("returns an empty object for an empty or missing declaration", () => { + assert.deepEqual(profileLib.parseQcBootEnv(""), {}); + assert.deepEqual(profileLib.parseQcBootEnv(undefined), {}); + }); + + it("ignores a malformed token with no =", () => { + assert.deepEqual(profileLib.parseQcBootEnv("GOOD=1 malformed"), { GOOD: "1" }); + }); +}); + +describe("hookEnv extraEnv", () => { + it("merges extraEnv on top of everything else, including profile.env", () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=api\n"); + slots.allocateSlot(lane.id); + const current = lanesLib.getLane(lane.id); + const profile = profileLib.resolveProfile(current); + const env = profileLib.hookEnv(current, profile, { PORTS: "overridden" }); + assert.equal(env.PORTS, "overridden"); + slots.releaseSlot(lane.id); + }); + + it("defaults extraEnv to nothing when omitted (existing callers unaffected)", () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=api\n"); + slots.allocateSlot(lane.id); + const current = lanesLib.getLane(lane.id); + const profile = profileLib.resolveProfile(current); + const env = profileLib.hookEnv(current, profile); + assert.equal(env.LANE_ID, String(current.id)); + slots.releaseSlot(lane.id); + }); +}); diff --git a/server/__tests__/lane-runtime.test.js b/server/__tests__/lane-runtime.test.js index 056a58a..b17e36c 100644 --- a/server/__tests__/lane-runtime.test.js +++ b/server/__tests__/lane-runtime.test.js @@ -354,6 +354,70 @@ describe("lifecycle", () => { }); }); +describe("upLane qc option", () => { + it("injects QC_BOOT_ENV into the boot hook's environment when qc:true", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19300\nQC_BOOT_ENV=SOME_QC_VAR=from-qc\n", { + boot: [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'echo "QC=$SOME_QC_VAR" > "$LANE_DIR/qc-marker.txt"', + 'harness_spawn web "$LANE_DIR" python3 -m http.server "$WEB_PORT" --bind 127.0.0.1', + "", + ].join("\n"), + health: [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "for _ in $(seq 1 50); do", + ' if curl -sf "http://127.0.0.1:$WEB_PORT/" >/dev/null; then exit 0; fi', + " sleep 0.2", + "done", + "exit 1", + "", + ].join("\n"), + }); + + await runtime.upLane(lanesLib.getLane(lane.id), { qc: true }); + const booted = lanesLib.getLane(lane.id); + const markerPath = pathMod.join(booted.cwd, "qc-marker.txt"); + const marker = fsMod.readFileSync(markerPath, "utf8"); + assert.match(marker, /QC=from-qc/); + + slots.releaseSlot(lane.id); + }); + + it("does not touch the environment when qc is omitted (default false)", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19400\nQC_BOOT_ENV=SOME_QC_VAR=from-qc\n", { + boot: [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'echo "QC=${SOME_QC_VAR:-not-set}" > "$LANE_DIR/qc-marker-no-qc.txt"', + 'harness_spawn web "$LANE_DIR" python3 -m http.server "$WEB_PORT" --bind 127.0.0.1', + "", + ].join("\n"), + health: [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "for _ in $(seq 1 50); do", + ' if curl -sf "http://127.0.0.1:$WEB_PORT/" >/dev/null; then exit 0; fi', + " sleep 0.2", + "done", + "exit 1", + "", + ].join("\n"), + }); + + await runtime.upLane(lanesLib.getLane(lane.id)); + const booted = lanesLib.getLane(lane.id); + const markerPath = pathMod.join(booted.cwd, "qc-marker-no-qc.txt"); + const marker = fsMod.readFileSync(markerPath, "utf8"); + assert.match(marker, /QC=not-set/); + + slots.releaseSlot(lane.id); + }); +}); + describe("allocation is not client-patchable", () => { it("ignores slot and ports coming through updateLane", () => { const lane = makeLane(); diff --git a/server/lib/lane-profile.js b/server/lib/lane-profile.js index d02cf65..8bdd3b1 100644 --- a/server/lib/lane-profile.js +++ b/server/lib/lane-profile.js @@ -71,6 +71,11 @@ const DEFAULTS = Object.freeze({ ENV_REWRITE: "", ENV_PRESERVE: "", UPLOAD_SUBDIR: "", + // E1: space-separated KEY=value pairs injected into the BOOT hook's + // environment only, only when `up` is called with qc:true — the deterministic + // stack `ship-feature-lane`'s Stage 3 boots for QC. Empty = off, same as + // every declaration above. + QC_BOOT_ENV: "", }); /** @@ -110,6 +115,25 @@ function splitList(value) { return [...new Set((value || "").split(/\s+/).filter(Boolean))]; } +/** + * Parse a `QC_BOOT_ENV` declaration — space-separated `KEY=value` pairs — into + * a plain object. A token with no `=` is dropped rather than throwing: a + * malformed declaration should degrade to "that one pair is missing", not + * crash a boot. + * + * @param {string} [value] + * @returns {Record} + */ +function parseQcBootEnv(value) { + const out = {}; + for (const token of splitList(value)) { + const eq = token.indexOf("="); + if (eq <= 0) continue; + out[token.slice(0, eq)] = token.slice(eq + 1); + } + return out; +} + /** * Find and read a lane's profile. * @@ -204,7 +228,7 @@ die() { echo "profile: $*" >&2; exit 1; } * 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) { +function hookEnv(lane, profile, extraEnv = {}) { const { readSecrets } = require("./secrets"); const dirs = slotDirs(lane.slot); const secrets = readSecrets(); @@ -265,6 +289,7 @@ function hookEnv(lane, profile) { env[`${name.toUpperCase()}_PORT`] = String(port); } + Object.assign(env, extraEnv); return env; } @@ -339,7 +364,7 @@ function runHook(lane, profile, name, args = [], options = {}) { ], { cwd: lane.cwd, - env: hookEnv(lane, profile), + env: hookEnv(lane, profile, options.extraEnv), stdio: ["ignore", "pipe", "pipe"], } ); @@ -390,6 +415,7 @@ module.exports = { PROFILE_SUBDIR, parseEnvFile, splitList, + parseQcBootEnv, resolveProfile, profileSearchPaths, hookEnv, diff --git a/server/lib/lane-runtime.js b/server/lib/lane-runtime.js index 7af9985..6551b2f 100644 --- a/server/lib/lane-runtime.js +++ b/server/lib/lane-runtime.js @@ -41,7 +41,7 @@ const { portBase, dbName, } = require("./lane-slots"); -const { resolveProfile, profileSearchPaths, runHook } = require("./lane-profile"); +const { resolveProfile, profileSearchPaths, parseQcBootEnv, runHook } = require("./lane-profile"); const { isListening, listenerPids } = require("./ports"); const { seedEnv } = require("./lane-env"); const { ensureDatabase, dropDatabase } = require("./lane-services"); @@ -343,7 +343,7 @@ async function removeLaneData(lane, profile, options = {}) { */ async function upLane(lane, options = {}) { const profile = requireProfile(lane); - const { build = true, onLine } = options; + const { build = true, qc = false, onLine } = options; let current = lane; if (!current.slot) { @@ -374,9 +374,11 @@ async function upLane(lane, options = {}) { await runRequiredHook(current, profile, "migrate", { onLine }, "EMIGRATEFAILED"); if (db.created) await runRequiredHook(current, profile, "seed", { onLine }, "ESEEDFAILED"); + const bootExtraEnv = qc ? parseQcBootEnv(profile.env.QC_BOOT_ENV) : undefined; const boot = await runHook(current, profile, "boot", build ? [] : ["--no-build"], { onLine, timeoutMs: BOOT_TIMEOUT_MS, + extraEnv: bootExtraEnv, }); if (boot.code !== 0) { throw Object.assign(new Error(`boot hook exited ${boot.code}`), { diff --git a/server/routes/lanes.js b/server/routes/lanes.js index afec3d7..ea265d3 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -676,13 +676,14 @@ router.post("/:id/up", sameOriginGuard, (req, res) => { } const build = req.body?.build !== false; + const qc = req.body?.qc === true; res.status(202).json({ ok: true, laneId: lane.id }); void withLaneLock(lane.id, async () => { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: lane.id, hook: "up", stream, line }); try { - const facts = await upLane(lanesLib.getLane(lane.id), { build, onLine }); + const facts = await upLane(lanesLib.getLane(lane.id), { build, qc, onLine }); broadcast("lane_runtime", { laneId: lane.id, runtime: facts }); } catch (err) { broadcast("lane_runtime", {