From 9d145865dd7430da55ed86b1b68df44b44368db0 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 10:03:40 +0700 Subject: [PATCH] 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. --- ...026-08-03-lane-runtime-isolation-design.md | 169 ++++++ openapi.yaml | 155 +++++ server/__tests__/lane-data.test.js | 370 ++++++++++++ server/__tests__/lane-runtime.test.js | 369 ++++++++++++ .../postgres-compose/hooks/db-create.sh | 29 + .../postgres-compose/hooks/db-drop.sh | 19 + .../postgres-compose/profile.env.example | 23 + server/db.js | 26 + server/lib/lane-env.js | 150 +++++ server/lib/lane-preflight.js | 11 + server/lib/lane-profile.js | 397 +++++++++++++ server/lib/lane-runtime.js | 532 ++++++++++++++++++ server/lib/lane-services.js | 98 ++++ server/lib/lane-slots.js | 245 ++++++++ server/lib/lanes.js | 78 ++- server/lib/ports.js | 116 ++++ server/lib/secrets.js | 79 +++ server/openapi-extra/lanes.js | 144 +++++ server/routes/lanes.js | 267 ++++++++- 19 files changed, 3265 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-03-lane-runtime-isolation-design.md create mode 100644 server/__tests__/lane-data.test.js create mode 100644 server/__tests__/lane-runtime.test.js create mode 100755 server/data/profile-templates/postgres-compose/hooks/db-create.sh create mode 100755 server/data/profile-templates/postgres-compose/hooks/db-drop.sh create mode 100644 server/data/profile-templates/postgres-compose/profile.env.example create mode 100644 server/lib/lane-env.js create mode 100644 server/lib/lane-profile.js create mode 100644 server/lib/lane-runtime.js create mode 100644 server/lib/lane-services.js create mode 100644 server/lib/lane-slots.js create mode 100644 server/lib/ports.js create mode 100644 server/lib/secrets.js diff --git a/docs/superpowers/specs/2026-08-03-lane-runtime-isolation-design.md b/docs/superpowers/specs/2026-08-03-lane-runtime-isolation-design.md new file mode 100644 index 0000000..f7aa22d --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-lane-runtime-isolation-design.md @@ -0,0 +1,169 @@ +# A1 — Lane runtime isolation: slots, ports, profile hooks, detached lifecycle + +**Status:** approved 2026-08-03. Roadmap it belongs to: +`docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`. + +## Problem + +A lane isolates **code** and nothing else. `server/lib/worktree.js` gives each lane +its own git worktree and branch, so two agents never overwrite each other's files — +but they share every other resource: the same ports, the same database, the same +`.env`, the same upload directory. Booting two lanes' stacks at once does not work. + +Shipyard (`AgentWorkflow/`) solved this with per-lane ports, database, Redis logical +index and `.env`. That is **not** containerization — every lane runs on the host as +the same user. Its isolation is *resource namespacing at the application layer*. + +## Scope + +Shipyard parity is six independent subsystems (see the roadmap). A — runtime +isolation — goes first because it blocks the rest, and splits into three: + +- **A1 (this spec)** — a lane can boot its stack on isolated ports and directories, + and the stack survives a dashboard restart. Profiles are hand-written. +- **A2** — data isolation: `.env` seeding, database create/drop/migrate/seed, Redis index. +- **A3** — stack detection + `ccam lane profile init` scaffolding, designed **from** + the real profiles written during A1/A2. + +The split point matters. Shipyard was genericized but its `profiles/` is still +empty (`PROFILE=_template`, `SOURCE_REPO=""`, `state/` empty) — a clean seam that +never ran against a real app. Designing presets before a real profile exists +repeats exactly that. + +**A1 done means:** `ccam lane up` boots a lane's stack on ports no other lane uses, +and `ccam update` (or any dashboard restart) leaves running stacks alone. + +## Decisions + +| | Choice | Rationale | +|---|---|---| +| Profile location | `/.ccam/profile/` | CCAM is multi-repo (lanes keyed by `cwd`); one global `PROFILE` is wrong the moment two repos have lanes. A profile that lives in the repo is already present in a fresh worktree. | +| Engine | Node, `server/lib/` | `execFile` with argument arrays (repo rule: never build a git/shell command as a string). Shares the SQLite DB and the websocket. Hooks stay shell scripts; the engine spawns them. | +| Resource model | Declared per repo | `PORTS="api fe worker"`, `LANE_DIRS="…"`. An app with no database declares none, so A2 generates no database hooks — no dead code. | +| Process ownership | Fully detached | pid files outside the worktree; a dashboard restart does not touch a running stack. | +| Port allocation | Base + slot, step aside on collision, record the real number | Predictable in the common case, does not hard-fail when a port is busy. | +| Adopted lanes | May `up`/`down`; every write path refused | `up` only runs what the user can already run themselves. Writes into their `cwd` (A2) are refused outright. | +| Runtime state | DB owns allocation, disk owns handles, up/down **computed on read** | A process dies to OOM, `kill`, or a reboot — caching a truth you do not control buys ghost state. Slot allocation is the opposite: fully controlled, needs to be race-free, so it belongs in the DB. | + +## Invariants + +1. **The runtime never writes `stage`, `status`, or `notes`** — only `slot` and + `ports`. In CCAM `status=running` means *an agent is working*, not *a stack is + up*; conflating the two breaks `classifyLiveness`. Boot failures surface through + `GET /runtime` and the hook log. Same reasoning as "the console never writes a + lane's stage". +2. **CCAM still does not orchestrate.** A1 adds *primitives* (`up`/`down`/`hook`) + that a session calls. The dashboard chains nothing, evaluates no gate, retries nothing. +3. **No shell command built as a string.** `execFile` + argv arrays everywhere. + Hooks are the deliberate exception — they *are* scripts, spawned as + `bash ` with a fixed argv, never interpolated. +4. **`profile.env` is parsed, never sourced.** Sourcing arbitrary shell from a repo + into the dashboard process is a code-execution path. Hooks are executed on + purpose; config is only read. +5. **Adopted lanes stay non-destroyable** (`assertManaged` in `worktree.js`). A1 + adds no bypass. + +## Design + +### Schema (`server/db.js`) + +```sql +ALTER TABLE lanes ADD COLUMN slot INTEGER; +ALTER TABLE lanes ADD COLUMN ports TEXT NOT NULL DEFAULT '{}'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_slot ON lanes(slot) WHERE slot IS NOT NULL; +``` + +Added with the existing probe-each-column-independently migration pattern. +`ports` is parsed by the same `hydrate()` branch that already handles +`stages`/`links`, corrupt-blob fallback included. + +`slot` and `ports` join `PROVISIONING_FIELDS` and stay **out of** `PATCHABLE`, for +the reason `kind` is excluded: a client that can set `slot` can change the +slot-derived `DB_NAME` A2 will use — which aims a drop at another database. + +No column records "is up". Deliberate. + +### Modules + +| File | Responsibility | +|---|---| +| `server/lib/ports.js` | `isListening(port)` via a short-timeout `net.connect`; `listenerPids(port)` via `lsof`, falling back to `ss`, returning `[]` with a one-time warning when neither exists. | +| `server/lib/lane-slots.js` | `allocateSlot()` — lowest free in `[1, LANE_MAX_SLOTS]` (default 9), called inside the existing `withLaneLock`, with the unique index as the backstop. `releaseSlot()` on `remove` only — **not** on `reset`, or a lane's ports and database move under a running session. `resolvePorts(slot, profile)` — prefers `PORT_BASE_ + slot`, then `+100`, `+200`… (the last digit stays the slot, so the number still reads as "lane 3"), up to 10 tries before `EPORTBUSY` naming the occupying pid. | +| `server/lib/lane-profile.js` | `resolveProfile(lane)` — looks in `lane.cwd/.ccam/profile/` **first**, `lane.source_repo/.ccam/profile/` second, because the profile lives in the repo and a branch that edits its hooks must run its own. `runHook(lane, name, args, {onLine})` — fixed hook-name allowlist, `bash ` with an argv array, Shipyard's env contract verbatim so its profiles port unchanged (`LANE` = the **slot**, `LANE_DIR`, `RUN_DIR`, `LOG_DIR`, `PROFILE_DIR`, `SOURCE_REPO`, `_PORT` per declared port), `harness_spawn` exported as a bash function writing the same `$RUN_DIR/.pid` + `$LOG_DIR/.log`, the `GIT_*` scrub from `worktree.js:git()`, and output streamed both to the log file and to a new `lane_hook_output` websocket message. | +| `server/lib/lane-runtime.js` | `upLane` (defensive down → mkdir run/log/`LANE_DIRS` → `boot` → `health`; no `bootstrap`, which belongs to provision/reset as in `lane-up.sh`), `downLane` (recursive pid-tree kill from the pid files — ported from `lane-down.sh:kill_tree`, which exists because detached uvicorn/celery prefork children were being missed — then a port-listener backstop; idempotent, sleep-free), `runtimeFacts` (**computed**: `kill -0` per pid file, `isListening` per port, plus `last-error.json`). | + +State lives at `LANES_ROOT/.state/lane/{run,logs}/`, **outside** the +worktree: `lane reset` runs `git clean -fd`, which would sweep pid files mid-flight. + +**Adopt-after-restart costs no code.** Pid files are on disk, detached processes +keep running, and `runtimeFacts` recomputes on read. That is the payoff of making +up/down computed rather than stored. + +### Routes (`server/routes/lanes.js`) + +``` +POST /api/lanes/:id/up { build? } 202 → background → broadcastLane +POST /api/lanes/:id/down { mcp? } 200 +GET /api/lanes/:id/runtime 200 +POST /api/lanes/:id/hook/:name { args? } 202 +GET /api/lanes/:id/logs/:svc ?tail=N 200 +``` + +All behind `sameOriginGuard` and the existing `withLaneLock`. `GET /runtime` +follows the contract of `GET /:id/git`: a lane with no profile returns +`{available:false}` with HTTP 200 — a normal state, not a fault. It probes ports +and stats pid files, which is why it is its own endpoint rather than part of the +polled `GET /api/lanes`. + +`:name` and `:svc` come from allowlists, never from the request; log paths are +`realpath`-confined to `LOG_DIR`; `args` is an array of strings passed straight to +argv, never joined. + +### Errors + +| Condition | Code | Behaviour | +|---|---|---| +| No `.ccam/profile/` | `ENOPROFILE` | 400, naming both paths searched | +| Unknown hook | `ENOHOOK` | 400 | +| Hook exits non-zero | — | `lane_hook_output` + `last-error.json`; reflected by `/runtime` | +| Every candidate port busy | `EPORTBUSY` | names the occupying pid | +| `health` fails | `EUNHEALTHY` | **leaves the processes running** — killing them destroys the evidence — and points at the log | +| Slot pool exhausted | `ESLOTS` | 409 | +| Name outside an allowlist | — | 400, nothing spawned | + +## Verification + +`npm run test:server` (required) and `npm run test:client` (review the snapshot +diff before regenerating). A fixture repo with a three-line profile: `boot.sh` +starts `python3 -m http.server $FE_PORT` through `harness_spawn`, `health.sh` curls +it with retries. + +Covered: + +- **Real lifecycle** — `up` → `/runtime` healthy with the port listening → simulate + a dashboard restart (rebuild the module) → still healthy, not rebooted → `down` → + port free, pid files gone. +- **Slots** — nine allocations yield 1..9; releasing 4 makes the next allocation 4; + concurrent allocations through `withLaneLock` never collide. +- **Port step-aside** — occupy the preferred port, assert `+100` is chosen and + recorded in `ports`. +- **Profile parsing** — missing keys fall back to defaults; no profile returns + `null` without throwing; a value containing `$(id)` stays literal and is **not** + executed. +- **Guards** — `PATCH /api/lanes/:id` ignores `slot`/`ports`; a hook name outside + the allowlist returns 400 and spawns nothing. +- **Env** — with `GIT_DIR` set on the parent, the hook does not see it. + +Manual: create a lane on ccam-lanes itself, write a three-line profile, +`ccam lane up`, open the port, restart the server, confirm the stack survived. + +## Out of scope + +`.env` seeding and databases (A2). Detection and scaffolding (A3). Per-feature +state, proof gallery, cross-lane locks, the `ship-feature` skill, integrations +(subsystems B–F). + +Container isolation is a separate design, not a later phase: hooks and MCP servers +would have to run *inside* the container, so the `cwd` a hook reports becomes a +container path and no longer matches the lane's host `cwd` — breaking +`resolveLaneByCwd`, which is what binds a session to a lane at all. diff --git a/openapi.yaml b/openapi.yaml index 5e813af..82260da 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8820,6 +8820,161 @@ paths: description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone. '404': description: Lane not found. + /api/lanes/{id}/runtime: + get: + tags: + - Lanes + summary: A lane's own application stack + description: Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .ccam/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault. + operationId: getLaneRuntime + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: available:false (no profile); available:true with provisioned:false (no slot yet); or the full facts with slot, ports, services, up, healthy, steppedAside, logs and lastError. + '404': + description: Lane not found. + /api/lanes/{id}/up: + post: + tags: + - Lanes + summary: Boot a lane's stack + description: 'Runs the profile''s boot then health hooks. Returns 202 and finishes in the background because a build can take minutes; progress streams as lane_hook_output and the attempt ends with a lane_runtime message. Does NOT run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running, because their logs are what identify the service that never came up. Writes only slot and ports on the lane row: never stage, status or notes.' + operationId: upLane + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + build: + type: boolean + default: true + description: false passes --no-build to the boot hook, reusing an existing build. + responses: + '202': + description: Accepted; the boot runs in the background. + '400': + description: ENOPROFILE — no .ccam/profile, with the paths searched. + '403': + description: The browser request was not same-origin/loopback. + '404': + description: Lane not found. + '409': + description: ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids). + /api/lanes/{id}/down: + post: + tags: + - Lanes + summary: Stop a lane's stack + description: Kills each recorded pid tree bottom-up (killing a parent first reparents its children to init, where nothing knows to look for them), then sweeps listeners on the lane's ports ONLY when a pid file existed — a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server the user started there. Idempotent, and a no-op for a lane that was never up. + operationId: downLane + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '{ok, killed: number[], runtime}.' + '403': + description: The browser request was not same-origin/loopback. + '404': + description: Lane not found. + /api/lanes/{id}/hook/{name}: + post: + tags: + - Lanes + summary: Run one of the lane profile's hooks + description: The surface a driving session uses for ci-gate, e2e, migrate and friends. Returns 202; output streams as lane_hook_output and completion arrives as lane_hook_result with the exit code. The name is checked against a fixed allowlist BEFORE anything is spawned, and args travels as an array of strings straight into argv — neither is ever joined into a command string. + operationId: runLaneHook + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: name + in: path + required: true + schema: + type: string + enum: + - bootstrap + - boot + - health + - migrate + - seed + - ci-gate + - e2e + - regen + - db-create + - db-drop + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + args: + type: array + items: + type: string + responses: + '202': + description: Accepted; the hook runs in the background. + '400': + description: ENOHOOK (name outside the allowlist) or ENOPROFILE. + '403': + description: The browser request was not same-origin/loopback. + '404': + description: Lane not found. + '409': + description: ENOSLOT — the lane has no runtime yet; bring it up first. + /api/lanes/{id}/logs/{svc}: + get: + tags: + - Lanes + summary: Tail a lane's hook or service log + description: The resolved path is confined to the lane's log directory after realpath, so a name from the request can never escape it. Read-only, so no same-origin guard. + operationId: getLaneLog + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: svc + in: path + required: true + schema: + type: string + - name: tail + in: query + required: false + schema: + type: integer + default: 65536 + maximum: 1048576 + description: Trailing bytes to return; capped at 1 MiB. + responses: + '200': + description: '{available, svc, size, truncated, text}, or {available:false} for a lane with no slot.' + '404': + description: Lane not found, or ENOLOG — no such log for this lane. /api/lanes/branches: get: tags: diff --git a/server/__tests__/lane-data.test.js b/server/__tests__/lane-data.test.js new file mode 100644 index 0000000..466408d --- /dev/null +++ b/server/__tests__/lane-data.test.js @@ -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ĩ + */ + +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); + }); +}); diff --git a/server/__tests__/lane-runtime.test.js b/server/__tests__/lane-runtime.test.js new file mode 100644 index 0000000..056a58a --- /dev/null +++ b/server/__tests__/lane-runtime.test.js @@ -0,0 +1,369 @@ +/** + * @file Tests for lane runtime isolation: slot and port allocation, profile + * parsing and hook execution, and the boot/down lifecycle. + * + * The lifecycle tests run a REAL profile whose boot hook starts a REAL server and + * whose health hook polls it, because the properties worth proving are exactly + * the ones a mock cannot show: that a service detached by `harness_spawn` keeps + * listening after the hook exits, that runtime facts recomputed from scratch still + * find it (the "survives a dashboard restart" claim), and that `downLane` reaches + * a process it never spawned directly. + * @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-runtime-")); +process.env.DASHBOARD_DB_PATH = pathMod.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = pathMod.join(SUITE_ROOT, "lanes"); + +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); +const net = require("node:net"); + +const lanesLib = require("../lib/lanes"); +const slots = require("../lib/lane-slots"); +const profileLib = require("../lib/lane-profile"); +const runtime = require("../lib/lane-runtime"); +const { isListening } = require("../lib/ports"); + +after(() => fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true })); + +let laneSeq = 0; +/** A lane row backed by a real directory, so profile lookup and mkdir work. */ +function makeLane(over = {}) { + laneSeq += 1; + const cwd = pathMod.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); + fsMod.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed", ...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; +} + +/** Hold a port open so the allocator has to step aside. */ +function occupy(port) { + const server = net.createServer(() => {}); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +describe("profile parsing", () => { + it("keeps command substitution literal instead of executing it", () => { + const parsed = profileLib.parseEnvFile( + ['PORTS="api fe"', "export EVIL=$(id)", "TICK=`whoami`", "# comment", "", "BARE=plain"].join( + "\n" + ) + ); + assert.equal(parsed.PORTS, "api fe"); + assert.equal(parsed.EVIL, "$(id)"); + assert.equal(parsed.TICK, "`whoami`"); + assert.equal(parsed.BARE, "plain"); + assert.equal(parsed["# comment"], undefined); + }); + + it("fills defaults for omitted declarations", () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=web\n"); + const profile = profileLib.resolveProfile(lane); + assert.deepEqual(profile.ports, ["web"]); + assert.equal(profile.env.PORT_BASE_fe, "3000"); + assert.equal(profile.env.BACKEND_DIR, "backend"); + }); + + it("returns null when no profile exists, without throwing", () => { + const lane = makeLane(); + assert.equal(profileLib.resolveProfile(lane), null); + }); + + it("prefers the lane's own working copy over the source repo", () => { + const source = pathMod.join(SUITE_ROOT, "source-repo"); + fsMod.mkdirSync(source, { recursive: true }); + writeProfile(source, "PORTS=fromsource\n"); + const lane = makeLane({ source_repo: source }); + writeProfile(lane.cwd, "PORTS=fromlane\n"); + assert.deepEqual(profileLib.resolveProfile(lane).ports, ["fromlane"]); + }); + + it("falls back to the source repo when the worktree has none", () => { + const source = pathMod.join(SUITE_ROOT, "source-repo-2"); + fsMod.mkdirSync(source, { recursive: true }); + writeProfile(source, "PORTS=fromsource\n"); + const lane = makeLane({ source_repo: source }); + assert.deepEqual(profileLib.resolveProfile(lane).ports, ["fromsource"]); + }); +}); + +describe("slot allocation", () => { + it("hands out the lowest free slot and reuses a released one", () => { + const made = []; + for (let i = 0; i < 4; i += 1) { + const lane = makeLane(); + slots.allocateSlot(lane.id); + made.push(lanesLib.getLane(lane.id)); + } + assert.deepEqual( + made.map((lane) => lane.slot), + [1, 2, 3, 4] + ); + + slots.releaseSlot(made[1].id); + assert.equal(lanesLib.getLane(made[1].id).slot, null); + + const next = makeLane(); + assert.equal(slots.allocateSlot(next.id), 2); + + for (const lane of [...made, next]) slots.releaseSlot(lane.id); + }); + + it("refuses to allocate past the configured ceiling", () => { + const previous = process.env.LANE_MAX_SLOTS; + process.env.LANE_MAX_SLOTS = "2"; + const made = [makeLane(), makeLane(), makeLane()]; + slots.allocateSlot(made[0].id); + slots.allocateSlot(made[1].id); + assert.throws( + () => slots.allocateSlot(made[2].id), + (err) => err.code === "ESLOTS" + ); + for (const lane of made) slots.releaseSlot(lane.id); + if (previous === undefined) delete process.env.LANE_MAX_SLOTS; + else process.env.LANE_MAX_SLOTS = previous; + }); +}); + +describe("port allocation", () => { + it("derives base + slot when the port is free", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18500\n"); + slots.allocateSlot(lane.id); + const current = lanesLib.getLane(lane.id); + const ports = await slots.resolvePorts(current, profileLib.resolveProfile(current)); + assert.equal(ports.api, 18500 + current.slot); + slots.releaseSlot(lane.id); + }); + + it("steps aside by 100 when the preferred port is taken, keeping the slot digit", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18600\n"); + slots.allocateSlot(lane.id); + const current = lanesLib.getLane(lane.id); + const held = await occupy(18600 + current.slot); + try { + const ports = await slots.resolvePorts(current, profileLib.resolveProfile(current)); + assert.equal(ports.api, 18700 + current.slot); + assert.equal(ports.api % 10, current.slot % 10); + } finally { + held.close(); + slots.releaseSlot(lane.id); + } + }); + + it("never reuses a port another lane has recorded, even while that lane is down", async () => { + const a = makeLane(); + const b = makeLane(); + writeProfile(a.cwd, "PORTS=api\nPORT_BASE_api=18800\n"); + // b's base is offset so its preferred number collides with a's recorded one. + slots.allocateSlot(a.id); + const laneA = lanesLib.getLane(a.id); + lanesLib.setProvisioningFacts(laneA.id, { ports: { api: 18800 + laneA.slot } }); + + slots.allocateSlot(b.id); + const laneB = lanesLib.getLane(b.id); + writeProfile(b.cwd, `PORTS=api\nPORT_BASE_api=${18800 + laneA.slot - laneB.slot}\n`); + const ports = await slots.resolvePorts(laneB, profileLib.resolveProfile(laneB)); + assert.notEqual(ports.api, 18800 + laneA.slot); + + slots.releaseSlot(a.id); + slots.releaseSlot(b.id); + }); +}); + +describe("hook execution", () => { + it("exports the lane contract and scrubs inherited git variables", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=api\nPORT_BASE_api=18900\nCUSTOM=from-profile\n", { + boot: '#!/usr/bin/env bash\necho "LANE=$LANE API_PORT=$API_PORT CUSTOM=$CUSTOM GITDIR=[${GIT_DIR:-unset}] ARG=$1"\n', + }); + slots.allocateSlot(lane.id); + lanesLib.setProvisioningFacts(lane.id, { ports: { api: 18999 } }); + const current = lanesLib.getLane(lane.id); + + process.env.GIT_DIR = "/somewhere/else/.git"; + try { + const result = await profileLib.runHook(current, profileLib.resolveProfile(current), "boot", [ + "--no-build", + ]); + assert.equal(result.code, 0); + assert.match(result.output, new RegExp(`LANE=${current.slot}\\b`)); + assert.match(result.output, /API_PORT=18999/); + assert.match(result.output, /CUSTOM=from-profile/); + assert.match(result.output, /GITDIR=\[unset\]/); + assert.match(result.output, /ARG=--no-build/); + } finally { + delete process.env.GIT_DIR; + slots.releaseSlot(lane.id); + } + }); + + it("rejects a hook name outside the allowlist without spawning anything", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=api\n", { boot: "#!/usr/bin/env bash\ntrue\n" }); + slots.allocateSlot(lane.id); + const current = lanesLib.getLane(lane.id); + await assert.rejects( + () => profileLib.runHook(current, profileLib.resolveProfile(current), "../../etc/passwd"), + (err) => err.code === "ENOHOOK" + ); + slots.releaseSlot(lane.id); + }); + + it("resolves with the exit code rather than throwing when a hook fails", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=api\n", { + boot: '#!/usr/bin/env bash\necho "nope" >&2\nexit 3\n', + }); + slots.allocateSlot(lane.id); + const current = lanesLib.getLane(lane.id); + const result = await profileLib.runHook(current, profileLib.resolveProfile(current), "boot"); + assert.equal(result.code, 3); + assert.match(result.output, /nope/); + assert.ok(fsMod.existsSync(result.logPath)); + slots.releaseSlot(lane.id); + }); +}); + +describe("lifecycle", () => { + /** A profile whose boot hook starts a real HTTP listener and detaches it. */ + function serverProfile(lane, base) { + writeProfile(lane.cwd, `PORTS=web\nPORT_BASE_web=${base}\nLANE_DIRS="uploads .cache"\n`, { + boot: [ + "#!/usr/bin/env bash", + "set -euo pipefail", + '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"), + }); + } + + it("boots a detached stack, still sees it after a cold recompute, then stops it", async () => { + const lane = makeLane(); + serverProfile(lane, 19100); + + const facts = await runtime.upLane(lanesLib.getLane(lane.id)); + assert.equal(facts.available, true); + assert.equal(facts.up, true); + assert.equal(facts.healthy, true); + + const booted = lanesLib.getLane(lane.id); + const port = booted.ports.web; + assert.ok(port, "a port was recorded"); + assert.equal(await isListening(port), true); + + // The declared per-lane directories exist inside the working copy. + assert.ok(fsMod.existsSync(pathMod.join(booted.cwd, "uploads"))); + assert.ok(fsMod.existsSync(pathMod.join(booted.cwd, ".cache"))); + + // "Survives a dashboard restart": nothing in memory is consulted — the facts + // are rebuilt from the pid file on disk and a fresh port probe. + const recomputed = await runtime.runtimeFacts(lanesLib.getLane(lane.id)); + assert.equal(recomputed.up, true); + assert.equal(recomputed.healthy, true); + assert.equal(recomputed.services.find((s) => s.name === "web").alive, true); + + await runtime.downLane(lanesLib.getLane(lane.id)); + assert.equal(await isListening(port), false); + const afterDown = await runtime.runtimeFacts(lanesLib.getLane(lane.id)); + assert.equal(afterDown.up, false); + assert.deepEqual(afterDown.services, []); + + slots.releaseSlot(lane.id); + }); + + it("records a failed health check without touching the lane's agent fields", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=web\nPORT_BASE_web=19200\n", { + boot: "#!/usr/bin/env bash\ntrue\n", + health: "#!/usr/bin/env bash\nexit 1\n", + }); + + await assert.rejects( + () => runtime.upLane(lanesLib.getLane(lane.id)), + (err) => err.code === "EUNHEALTHY" + ); + + const after = lanesLib.getLane(lane.id); + assert.equal(after.stage, "idle", "runtime must not write stage"); + assert.equal(after.status, "idle", "runtime must not write status"); + assert.equal(after.notes, null, "runtime must not write notes"); + + const facts = await runtime.runtimeFacts(after); + assert.equal(facts.lastError.code, "EUNHEALTHY"); + + slots.releaseSlot(lane.id); + }); + + it("reports available:false for a lane with no profile", async () => { + const lane = makeLane(); + const facts = await runtime.runtimeFacts(lane); + assert.equal(facts.available, false); + assert.ok(facts.searched.length > 0); + await assert.rejects( + () => runtime.upLane(lane), + (err) => err.code === "ENOPROFILE" + ); + }); + + it("is a no-op when taking down a lane that was never up", async () => { + const lane = makeLane(); + writeProfile(lane.cwd, "PORTS=web\n", { boot: "#!/usr/bin/env bash\ntrue\n" }); + assert.deepEqual(await runtime.downLane(lane), { killed: [] }); + }); + + it("refuses a LANE_DIRS entry that escapes the working copy", () => { + const lane = makeLane(); + writeProfile(lane.cwd, 'PORTS=web\nLANE_DIRS="../escaped"\n'); + assert.throws( + () => runtime.makeLaneDirs(lane, profileLib.resolveProfile(lane)), + (err) => err.code === "EBADLANEDIR" + ); + }); +}); + +describe("allocation is not client-patchable", () => { + it("ignores slot and ports coming through updateLane", () => { + const lane = makeLane(); + slots.allocateSlot(lane.id); + const before = lanesLib.getLane(lane.id); + lanesLib.updateLane(lane.id, { slot: 99, ports: { api: 1 }, title: "renamed" }); + const after = lanesLib.getLane(lane.id); + assert.equal(after.slot, before.slot); + assert.deepEqual(after.ports, before.ports); + assert.equal(after.title, "renamed", "patchable fields still apply"); + slots.releaseSlot(lane.id); + }); +}); diff --git a/server/data/profile-templates/postgres-compose/hooks/db-create.sh b/server/data/profile-templates/postgres-compose/hooks/db-create.sh new file mode 100755 index 0000000..19f5979 --- /dev/null +++ b/server/data/profile-templates/postgres-compose/hooks/db-create.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# db-create.sh – Create this lane's Postgres database inside a docker-compose +# service, via `createdb`. Ported verbatim from Shipyard's own lane-bootstrap +# / lane-up / lane-reset scripts, so a Postgres-via-compose repo gets parity +# by copying this template into its own .ccam/profile/hooks/. +# +# Called as: db-create.sh (also available as $DB_NAME) +# Requires, declared in the repo's own profile.env: +# COMPOSE_FILE path to the docker-compose file (e.g. "$LANE_DIR/docker-compose.yml") +# DB_SERVICE the compose service name running Postgres +# Provided by CCAM's hook environment (server/lib/secrets.js): +# PG_USER (PG_PASS is deliberately not exported — trust auth inside the +# compose network needs no password for this call) +# ───────────────────────────────────────────────────────────────────────────── +# @author Nguyễn Ngọc Trí Vĩ +set -euo pipefail + +NAME="${1:-$DB_NAME}" +[ -n "$NAME" ] || { echo "db-create: no database name given" >&2; exit 1; } + +if docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" \ + psql -U "$PG_USER" -tAc "SELECT 1 FROM pg_database WHERE datname='$NAME'" | grep -q 1; then + echo "db-create: $NAME already exists" + exit 0 +fi + +docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" createdb -U "$PG_USER" "$NAME" +echo "db-create: created $NAME" diff --git a/server/data/profile-templates/postgres-compose/hooks/db-drop.sh b/server/data/profile-templates/postgres-compose/hooks/db-drop.sh new file mode 100755 index 0000000..9735c99 --- /dev/null +++ b/server/data/profile-templates/postgres-compose/hooks/db-drop.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# db-drop.sh – Drop this lane's Postgres database inside a docker-compose +# service, via `dropdb --if-exists`. Ported verbatim from Shipyard's own +# lane-reset / lane-remove scripts. CCAM only ever calls this with a name it +# derived itself (server/lib/lane-services.js:dropDatabase), so this script +# never needs to re-validate its argument. +# +# Called as: db-drop.sh (also available as $DB_NAME) +# Requires/provides: same as db-create.sh in this same directory. +# ───────────────────────────────────────────────────────────────────────────── +# @author Nguyễn Ngọc Trí Vĩ +set -euo pipefail + +NAME="${1:-$DB_NAME}" +[ -n "$NAME" ] || { echo "db-drop: no database name given" >&2; exit 1; } + +docker compose -f "$COMPOSE_FILE" exec -T "$DB_SERVICE" dropdb --if-exists -U "$PG_USER" "$NAME" +echo "db-drop: dropped $NAME (if it existed)" diff --git a/server/data/profile-templates/postgres-compose/profile.env.example b/server/data/profile-templates/postgres-compose/profile.env.example new file mode 100644 index 0000000..b2ae47a --- /dev/null +++ b/server/data/profile-templates/postgres-compose/profile.env.example @@ -0,0 +1,23 @@ +# postgres-compose profile template — data-isolation additions (A2). +# Copy the declarations you need into your repo's own .ccam/profile/profile.env +# alongside db-create.sh / db-drop.sh from this template's hooks/ directory. + +DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB +DB_KIND="postgres" # informational +DB_URL_SCHEME="postgresql" # DATABASE_URL scheme +REDIS=1 # 1 = allocate a logical Redis index = slot + # (16 logical DBs by default, 0-15 — keep + # LANE_MAX_SLOTS <= 15 if you turn this on) + +ENV_FILES="backend/.env" # file(s) to seed, relative to the lane +ENV_SOURCE="backend/.env" # source path in the source repo +ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys CCAM overwrites per lane +ENV_PRESERVE="JWT_SECRET" # keys kept from the lane's OWN file on --force +UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR + +# Read by db-create.sh / db-drop.sh, NOT by CCAM itself: +COMPOSE_FILE="docker-compose.yml" # relative to the lane's working copy +DB_SERVICE="postgres" # the compose service name running Postgres + +# Machine-level credentials (PG_HOST/PORT/USER/PASS, REDIS_HOST/PORT) come +# from ~/.ccam/secrets.env, never from here — see server/lib/secrets.js. diff --git a/server/db.js b/server/db.js index fad723e..7f67d1b 100644 --- a/server/db.js +++ b/server/db.js @@ -493,6 +493,32 @@ try { db.prepare("ALTER TABLE lanes ADD COLUMN detected_at TEXT").run(); } +// Migrate: per-lane runtime allocation. `slot` is the small integer every runtime +// fact derives from (ports now; database name and Redis index later) — the +// numbering Shipyard gets for free from its fixed lane1..lane9 directories and +// CCAM, keyed by cwd, has to allocate. `ports` is a JSON map name -> port, +// recording the number a lane ACTUALLY got: the preferred `base + slot` may be +// taken by something outside CCAM, so the allocator steps aside and the real +// number has to survive a restart or the next boot would move a live lane. +// +// Allocation is lazy — a lane that is only ever watched never takes a slot — so +// the column is nullable and the uniqueness constraint is partial. That index is +// the backstop under the lock in lane-slots.js, not a substitute for it. +// One probe per column, same reasoning as the detection columns above. +try { + db.prepare("SELECT slot FROM lanes LIMIT 1").get(); +} catch { + db.prepare("ALTER TABLE lanes ADD COLUMN slot INTEGER").run(); +} +try { + db.prepare("SELECT ports FROM lanes LIMIT 1").get(); +} catch { + db.prepare("ALTER TABLE lanes ADD COLUMN ports TEXT NOT NULL DEFAULT '{}'").run(); +} +db.prepare( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_slot ON lanes(slot) WHERE slot IS NOT NULL" +).run(); + // Migrate: link agent rows to a workflow run. Workflow inner-agents are already // ingested as subagents (same subagents/ dir); these columns add the grouping + // phase that the run journal provides. Additive, safe on existing DBs. diff --git a/server/lib/lane-env.js b/server/lib/lane-env.js new file mode 100644 index 0000000..e9ee7a6 --- /dev/null +++ b/server/lib/lane-env.js @@ -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ĩ + */ + +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} 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} 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 }; diff --git a/server/lib/lane-preflight.js b/server/lib/lane-preflight.js index a6f52b4..93ca447 100644 --- a/server/lib/lane-preflight.js +++ b/server/lib/lane-preflight.js @@ -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, }; diff --git a/server/lib/lane-profile.js b/server/lib/lane-profile.js new file mode 100644 index 0000000..d02cf65 --- /dev/null +++ b/server/lib/lane-profile.js @@ -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: `/.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 ` with a fixed argument + * array, never a command string. + * @author Nguyễn Ngọc Trí Vĩ + */ + +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} + */ +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, hooks: Set, 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 ` 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 "$RUN_DIR/$name.pid" + ) /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; + + // _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/.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, +}; diff --git a/server/lib/lane-runtime.js b/server/lib/lane-runtime.js new file mode 100644 index 0000000..7af9985 --- /dev/null +++ b/server/lib/lane-runtime.js @@ -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ĩ + */ + +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} 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 `.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} 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} 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} + */ +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, +}; diff --git a/server/lib/lane-services.js b/server/lib/lane-services.js new file mode 100644 index 0000000..aab10f5 --- /dev/null +++ b/server/lib/lane-services.js @@ -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ĩ + */ + +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 }; diff --git a/server/lib/lane-slots.js b/server/lib/lane-slots.js new file mode 100644 index 0000000..dbf0bf1 --- /dev/null +++ b/server/lib/lane-slots.js @@ -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_ + 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ĩ + */ + +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_ + 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>} 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} 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, +}; diff --git a/server/lib/lanes.js b/server/lib/lanes.js index d19abbe..05218d6 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -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} + */ +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, }; diff --git a/server/lib/ports.js b/server/lib/ports.js new file mode 100644 index 0000000..174b265 --- /dev/null +++ b/server/lib/ports.js @@ -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ĩ + */ + +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} + */ +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} 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 }; diff --git a/server/lib/secrets.js b/server/lib/secrets.js new file mode 100644 index 0000000..df2ed57 --- /dev/null +++ b/server/lib/secrets.js @@ -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ĩ + */ + +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} + */ +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 }; diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index 62ec785..686beeb 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -280,6 +280,150 @@ const paths = { }, }, }, + "/api/lanes/{id}/runtime": { + get: { + tags: ["Lanes"], + summary: "A lane's own application stack", + description: + "Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .ccam/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.", + operationId: "getLaneRuntime", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + responses: { + 200: { + description: + "available:false (no profile); available:true with provisioned:false (no slot yet); or the full facts with slot, ports, services, up, healthy, steppedAside, logs and lastError.", + }, + 404: { description: "Lane not found." }, + }, + }, + }, + "/api/lanes/{id}/up": { + post: { + tags: ["Lanes"], + summary: "Boot a lane's stack", + description: + "Runs the profile's boot then health hooks. Returns 202 and finishes in the background because a build can take minutes; progress streams as lane_hook_output and the attempt ends with a lane_runtime message. Does NOT run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running, because their logs are what identify the service that never came up. Writes only slot and ports on the lane row: never stage, status or notes.", + operationId: "upLane", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + requestBody: { + required: false, + content: { + "application/json": { + schema: { + type: "object", + properties: { + build: { + type: "boolean", + default: true, + description: + "false passes --no-build to the boot hook, reusing an existing build.", + }, + }, + }, + }, + }, + }, + responses: { + 202: { description: "Accepted; the boot runs in the background." }, + 400: { description: "ENOPROFILE — no .ccam/profile, with the paths searched." }, + 403: { description: "The browser request was not same-origin/loopback." }, + 404: { description: "Lane not found." }, + 409: { description: "ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids)." }, + }, + }, + }, + "/api/lanes/{id}/down": { + post: { + tags: ["Lanes"], + summary: "Stop a lane's stack", + description: + "Kills each recorded pid tree bottom-up (killing a parent first reparents its children to init, where nothing knows to look for them), then sweeps listeners on the lane's ports ONLY when a pid file existed — a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server the user started there. Idempotent, and a no-op for a lane that was never up.", + operationId: "downLane", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + responses: { + 200: { description: "{ok, killed: number[], runtime}." }, + 403: { description: "The browser request was not same-origin/loopback." }, + 404: { description: "Lane not found." }, + }, + }, + }, + "/api/lanes/{id}/hook/{name}": { + post: { + tags: ["Lanes"], + summary: "Run one of the lane profile's hooks", + description: + "The surface a driving session uses for ci-gate, e2e, migrate and friends. Returns 202; output streams as lane_hook_output and completion arrives as lane_hook_result with the exit code. The name is checked against a fixed allowlist BEFORE anything is spawned, and args travels as an array of strings straight into argv — neither is ever joined into a command string.", + operationId: "runLaneHook", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "integer" } }, + { + name: "name", + in: "path", + required: true, + schema: { + type: "string", + enum: [ + "bootstrap", + "boot", + "health", + "migrate", + "seed", + "ci-gate", + "e2e", + "regen", + "db-create", + "db-drop", + ], + }, + }, + ], + requestBody: { + required: false, + content: { + "application/json": { + schema: { + type: "object", + properties: { args: { type: "array", items: { type: "string" } } }, + }, + }, + }, + }, + responses: { + 202: { description: "Accepted; the hook runs in the background." }, + 400: { description: "ENOHOOK (name outside the allowlist) or ENOPROFILE." }, + 403: { description: "The browser request was not same-origin/loopback." }, + 404: { description: "Lane not found." }, + 409: { description: "ENOSLOT — the lane has no runtime yet; bring it up first." }, + }, + }, + }, + "/api/lanes/{id}/logs/{svc}": { + get: { + tags: ["Lanes"], + summary: "Tail a lane's hook or service log", + description: + "The resolved path is confined to the lane's log directory after realpath, so a name from the request can never escape it. Read-only, so no same-origin guard.", + operationId: "getLaneLog", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "integer" } }, + { name: "svc", in: "path", required: true, schema: { type: "string" } }, + { + name: "tail", + in: "query", + required: false, + schema: { type: "integer", default: 65536, maximum: 1048576 }, + description: "Trailing bytes to return; capped at 1 MiB.", + }, + ], + responses: { + 200: { + description: + "{available, svc, size, truncated, text}, or {available:false} for a lane with no slot.", + }, + 404: { description: "Lane not found, or ENOLOG — no such log for this lane." }, + }, + }, + }, "/api/lanes/branches": { get: { tags: ["Lanes"], diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 28e6134..f64c880 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -30,10 +30,25 @@ const { slugify, } = require("../lib/worktree"); const { withLaneLock } = require("../lib/lane-lock"); +const { HOOKS, runHook, resolveProfile } = require("../lib/lane-profile"); +const { slotDirs } = require("../lib/lane-slots"); +const { + upLane, + downLane, + runtimeFacts, + requireProfile, + provisionLane, + resetLaneData, + removeLaneData, +} = require("../lib/lane-runtime"); const router = Router(); const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50; +/** Bytes of a hook log returned by default — enough to see a failure's tail. */ +const LOG_TAIL_DEFAULT = 64 * 1024; +const LOG_TAIL_MAX = 1024 * 1024; + /** Seconds since this lane's session last emitted an event; null if never. */ function lastEventAge(lane) { if (!lane.session_id) return null; @@ -317,6 +332,18 @@ router.post("/worktree", sameOriginGuard, async (req, res) => { await addWorktree({ sourceRepo: resolvedSourceRepo, dir, branch, base: baseBranch }); // base_branch is a provisioning fact, not a patchable field — see PATCHABLE. lanesLib.setProvisioningFacts(lane.id, { base_branch: baseBranch }); + + // A2 data isolation: only when the repo actually declares a profile — a + // worktree lane with none is a normal state (nothing about A1 required + // one either), so this is a no-op rather than a provisioning failure. + const worktreeLane = lanesLib.getLane(lane.id); + const profile = resolveProfile(worktreeLane); + if (profile) { + const onLine = (line, stream) => + broadcast("lane_hook_output", { laneId: lane.id, hook: "provision", stream, line }); + await provisionLane(worktreeLane, { onLine }); + } + lanesLib.updateLane(lane.id, { status: "idle", notes: null }); } catch (err) { lanesLib.updateLane(lane.id, { @@ -419,6 +446,216 @@ function sendLifecycleError(res, err) { return res.status(500).json({ error: { code: err.code, message: err.message } }); } +/* --------------------------------------------------------------------------- + * Runtime: a lane's own stack, isolated by slot-derived ports and directories. + * + * These routes are registered BEFORE the "/:id/:action" catch-all below, which + * would otherwise swallow "up" and "down" as unknown actions. They are also + * deliberately NOT folded into that catch-all: it drives a lane's Claude RUN, + * while these drive the application the lane is working on — two different + * lifecycles that happen to share a lane id. + * + * None of them writes `stage`, `status` or `notes`. A booted stack is not an + * agent at work, and only `slot`/`ports` describe the runtime. + * ------------------------------------------------------------------------ */ + +/** Map a runtime error onto its status code. */ +function sendRuntimeError(res, err) { + const badRequest = ["ENOPROFILE", "ENOHOOK", "EBADLANEDIR", "EBADSVC"]; + if (badRequest.includes(err.code)) { + return res.status(400).json({ + error: { + code: err.code, + message: err.message, + ...(err.searched ? { searched: err.searched } : {}), + }, + }); + } + if (err.code === "ESLOTS") { + return res.status(409).json({ error: { code: err.code, message: err.message } }); + } + if (err.code === "EPORTBUSY") { + return res.status(409).json({ + error: { code: err.code, message: err.message, port: err.preferred, pids: err.pids }, + }); + } + return res.status(500).json({ + error: { code: err.code || "ERUNTIME", message: err.message, logPath: err.logPath }, + }); +} + +/** Resolve `:id` or answer 404. Returns null once the response has been sent. */ +function laneOr404(req, res) { + const lane = lanesLib.getLane(req.params.id); + if (!lane) { + res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); + return null; + } + return lane; +} + +/** + * What is running for this lane, computed fresh. + * + * Follows `GET /:id/git`'s contract: a lane with no profile answers + * `{available:false}` with HTTP 200, because that is a normal state and not a + * fault. It probes ports and stats pid files, which is why it is its own endpoint + * rather than a field on the polled lane list. + */ +router.get("/:id/runtime", async (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + try { + res.json(await runtimeFacts(lane)); + } catch (err) { + sendRuntimeError(res, err); + } +}); + +/** + * Tail one of the lane's hook or service logs. + * + * `:svc` is resolved against the log directory's real contents and the result is + * confined to that directory after `realpath`, so a name from the request can + * never escape it. + */ +router.get("/:id/logs/:svc", (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + if (!lane.slot) return res.json({ available: false }); + + const { logDir } = slotDirs(lane.slot); + const file = path.resolve(logDir, `${req.params.svc}.log`); + let real; + try { + real = fs.realpathSync(file); + const relative = path.relative(fs.realpathSync(logDir), real); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("escapes log dir"); + } catch { + return res.status(404).json({ error: { code: "ENOLOG", message: "no such log" } }); + } + + const requested = Number(req.query.tail); + const tail = Math.min( + Number.isInteger(requested) && requested > 0 ? requested : LOG_TAIL_DEFAULT, + LOG_TAIL_MAX + ); + const { size } = fs.statSync(real); + const start = Math.max(0, size - tail); + const fd = fs.openSync(real, "r"); + try { + const buffer = Buffer.alloc(size - start); + fs.readSync(fd, buffer, 0, buffer.length, start); + res.json({ + available: true, + svc: req.params.svc, + size, + truncated: start > 0, + text: buffer.toString("utf8"), + }); + } finally { + fs.closeSync(fd); + } +}); + +/** + * Boot the lane's stack. 202 + background, like `POST /worktree`: a build can run + * for minutes and the caller should not hold a socket open for it. Progress + * streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports` + * the boot may have changed. + */ +router.post("/:id/up", sameOriginGuard, (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + try { + requireProfile(lane); + } catch (err) { + return sendRuntimeError(res, err); + } + + const build = req.body?.build !== false; + 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 }); + broadcast("lane_runtime", { laneId: lane.id, runtime: facts }); + } catch (err) { + broadcast("lane_runtime", { + laneId: lane.id, + error: { code: err.code || "ERUNTIME", message: err.message }, + }); + } + broadcastLane(lane.id); + }); +}); + +/** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */ +router.post("/:id/down", sameOriginGuard, async (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + try { + const result = await withLaneLock(lane.id, () => downLane(lanesLib.getLane(lane.id))); + const facts = await runtimeFacts(lanesLib.getLane(lane.id)); + broadcast("lane_runtime", { laneId: lane.id, runtime: facts }); + res.json({ ok: true, killed: result.killed, runtime: facts }); + } catch (err) { + sendRuntimeError(res, err); + } +}); + +/** + * Run one of the profile's hooks on the lane — the surface a driving session uses + * for `ci-gate`, `e2e`, `migrate` and friends. + * + * `:name` is checked against the hook allowlist BEFORE anything is spawned, and + * `args` travels as an array of strings straight into argv. Neither is ever + * joined into a command string. + */ +router.post("/:id/hook/:name", sameOriginGuard, (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + const { name } = req.params; + if (!HOOKS.includes(name)) { + return res + .status(400) + .json({ error: { code: "ENOHOOK", message: `unknown hook ${name}`, allowed: HOOKS } }); + } + const args = Array.isArray(req.body?.args) ? req.body.args.map(String) : []; + + let profile; + try { + profile = requireProfile(lane); + } catch (err) { + return sendRuntimeError(res, err); + } + if (!lane.slot) { + return res.status(409).json({ + error: { code: "ENOSLOT", message: "lane has no runtime yet — bring it up first" }, + }); + } + + res.status(202).json({ ok: true, laneId: lane.id, hook: name }); + + void withLaneLock(lane.id, async () => { + const onLine = (line, stream) => + broadcast("lane_hook_output", { laneId: lane.id, hook: name, stream, line }); + try { + const result = await runHook(lanesLib.getLane(lane.id), profile, name, args, { onLine }); + broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: result.code }); + } catch (err) { + broadcast("lane_hook_result", { + laneId: lane.id, + hook: name, + code: null, + error: { code: err.code || "ERUNTIME", message: err.message }, + }); + } + }); +}); + /** * Lane control. Deliberately thin: every action maps onto one existing * run-spawner call. There is no queue, no chaining, no gate evaluation — the @@ -447,6 +684,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found"); await stopLaneRun(lockedLane); + // A running stack holds files open in the very directory reset and remove + // are about to rewrite or delete, and its processes would outlive the lane + // still bound to its ports. Stop it before touching git. Idempotent and a + // no-op for a lane that was never brought up. + if (action === "reset" || action === "remove") { + await downLane(lanesLib.getLane(lane.id)); + } const current = lanesLib.getLane(lane.id); const facts = await preflight(current, action); assertExpectedPreflight(action, facts, body.expect); @@ -463,13 +707,34 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { if (action === "reset") { await resetWorktree(current); - return { lane: lanesLib.clearLane(current.id) }; + const resetLane = lanesLib.clearLane(current.id); + // A2 data isolation, only when the lane actually has both a profile + // and an allocated slot — a lane that was never brought up has + // nothing of this kind to reset. + const profile = resolveProfile(resetLane); + if (profile && resetLane.slot) { + const onLine = (line, stream) => + broadcast("lane_hook_output", { laneId: resetLane.id, hook: "reset", stream, line }); + await resetLaneData(resetLane, profile, { keepDb: body.keepDb === true, onLine }); + } + return { lane: lanesLib.getLane(current.id) }; } if (action === "remove") { + // Drop the lane's own database(s) before anything else — its state + // directory (the drop-created marker) is about to be deleted too. + const profile = resolveProfile(current); + if (profile && current.slot) await removeLaneData(current, profile); // Forgetting an adopted lane only removes dashboard metadata. The // filesystem destroy guard is deliberately reached only for managed // worktrees, where removal can actually touch a directory. if (current.kind === "managed") await removeWorktree(current); + // Runtime bookkeeping outlives the row otherwise: pid files and hook + // logs under .state/lane/ would be inherited by whichever lane + // claims that slot next. Deleting the row is what frees the slot — + // usedSlots() reads the table, so there is nothing else to release. + if (current.slot) { + fs.rmSync(slotDirs(current.slot).stateDir, { recursive: true, force: true }); + } lanesLib.deleteLane(current.id); return { removed: current.id }; }