// Lifecycle Task 2 (#797 / Gitea #1839): what an exact project addition looks like. The organization // writer's addProject calls additionProblem on the document it is about to publish, and again on the // bytes it will write. The bootstrap executor names the paths it reserves with managedPaths, so the // writer and the executor cannot disagree about the layout. import { lstatSync, readFileSync } from "node:fs"; import { isAbsolute, join, normalize } from "node:path"; import { canonicalJson } from "./util.mjs"; import { ID_PATTERN } from "./vocabulary.mjs"; export const ADDITION_VERB = "project.add"; const RECEIPT_KEYS = ["data_root", "plan_digest", "project", "request"]; const ENTRY_KEYS = ["remote", "repo", "root"]; const plain = x => x !== null && typeof x === "object" && !Array.isArray(x); const REQUEST_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/; // A managed product's code checkout and its records repository, from the data root alone. export function managedPaths({ dataRoot, business, project }) { return { root: join(dataRoot, "products", business, project, "code"), repo: join(dataRoot, "projects", business, project) }; } // null when `next` is `old` plus exactly the planned `projects[project]` entry, else the reason. export function additionProblem({ old, next, project, entry, receipt }) { if (typeof project !== "string" || !ID_PATTERN.test(project)) return "project key is not an id"; if (!plain(receipt) || Object.keys(receipt).sort().join() !== RECEIPT_KEYS.join()) return "receipt has the wrong keys"; if (typeof receipt.request !== "string" || receipt.request.length > 160 || !REQUEST_ID.test(receipt.request)) return "receipt request is not an id"; if (typeof receipt.plan_digest !== "string" || !/^[a-f0-9]{64}$/.test(receipt.plan_digest)) return "receipt plan digest is not a digest"; if (receipt.project !== project) return "receipt names another project"; if (typeof receipt.data_root !== "string" || !isAbsolute(receipt.data_root) || normalize(receipt.data_root) !== receipt.data_root) return "receipt data root is not absolute"; if (!plain(entry) || Object.keys(entry).some(k => !ENTRY_KEYS.includes(k))) return "entry holds more than root, repo and remote"; if (typeof entry.root !== "string" || typeof entry.repo !== "string" || ("remote" in entry && typeof entry.remote !== "string")) return "entry root and repo are required strings"; if (!plain(old?.projects) || !plain(next?.projects)) return "business has no project mapping"; const expected = managedPaths({ dataRoot: receipt.data_root, business: old.id, project }); if (entry.root !== expected.root || entry.repo !== expected.repo) return "entry is not the managed product layout"; if (Object.hasOwn(old.projects, project)) return "the business already declares this project"; if (canonicalJson(next.projects) !== canonicalJson({ ...old.projects, [project]: entry })) return "mapping differs from the old mapping plus the entry"; const { projects: _next, ...rest } = next, { projects: _old, ...was } = old; if (canonicalJson(rest) !== canonicalJson(was)) return "a key outside projects changed"; return null; } // The product marker, /products///bootstrap.json. The bootstrap executor writes it // `reserved` before the mapping exists and `complete` once every step before the activation passed, just before the // bus admits the project, so a process that cannot read the request journal (the launcher and the boot config) can // tell a half-built product from a finished one. `complete` alone is not admission: the bus also needs the // committed activate receipt (#802). const MARKER_STATES = ["reserved", "complete"]; export const markerFile = ({ dataRoot, business, project }) => join(dataRoot, "products", business, project, "bootstrap.json"); export function readMarker(file) { let bytes; try { if (!lstatSync(file).isFile()) return { state: "invalid" }; bytes = readFileSync(file, "utf8"); } catch (e) { if (e.code === "ENOENT") return null; return { state: "invalid" }; } try { const m = JSON.parse(bytes); const keys = Object.keys(m ?? {}).sort().join(); if (plain(m) && keys === "plan_digest,project,request,state,version" && m.version === 1 && MARKER_STATES.includes(m.state) && typeof m.project === "string" && typeof m.request === "string" && /^[a-f0-9]{64}$/.test(m.plan_digest)) return m; } catch { /* falls through */ } return { state: "invalid" }; } export const markerBytes = ({ state, request, plan_digest, project }) => JSON.stringify({ version: 1, state, request, plan_digest, project }, null, 2) + "\n"; // Whether an entry is a managed product is a fact of its layout, never of the marker: the mapping a bootstrap // published sits at managedPaths, and the organization writer refuses any other writer that tries to put an entry // there. So 'unmanaged' only for an entry off that layout; 'complete' only for a readable, matching `complete` // marker; 'reserved' for everything else at the layout, including a product whose marker is missing or unreadable. // Neither state means admitted: the bus admits a managed product only on its committed activation receipt, and // the launcher asks the bus before it clones one. export function managedState({ dataRoot, business, project, entry }) { const here = managedPaths({ dataRoot, business, project }); if (!plain(entry) || entry.root !== here.root || entry.repo !== here.repo) return "unmanaged"; const marker = readMarker(markerFile({ dataRoot, business, project })); return marker?.state === "complete" && marker.project === project ? "complete" : "reserved"; }