feat(lanes): per-lane database, Redis, and .env isolation (A1+A2)

Gives each lane its own slot-derived runtime (ports, detached process
lifecycle, profile-driven hooks) and its own database/Redis logical
index/.env file, so two lanes running the same repo's stack at once no
longer share state. Machine-level DB/Redis credentials live at
~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's
output is redacted of that password (raw and URL-encoded forms) before
it reaches a log file or the lane_hook_output websocket broadcast.
Wired into provision/up/reset/remove; reset accepts --keep-db to skip
the drop/recreate/migrate/reseed block entirely.
This commit is contained in:
2026-08-04 10:03:40 +07:00
parent d71086f677
commit 9d145865dd
19 changed files with 3265 additions and 12 deletions
+144
View File
@@ -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"],