Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions src/cli/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import { mkdir, open, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { withKernelLock } from "../updater/lock";

export function maintenance(root: string, enabled: boolean) {
const path = join(root, "data/roost.sqlite");
Expand All @@ -26,11 +27,13 @@ export function activeRuns(root: string): number {

const runs = Number(
db
.prepare("SELECT count(*) AS count FROM runs WHERE status='running'")
.prepare(
"SELECT count(*) AS count FROM runs WHERE status IN ('running','steering')",
)
.get()?.count ?? 0,
);
// Older installations predate coding jobs. A detached Herdr server still
// belongs to Roost's systemd cgroup and will be killed when Roost stops.
// Older installations predate coding jobs. Missing and unknown workers are
// not evidence of quiescence; preserve them for operator inspection.
const hasCodingJobs = db
.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='coding_jobs'",
Expand All @@ -40,7 +43,7 @@ export function activeRuns(root: string): number {
? Number(
db
.prepare(
"SELECT count(*) AS count FROM coding_jobs WHERE status IN ('starting','running') OR (status IN ('blocked','review') AND lastWorkerState NOT IN ('not_started','missing'))",
"SELECT count(*) AS count FROM coding_jobs WHERE status IN ('starting','running') OR (status IN ('blocked','review') AND (lastWorkerState IS NULL OR lastWorkerState != 'not_started'))",
)
.get()?.count ?? 0,
)
Expand All @@ -51,7 +54,7 @@ export function activeRuns(root: string): number {
}
}

export async function withLock<T>(
async function withLegacyLock<T>(
root: string,
action: () => Promise<T>,
): Promise<T> {
Expand Down Expand Up @@ -79,3 +82,16 @@ export async function withLock<T>(
export async function readJson<T>(path: string): Promise<T> {
return JSON.parse(await readFile(path, "utf8")) as T;
}

// Keep the legacy exclusion file as well: older binaries know nothing about flock.
// Enrollment must retain a permanent sentinel before routing clients to a helper.
export async function withLock<T>(
root: string,
action: () => Promise<T>,
): Promise<T> {
if (existsSync(join(root, "updater.json")))
throw new Error(
"This installation is enrolled. Use the supervised updater; setup and legacy direct operations are fenced.",
);
return withKernelLock(root, () => withLegacyLock(root, action));
}
127 changes: 127 additions & 0 deletions src/updater/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/** This protocol is independent of the application release/bundle schema. */
export const updaterProtocol = 1;
export const stableVersion =
/^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})(?![\s\S])/;

export function compareVersions(a: string, b: string) {
if (!stableVersion.test(a) || !stableVersion.test(b))
throw new Error("Invalid stable version.");
const left = a.split(".").map(Number);
const right = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (left[i] !== right[i]) return left[i]! > right[i]! ? 1 : -1;
}
return 0;
}

export type Compatibility = {
protocol: 1;
app: { min: number; max: number; output: number };
auth: { min: number; max: number; output: number };
data: "complete-snapshot-v1";
externalState: "unchanged";
codex: string;
};

export function compatibility(value: unknown): Compatibility {
const c = value as Compatibility;
const range = (r: Compatibility["app"]) =>
r &&
[r.min, r.max, r.output].every(
(n) => Number.isSafeInteger(n) && n >= 0 && n <= 10000,
) &&
r.min <= r.max &&
r.output >= r.max;
if (
!c ||
c.protocol !== updaterProtocol ||
!range(c.app) ||
!range(c.auth) ||
c.data !== "complete-snapshot-v1" ||
c.externalState !== "unchanged" ||
!stableVersion.test(c.codex)
)
throw new Error("Release has no supported update compatibility contract.");
return c;
}

export function assertCompatible(
value: unknown,
installed: { app: number; auth: number; codex: string },
) {
const c = compatibility(value);
if (
installed.app < c.app.min ||
installed.app > c.app.max ||
installed.auth < c.auth.min ||
installed.auth > c.auth.max ||
installed.codex !== c.codex
)
throw new Error(
"Database or bundled Codex compatibility requires terminal maintenance.",
);
return c;
}

export type Capability = {
code:
| "externally-managed"
| "unsupported-platform"
| "unsupported-service-manager"
| "unsupported-distribution"
| "unsupported-storage"
| "setup-required"
| "qualification-required";
reason: string;
canActivate: false;
};

export function capability(facts: {
packaged: boolean;
platform: string;
arch: string;
systemd: boolean;
distribution?: string;
filesystem?: string;
}): Capability {
if (!facts.packaged)
return {
code: "externally-managed",
reason:
"This source or orchestrated installation is externally managed. Update it through its deployment workflow.",
canActivate: false,
};
if (facts.platform !== "linux" || facts.arch !== "x64")
return {
code: "unsupported-platform",
reason: "UI updates initially require a packaged Linux x64 installation.",
canActivate: false,
};
if (!facts.systemd)
return {
code: "unsupported-service-manager",
reason:
"This installation does not have the required systemd supervisor.",
canActivate: false,
};
if (facts.distribution && facts.distribution !== "ubuntu:24.04")
return {
code: "unsupported-distribution",
reason:
"Initial UI update qualification is limited to Ubuntu 24.04. Use operator-managed updates on this distribution.",
canActivate: false,
};
if (facts.filesystem && facts.filesystem !== "ext4")
return {
code: "unsupported-storage",
reason:
"Initial UI updates require persistent local ext4 installation storage. This storage layout requires operator-managed updates.",
canActivate: false,
};
return {
code: "setup-required",
reason:
"Explicit operator enrollment is required. Run roost updates enroll from the packaged terminal CLI; activation also requires a qualified helper build.",
canActivate: false,
};
}
169 changes: 169 additions & 0 deletions src/updater/journal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { mkdir, open, rename, rm } from "node:fs/promises";
import { dirname, join } from "node:path";

export const phases = [
"accepted",
"staged",
"draining",
"stopping",
"snapshot-complete",
"activating",
"verifying",
"committed",
"restoring",
"rolled-back",
"succeeded",
"cancelled",
"deferred",
"failed",
"manual-recovery",
] as const;
export type Phase = (typeof phases)[number];
export type Journal = {
protocol: 1;
id: string;
sequence: number;
phase: Phase;
previous: string;
candidate: string;
wasRunning: boolean;
snapshotDigest?: string;
updatedAt: number;
};
export const operationId =
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}(?![\s\S])/;
const version =
/^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})(?![\s\S])/;

export async function syncDirectory(path: string) {
const fd = await open(path, constants.O_RDONLY | constants.O_DIRECTORY);
try {
await fd.sync();
} finally {
await fd.close();
}
}

export async function durableJson(path: string, value: unknown) {
const temporary = `${path}.${randomUUID()}.tmp`;
const fd = await open(temporary, "wx", 0o600);
try {
await fd.writeFile(`${JSON.stringify(value)}\n`);
await fd.sync();
} finally {
await fd.close();
}
try {
await rename(temporary, path);
await syncDirectory(dirname(path));
} finally {
await rm(temporary, { force: true });
}
}

export function validateJournal(value: unknown): Journal {
const j = value as Journal;
if (
j?.protocol !== 1 ||
!operationId.test(j.id) ||
!Number.isSafeInteger(j.sequence) ||
j.sequence < 1 ||
!phases.includes(j.phase) ||
!version.test(j.previous) ||
!version.test(j.candidate) ||
j.previous === j.candidate ||
typeof j.wasRunning !== "boolean" ||
!Number.isSafeInteger(j.updatedAt) ||
(j.snapshotDigest !== undefined && !/^[a-f0-9]{64}$/.test(j.snapshotDigest))
)
throw new Error("Invalid update journal; manual recovery required.");
if (
[
"snapshot-complete",
"activating",
"verifying",
"committed",
"restoring",
"rolled-back",
"succeeded",
].includes(j.phase) &&
!j.snapshotDigest
)
throw new Error("Update journal has no complete snapshot evidence.");
return j;
}

export async function readJournal(root: string, id: string) {
if (!operationId.test(id)) throw new Error("Invalid operation ID.");
const fd = await open(
join(root, "updates", id, "journal.json"),
constants.O_RDONLY | constants.O_NOFOLLOW,
);
try {
const stat = await fd.stat();
if (!stat.isFile() || stat.size > 16384 || (stat.mode & 0o077) !== 0)
throw new Error("Unsafe update journal; manual recovery required.");
const journal = validateJournal(JSON.parse(await fd.readFile("utf8")));
if (journal.id !== id) throw new Error("Update journal identity mismatch.");
return journal;
} finally {
await fd.close();
}
}

export async function writeJournal(root: string, journal: Journal) {
validateJournal(journal);
const directory = join(root, "updates", journal.id);
await mkdir(dirname(directory), { recursive: true, mode: 0o700 });
await syncDirectory(root);
try {
await mkdir(directory, { mode: 0o700 });
if (journal.sequence !== 1)
throw new Error("Initial journal sequence must be one.");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
const previous = await readJournal(root, journal.id);
if (
journal.sequence !== previous.sequence + 1 ||
journal.previous !== previous.previous ||
journal.candidate !== previous.candidate ||
journal.wasRunning !== previous.wasRunning ||
(previous.snapshotDigest &&
journal.snapshotDigest !== previous.snapshotDigest) ||
(["committed", "succeeded"].includes(previous.phase) &&
!["committed", "succeeded", "manual-recovery"].includes(journal.phase))
)
throw new Error("Conflicting update journal transition.");
}
await syncDirectory(dirname(directory));
await durableJson(join(directory, "journal.json"), journal);
}

/** A recovery plan is deliberately conservative and never infers commit from a
* symlink or from an app answering health requests. Execute under kernel lock. */
export function recoveryPlan(journal: Journal) {
validateJournal(journal);
switch (journal.phase) {
case "accepted":
case "staged":
case "draining":
return "preserve-old";
case "stopping":
return "probe-old-behind-gate";
case "snapshot-complete":
case "activating":
case "verifying":
case "restoring":
return "restore-matching-pair";
case "committed":
return "finish-commit-never-restore";
case "rolled-back":
return "finish-rollback";
case "manual-recovery":
return "manual-recovery";
default:
return "terminal";
}
}
Loading