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:
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user