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
15 changes: 15 additions & 0 deletions scripts/package-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ writeFileSync(
)}\n`,
);

writeFileSync(
join(bundle, "compatibility.json"),
JSON.stringify({
protocol: 1,
startupGate: 1,
app: { min: 0, max: 10, output: 10 },
auth: { min: 0, max: 0, output: 0 },
data: "complete-snapshot-v1",
externalState: "unchanged",
codex: pins.codex.version,
}),
);
for (const file of ["extract.py", "peer-broker.py"])
copyReleaseTree(join(root, "src/updater", file), join(bundle, "cli", file));

const archive = join(output, "roost-linux-x64.tar.gz");

execFileSync(
Expand Down
137 changes: 137 additions & 0 deletions scripts/test-updates-systemd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/** Disposable-guest-only lifecycle driver. Never used by application or helper.
* Bundle with Vite SSR and run as the guest installation user in its own systemd
* unit. The guest must have the explicit marker below and this exact test root. */

import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { UpdateEngine } from "../src/updater/engine";
import { readGate } from "../src/updater/gate";
import { durableJson } from "../src/updater/journal";
import { parseOffer } from "../src/updater/releases";
import { systemdAdapter } from "../src/updater/systemd";

const root = "/home/ubuntu/roost-update-test";
if (!existsSync("/etc/roost-update-disposable") || process.getuid?.() !== 1000)
throw new Error(
"This driver requires the explicitly marked disposable Ubuntu guest.",
);
const config = JSON.parse(await readFile(join(root, "config.json"), "utf8"));
if (config.root !== root || config.user !== "ubuntu")
throw new Error("Fixture installation mismatch.");
const [
version = "0.1.41",
boundary = "none",
mode = "run",
run = randomUUID(),
scenario = "normal",
] = process.argv.slice(2);
const adapter = systemdAdapter(config, join(root, "current", "cli"));
const readyDeadline = Date.now() + 120000;
while (true) {
const open = readGate(root).mode === "open";
let healthy = false;
if (open) {
if (!(await adapter.running())) healthy = true;
else
try {
healthy = (
await fetch(`http://127.0.0.1:${config.port}/api/health`, {
signal: AbortSignal.timeout(2000),
})
).ok;
} catch {
/* Wait for the test app. */
}
}
if (healthy) break;
if (Date.now() > readyDeadline)
throw new Error("Disposable fixture did not become ready.");
await delay(500);
}
// Artifacts are preinstalled copies of the built package in this lifecycle test.
// Network pinning and hostile archive rejection have separate isolated tests.
adapter.stage = async () => {
if (scenario === "staging-failure")
throw new Error("Disposable staging failure");
if (scenario === "cancel") await delay(200);
};
if (scenario === "busy")
adapter.blockers = async () => ["Disposable uncertain worker"];
if (scenario === "rollback-probe")
adapter.probe = async () => {
throw new Error("Disposable readiness failure");
};
// Rollback-boundary cases inject the readiness failure; the independent native
// browser and lifecycle suites exercise real bad-health detection and deadlines.
if (
scenario === "candidate-probe-failure" ||
(scenario === "normal" &&
/restor|failed-data|rolled-back|rollback-/.test(boundary))
) {
const probe = adapter.probe;
adapter.probe = async (target, id, token) => {
if (target === version)
throw new Error("Disposable candidate readiness failure");
return probe(target, id, token);
};
}
const engine = new UpdateEngine(
root,
adapter,
scenario === "busy" ? 50 : 30000,
async (phase) => {
if (phase !== boundary) return;
await durableJson(join(root, "boundary.json"), {
phase,
mode,
run,
version,
pid: process.pid,
boot: (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim(),
operation: (await engine.status())?.id,
reachedAt: Date.now(),
});
if (mode === "pause") {
const deadline = Date.now() + 300000;
while (!existsSync(join(root, `continue-${run}`))) {
if (Date.now() > deadline)
throw new Error("Disposable boundary pause expired");
await delay(100);
}
}
if (mode === "kill") process.kill(process.pid, "SIGKILL");
if (mode === "reboot") await new Promise(() => setInterval(() => {}, 1000));
},
);
const offer = parseOffer(
{
id: 1,
tag_name: `v${version}`,
draft: false,
prerelease: false,
assets: [
{
id: 2,
name: "roost-linux-x64.tar.gz",
size: 1,
digest: `sha256:${"a".repeat(64)}`,
url: "https://api.github.com/repos/srctl/roost/releases/assets/2",
},
],
},
"srctl/roost",
Date.now(),
);
const operation = await engine.accept({
actor: "b".repeat(64),
key: randomUUID(),
offer,
confirmedVersion: version,
});
await writeFile(join(root, "test-operation"), operation.id, { mode: 0o600 });
if (scenario === "cancel") await engine.cancel(operation.id);
await engine.settled();
console.log(JSON.stringify(await engine.status()));
172 changes: 157 additions & 15 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Effect } from "effect";
import { AuthStore } from "../server/auth/store.server";
import { updaterRequest } from "../updater/client";
import { serveUpdater } from "../updater/daemon";
import { enroll, readEnrollment } from "../updater/enrollment";
import { readGate, startupGuard } from "../updater/gate";
import { withKernelLock } from "../updater/lock";
import {
activate,
downloadRelease,
Expand Down Expand Up @@ -45,6 +50,9 @@ const help = `Roost
roost setup [--repository owner/repo] [--port 3000] [--skip-login]
roost setup --login Sign in with the bundled Codex
roost update [--version 0.1.0]
roost updates enroll Explicit operator enrollment (sudo)
roost updates status [--id UUID] Read-only helper diagnostics
roost updates repair --id UUID --decision restore|resume --confirm-version X.Y.Z
roost auth setup --origin https://roost.example.com
roost auth recover [--origin https://roost.example.com]
roost server start
Expand Down Expand Up @@ -256,27 +264,118 @@ async function main() {

return;
}
if (action === "updates") {
const [operation, ...rest] = args;
if (operation === "serve") {
flags(rest, {});
await serveUpdater(root, join(bundle, "cli"));
return;
}
if (operation === "enroll") {
flags(rest, {});
await enroll(await config(), (await readRelease(bundle)).version);
return;
}
if (operation === "repair") {
const options = flags(rest, {
"--id": "value",
"--decision": "value",
"--confirm-version": "value",
});
console.log(
await updaterRequest(root, {
action: "repair",
id: options["--id"],
decision: options["--decision"],
version: options["--confirm-version"],
}),
);
return;
}
if (operation === "status") {
const options = flags(rest, { "--id": "value" });
// Diagnostics are independently available even if the web app/socket is down.
const { readJournal } = await import("../updater/journal");
const { readdir } = await import("node:fs/promises");
const enrollment = await readEnrollment(root);
console.log(
JSON.stringify(
{
unit: serviceName(enrollment.installation),
helper: `roost-${enrollment.installation.uid}-updater.service`,
root,
gate: {
mode: readGate(root).mode,
operation: readGate(root).operation,
},
lockOwner: existsSync(join(root, "updater-owner.json"))
? JSON.parse(
await readFile(join(root, "updater-owner.json"), "utf8"),
)
: null,
},
null,
2,
),
);
for (const id of await readdir(join(root, "updates"))) {
if (
!/^[a-f0-9-]{36}$/.test(id) ||
(options["--id"] && id !== options["--id"])
)
continue;
const journal = await readJournal(root, id);
console.log(
JSON.stringify(
{
id,
phase: journal.phase,
previous: journal.previous,
candidate: journal.candidate,
snapshotDigest: journal.snapshotDigest,
location: join(root, "updates", id),
},
null,
2,
),
);
}
return;
}
throw new Error(
"Use roost updates enroll or status. Recovery runs automatically in the supervised helper; inspect its journal before operator repair.",
);
}
if (action === "auth") {
const [operation, ...rest] = args;
if (operation !== "setup" && operation !== "recover")
throw new Error("Use roost auth setup or recover.");
const options = flags(rest, { "--origin": "value" });
if (operation === "setup" && !options["--origin"])
throw new Error("Specify --origin https://your-host.");
const store = new AuthStore(
resolve(process.env.ROOST_DATA_DIR ?? join(root, "data")),
);
try {
const link = store.setup(options["--origin"], operation === "recover");
console.log(
"Open this private, single-use link within 15 minutes to register your passkey:",
await withKernelLock(root, async () => {
if (
existsSync(join(root, "updater.json")) &&
readGate(root).mode !== "open"
)
throw new Error(
"Authentication recovery must wait for updater recovery.",
);
const store = new AuthStore(
resolve(process.env.ROOST_DATA_DIR ?? join(root, "data")),
);
console.log(link);
if (operation === "recover")
console.log("Previous passkeys and sessions were revoked.");
} finally {
store.close();
}
try {
const link = store.setup(options["--origin"], operation === "recover");
console.log(
"Open this private, single-use link within 15 minutes to register your passkey:",
);
console.log(link);
if (operation === "recover")
console.log("Previous passkeys and sessions were revoked.");
} finally {
store.close();
}
});
return;
}
if (process.platform !== "linux" || process.arch !== "x64")
Expand All @@ -298,7 +397,37 @@ async function main() {
}
if (action === "update") {
const options = flags(args, { "--version": "value" });
await withLock(root, () => update(options));
if (existsSync(join(root, "updater.json"))) {
const status = await updaterRequest<{
latest?: { id: string; version: string; expiresAt: number };
}>(root, { action: "status" });
const offer =
status.latest && status.latest.expiresAt > Date.now()
? status.latest
: await updaterRequest<{ id: string; version: string }>(root, {
action: "check",
});
if (!options["--version"])
throw new Error(
`Confirm the exact offer with roost update --version ${offer.version}. This requires an outage.`,
);
if (options["--version"] !== offer.version)
throw new Error(
"Enrolled CLI updates accept only the pinned latest stable offer.",
);
const { createHash, randomUUID } = await import("node:crypto");
console.log(
await updaterRequest(root, {
action: "accept",
offerId: offer.id,
version: offer.version,
key: randomUUID(),
actor: createHash("sha256")
.update(`operator:${userInfo().uid}`)
.digest("hex"),
}),
);
} else await withLock(root, () => update(options));

return;
}
Expand All @@ -312,6 +441,13 @@ async function main() {
const c = await config();
if (operation === "run") {
const release = await readRelease(bundle);
// Read verification credentials after systemd has switched to the app UID.
// Never ask privileged PID 1 to read a file in this user-writable directory.
const gate = readGate(root);
if (gate.mode === "verify") process.env.ROOST_UPDATE_TOKEN = gate.token;
else delete process.env.ROOST_UPDATE_TOKEN;
startupGuard(root, release.version, process.env.ROOST_UPDATE_TOKEN);
process.env.ROOST_PUBLIC_DIR = join(bundle, "app/public");
process.env.HOST = "127.0.0.1";
process.env.NITRO_HOST = "127.0.0.1";
process.env.PORT = String(c.port);
Expand Down Expand Up @@ -340,11 +476,17 @@ async function main() {
return;
}
if (operation === "start") {
await withLock(root, () => start(c));
if (existsSync(join(root, "updater.json")))
await updaterRequest(root, { action: "start" });
else await withLock(root, () => start(c));

return;
}
if (operation === "stop") {
if (existsSync(join(root, "updater.json"))) {
await updaterRequest(root, { action: "stop" });
return;
}
await withLock(root, async () => {
await service(c, "stop");
console.log(
Expand Down
Loading