import fs from "node:fs"; import { RunsError } from "./errors.mjs"; import { STATE_DIRNAME, resolveInside } from "./paths.mjs"; export const POINTER_FILE = "active.json"; export const ACTIVATION_LOG_FILE = "activation-log.jsonl"; const POINTER_KEYS = ["pointerVersion", "release", "imageTag", "activatedAt"]; const LOG_KEYS = ["at", "event", "release", "imageTag", "note"]; function isNonEmptyString(value) { return typeof value === "string" && value.length > 0; } function readStateFile(dataRoot, name) { const file = resolveInside(dataRoot, STATE_DIRNAME, name); if (file === null) return null; try { return { file, text: fs.readFileSync(file, "utf8") }; } catch (error) { throw new RunsError(`cannot read ${file}: ${error.code ?? error.message}`); } } // The active release pointer that scripts/release.sh writes, or null when // no release has been activated. A pointer that isn't the version 1 shape // refuses rather than being guessed at. export function readActivePointer(dataRoot) { const state = readStateFile(dataRoot, POINTER_FILE); if (state === null) return null; let pointer; try { pointer = JSON.parse(state.text); } catch (error) { throw new RunsError(`release pointer is not valid JSON (${state.file}): ${error.message}`, 2); } const invalid = (why) => new RunsError(`release pointer ${why} (${state.file})`, 2); if (typeof pointer !== "object" || pointer === null || Array.isArray(pointer)) throw invalid("must be a JSON object"); for (const key of Object.keys(pointer)) { if (!POINTER_KEYS.includes(key)) throw invalid(`has an unsupported key: "${key}"`); } if (pointer.pointerVersion !== 1) throw invalid(`has unsupported pointerVersion ${JSON.stringify(pointer.pointerVersion)}`); for (const key of ["release", "imageTag", "activatedAt"]) { if (!isNonEmptyString(pointer[key])) throw invalid(`needs a non-empty string "${key}"`); } return { pointerVersion: 1, release: pointer.release, imageTag: pointer.imageTag, activatedAt: pointer.activatedAt }; } function logEntry(line) { let entry; try { entry = JSON.parse(line); } catch { return null; } if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return null; if (Object.keys(entry).some((key) => !LOG_KEYS.includes(key))) return null; if (!["at", "event", "release", "imageTag"].every((key) => typeof entry[key] === "string")) return null; if (entry.note !== undefined && typeof entry.note !== "string") return null; return entry; } // 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)})`); } const state = readStateFile(dataRoot, ACTIVATION_LOG_FILE); if (state === null) return { entries: [], malformed: 0 }; const entries = []; let malformed = 0; for (const line of state.text.split("\n")) { if (line.trim() === "") continue; const entry = logEntry(line); if (entry === null) malformed += 1; else entries.push(entry); } return { entries: last === undefined ? entries : entries.slice(-last), malformed }; }