diff --git tree/agents/rocko/work/queue-56/delta-check.sh r2/agents/rocko/work/queue-56/delta-check.sh index 69224ea..a502d1d 100755 --- tree/agents/rocko/work/queue-56/delta-check.sh +++ r2/agents/rocko/work/queue-56/delta-check.sh @@ -59,4 +59,7 @@ if [ "$(id -u)" != 0 ]; then hdr "runs directory unreadable: list"; run_case unreadable list hdr "runs directory unreadable: show"; run_case unreadable show "$A" chmod 755 "$D/runs" + D="$(root run-unreadable)"; mkdir -p "$D/runs/$A"; printf '%s\n' "$RESULT" > "$D/runs/$A/result.json"; chmod 000 "$D/runs/$A" + hdr "run directory unreadable: show"; run_case run-unreadable show "$A" + chmod 755 "$D/runs/$A" fi diff --git tree/packages/runs/README.md r2/packages/runs/README.md index 84230a1..8fd7808 100644 --- tree/packages/runs/README.md +++ r2/packages/runs/README.md @@ -40,7 +40,11 @@ printed them. - a run document resolving outside reads as `null`; - `active.json` or `activation-log.jsonl` resolving outside refuses. - A link that stays inside the data root is followed. + Only a path that exists can resolve. A link with nothing behind it reads + as missing wherever it points. A `state/` link out of the data root to a + path that doesn't exist reads as no release and an empty log, and a + `runs/` link like that lists nothing. A link that stays inside the data + root is followed. - **Run documents are JSON objects or `null`.** A document that is missing, unreadable, not JSON, or JSON but not an object (`null`, `5`, `[]`) reads as `null`. Run records are never validated beyond that, @@ -49,11 +53,14 @@ printed them. shape `scripts/release.sh` writes: exactly `pointerVersion` 1 and non-empty strings `release`, `imageTag` and `activatedAt`. Anything else refuses with exit code 2 rather than being guessed at. -- **The activation log is lenient per line.** An entry needs string `at`, - `event`, `release` and `imageTag`, an optional string `note`, and no - other keys. Other lines are counted in `malformed` and skipped, the way - `release.sh rollback` skips them. Blank lines aren't counted. `last` - must be a positive integer and keeps the newest entries. +- **The activation log is read per line.** One bad line never refuses + the log. An entry needs string `at`, `event`, `release` and `imageTag`, + an optional string `note`, and no other keys. A line that isn't JSON, + or is JSON with another shape, is counted in `malformed` and skipped. + That is stricter than `release.sh rollback`, which skips only lines that + aren't JSON and would act on an entry with an extra key or a non-string + field. Blank lines aren't counted. `last` must be a positive integer and + keeps the newest entries. - **Errors.** Every refusal is a `RunsError` with `exitCode` 2 (invalid data) or 4 (a file or environment problem), matching `mosaic-task.mjs`. A missing data root, `runs/` or `state/` file is not @@ -78,6 +85,7 @@ output changes on purpose: | `task.json` or `mission.json` is JSON but not an object | `show` printed its snapshot line | snapshot line omitted | | run is a regular file | `show` crashed | `run not found`, exit 4 | | `runs/` unreadable | `list` printed nothing | refuses, exit 4 | +| run directory unreadable (mode 000) | `show` printed two lines, then crashed, exit 1 | refuses with EACCES, exit 4 | | link loop or a permission error resolving a run | `run not found` | refuses with the error code, exit 4 | `agents/rocko/work/queue-56/delta-check.sh` prints the before and after diff --git tree/packages/runs/src/state.mjs r2/packages/runs/src/state.mjs index a92c871..af5cc92 100644 --- tree/packages/runs/src/state.mjs +++ r2/packages/runs/src/state.mjs @@ -60,10 +60,11 @@ function logEntry(line) { return entry; } -// The activation log as { entries, malformed }, oldest first. Lines that -// aren't a well-formed entry are counted in malformed and skipped, the way -// release.sh rollback skips them. A missing log is empty. With last, only -// the newest last well-formed entries are returned. +// The activation log as { entries, malformed }, oldest first. A line that +// isn't JSON, or is JSON but not the entry shape release.sh writes, is +// counted in malformed and skipped. This is stricter than release.sh +// rollback, which skips only lines that aren't JSON. A missing log is empty. +// With last, only the newest last well-formed entries are returned. export function readActivationLog(dataRoot, { last } = {}) { if (last !== undefined && (!Number.isInteger(last) || last < 1)) { throw new RunsError(`last must be a positive integer (got ${JSON.stringify(last)})`); diff --git tree/packages/runs/tests/runs.test.mjs r2/packages/runs/tests/runs.test.mjs index 85838e5..a652f62 100644 --- tree/packages/runs/tests/runs.test.mjs +++ r2/packages/runs/tests/runs.test.mjs @@ -216,6 +216,15 @@ test("listRunIds sorts whatever order the directory returns", (t) => { assert.deepEqual(listRunIds(dataRoot), [A, B]); }); +// list has always shown any r- name, including ones show refuses as ids. +test("listRunRecords lists an r- name that isn't a valid run id, as before", (t) => { + const { dataRoot } = scratch(t); + write(path.join(dataRoot, "runs", "r-.bad", "result.json"), {}); + assert.equal(isRunId("r-.bad"), false); + assert.deepEqual(listRunIds(dataRoot), ["r-.bad"]); + assert.deepEqual(listRunRecords(dataRoot), [{ runId: "r-.bad", result: {} }]); +}); + test("the readers write nothing", (t) => { const { dataRoot } = scratch(t); const runs = path.join(dataRoot, "runs"); diff --git tree/packages/runs/tests/state.test.mjs r2/packages/runs/tests/state.test.mjs index 80ab5ea..4b126f3 100644 --- tree/packages/runs/tests/state.test.mjs +++ r2/packages/runs/tests/state.test.mjs @@ -72,6 +72,13 @@ test("a state directory linked out of the data root refuses", (t) => { assert.throws(() => readActivePointer(dataRoot), /state\/active.json resolves outside the data root/); }); +test("a state directory linked out to nothing reads as no release and an empty log", (t) => { + const { dataRoot, outside } = scratch(t); + fs.symlinkSync(path.join(outside, "absent"), path.join(dataRoot, "state")); + assert.equal(readActivePointer(dataRoot), null); + assert.deepEqual(readActivationLog(dataRoot), { entries: [], malformed: 0 }); +}); + test("a missing log is empty", (t) => { const { dataRoot } = scratch(t); assert.deepEqual(readActivationLog(dataRoot), { entries: [], malformed: 0 }); @@ -95,11 +102,13 @@ test("the log reads oldest first and counts malformed lines", (t) => { { ...good[1], extra: true }, { ...good[1], at: 5 }, { ...good[1], note: null }, + { at: good[1].at, event: good[1].event, release: good[1].release }, + { ...good[1], release: 5 }, good[2], good[3], ]); - assert.deepEqual(readActivationLog(dataRoot), { entries: good, malformed: 5 }); - assert.deepEqual(readActivationLog(dataRoot, { last: 2 }), { entries: good.slice(2), malformed: 5 }); + assert.deepEqual(readActivationLog(dataRoot), { entries: good, malformed: 7 }); + assert.deepEqual(readActivationLog(dataRoot, { last: 2 }), { entries: good.slice(2), malformed: 7 }); assert.deepEqual(readActivationLog(dataRoot, { last: 99 }).entries, good); }); diff --git tree/packages/runs/tests/task-cli.test.mjs r2/packages/runs/tests/task-cli.test.mjs index b5fafd4..ed13ecd 100644 --- tree/packages/runs/tests/task-cli.test.mjs +++ r2/packages/runs/tests/task-cli.test.mjs @@ -92,3 +92,20 @@ test("show refuses a run linked out of the data root; list reports it unknown", assert.equal(list.status, 0, list.stderr); assert.equal(list.stdout, `${A} unknown task=- ws=- session=-\n`); }); + +test("list shows an r- name that isn't a valid run id, as before", (t) => { + const { dataRoot } = scratch(t); + write(path.join(dataRoot, "runs", "r-.bad", "result.json"), RESULT); + const out = task(t, dataRoot, "list"); + assert.equal(out.status, 0, out.stderr); + assert.equal(out.stdout, "r-.bad succeeded task=t-one ws=- session=-\n"); +}); + +test("show refuses an invalid id before reading the config", (t) => { + const { dir } = scratch(t); + const env = { ...process.env, MOSAIC_CONFIG: path.join(dir, "missing.json") }; + delete env.NODE_TEST_CONTEXT; + const proc = spawnSync(process.execPath, [path.join(ROOT, "scripts", "mosaic-task.mjs"), "show", "../x"], { cwd: ROOT, env, encoding: "utf8" }); + assert.equal(proc.status, 4, proc.stderr); + assert.equal(proc.stderr, 'mosaic-task: invalid run id: "../x" (expected r-)\n'); +});