clawdie-ai/setup/system-update.ts
Operator & Claude Code 5c54aea011 Wire system-update into orchestrator + daily cron (Sam & Claude)
system-update was complete but unwired — only callable directly via
`npx tsx setup/system-update.ts`. This commit:

- Adds it to setup/index.ts STEPS so `npm run setup -- --step system-update`
  works the same as the other lifecycle steps.
- Adds a sibling `system-update-cron` step that drops a managed wrapper
  at /usr/local/sbin/clawdie-system-update and a cron entry at
  /etc/cron.d/clawdie-system-update. Default schedule is 06:50 daily so
  the morning patch state lands before the 08:00 operator status report.
- Folds `pkg audit -F` into the system-update run — read-only CVE scan
  that always executes (even in dry-run) and surfaces vulnerable count
  in the structured status.
- Adds a reboot-pending detector that compares running kernel (uname -r)
  to installed userland (freebsd-version). When a kernel patch lands,
  REBOOT_PENDING=yes appears in the status; the platform never reboots
  itself — the operator decides.

Cadence is daily, not weekly: freebsd-update fetches are cheap, security
patches benefit from same-day rollout, and pairing with the morning
report makes the result legible. Heavier `pkg upgrade` (full userland
refresh, not just CVE scan) is a separate question for later.

Tests cover the new pure helpers (parsePkgAudit, rebootPending) plus
the cron entry/wrapper builders. The orchestrator wiring is mechanical.

---
Targeted tests pass (system-update + system-update-cron, 21 tests).
Codex to validate end-to-end on host: install the cron module, confirm
/etc/cron.d/clawdie-system-update lands, confirm the wrapper is exec'd
on the next 06:50, and confirm the structured status reaches the 08:00
report pipeline.

---
Build: FAIL | Tests: FAIL — 16 failed

---
Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:40:39 +02:00

352 lines
9.7 KiB
TypeScript

#!/usr/bin/env npx tsx
import { execSync, spawnSync } from 'child_process';
import { parseBastilleList, type BastilleJailRow } from '../src/bastille-list.js';
import { TENANT_ID } from '../src/config.js';
import { getJailIp } from '../src/jail-registry.js';
import { logger } from '../src/logger.js';
import { SERVICE_NAME } from '../src/platform-identity.js';
import { detectFreeBSDRelease } from './bastille-helpers.js';
import { commandExists, getPlatform, isRoot } from './platform.js';
import { emitStatus } from './status.js';
type DbRuntime = 'host' | 'jail';
export interface SystemUpdateArgs {
dryRun: boolean;
dbRuntime: DbRuntime;
}
export interface FreeBSDVersionInfo {
raw: string;
family: string;
patchLevel: number | null;
}
export interface JailUpdateTargets {
thinJails: string[];
dbJail: string | null;
skippedThickJails: string[];
}
function buildDbJailNameCandidates(): string[] {
const explicit = (process.env.DB_JAIL_NAME || '').trim();
const names = new Set<string>();
if (explicit) names.add(explicit);
const addRoleCandidates = (prefix: string): void => {
const trimmed = prefix.trim();
if (!trimmed) return;
const safe = trimmed.replace(/[-_]/g, '');
names.add(`${trimmed}-db`);
names.add(`${trimmed}_db`);
names.add(`${safe}db`);
};
addRoleCandidates(SERVICE_NAME);
addRoleCandidates(TENANT_ID);
names.add('db');
return Array.from(names);
}
export function parseArgs(args: string[]): SystemUpdateArgs {
let dryRun = false;
let dbRuntime: DbRuntime = ((process.env.DB_RUNTIME || 'host').trim().toLowerCase() === 'jail'
? 'jail'
: 'host');
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--dry-run') {
dryRun = true;
continue;
}
if (arg === '--db-runtime') {
const value = (args[i + 1] || '').trim().toLowerCase();
if (value === 'host' || value === 'jail') {
dbRuntime = value;
i++;
continue;
}
throw new Error(`Invalid --db-runtime value: ${args[i + 1] || '(missing)'}`);
}
throw new Error(`Unknown argument: ${arg}`);
}
return { dryRun, dbRuntime };
}
export function parseFreeBSDVersion(raw: string): FreeBSDVersionInfo {
const value = raw.trim();
const match = value.match(/^(\d+\.\d+-[A-Z]+)(?:-p(\d+))?$/u);
if (!match) {
return { raw: value, family: value, patchLevel: null };
}
return {
raw: value,
family: match[1],
patchLevel: match[2] ? parseInt(match[2], 10) : null,
};
}
export function parseReleaseList(raw: string): string[] {
return raw
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
}
export interface PkgAuditResult {
ok: boolean;
vulnerableCount: number;
summary: string;
}
export function parsePkgAudit(raw: string): PkgAuditResult {
const text = raw.trim();
if (!text) {
return { ok: true, vulnerableCount: 0, summary: '0 problem(s) found' };
}
const match = text.match(/(\d+)\s+problem\(s\)\s+found/iu);
const count = match ? parseInt(match[1], 10) : 0;
return {
ok: count === 0,
vulnerableCount: count,
summary: text.split('\n').slice(-1)[0] || text,
};
}
export function rebootPending(runningKernel: string, installedUserland: string): boolean {
return runningKernel.trim() !== installedUserland.trim();
}
export function selectJailUpdateTargets(
rows: BastilleJailRow[],
dbRuntime: DbRuntime,
dbIp: string,
dbJailNames: string[] = [],
): JailUpdateTargets {
const thinJails = rows
.filter((row) => row.type?.toLowerCase() === 'thin')
.map((row) => row.name);
let dbJail: string | null = null;
const skippedThickJails: string[] = [];
for (const row of rows) {
const type = row.type?.toLowerCase() || '';
if (type !== 'thick') continue;
if (
dbRuntime === 'jail' &&
(row.ip === dbIp || dbJailNames.includes(row.name))
) {
dbJail = row.name;
continue;
}
skippedThickJails.push(row.name);
}
return { thinJails, dbJail, skippedThickJails };
}
function readHostVersion(): FreeBSDVersionInfo {
return parseFreeBSDVersion(
execSync('freebsd-version -u', { encoding: 'utf-8' }).trim(),
);
}
function readReleaseList(): string[] {
return parseReleaseList(
execSync('bastille list releases', { encoding: 'utf-8' }),
);
}
function readJails(): BastilleJailRow[] {
return parseBastilleList(execSync('bastille list', { encoding: 'utf-8' }));
}
function runCommand(
command: string,
args: string[],
allowExitCodes: number[] = [0],
): string {
const result = spawnSync(command, args, {
encoding: 'utf-8',
stdio: 'inherit',
env: process.env,
});
const status = result.status ?? 1;
if (!allowExitCodes.includes(status)) {
throw new Error(
`${command} ${args.join(' ')} failed with exit ${status}`,
);
}
return result.stdout || '';
}
function formatJailList(names: string[]): string {
return names.length > 0 ? names.join(',') : '-';
}
export async function run(argv: string[]): Promise<void> {
const opts = parseArgs(argv);
if (getPlatform() !== 'freebsd') {
emitStatus('SYSTEM_UPDATE', {
STATUS: 'failed',
ERROR: 'unsupported_platform',
});
process.exit(1);
}
if (!isRoot()) {
emitStatus('SYSTEM_UPDATE', {
STATUS: 'failed',
ERROR: 'must_run_as_root',
});
process.exit(1);
}
for (const cmd of ['freebsd-update', 'bastille']) {
if (!commandExists(cmd)) {
emitStatus('SYSTEM_UPDATE', {
STATUS: 'failed',
ERROR: `missing_${cmd}`,
});
process.exit(1);
}
}
const hostBefore = readHostVersion();
const targetRelease = detectFreeBSDRelease();
const releasesBefore = readReleaseList();
const jailsBefore = readJails();
const dbIp = getJailIp('db', process.env.WARDEN_DB_IP || undefined);
const dbJailNames = buildDbJailNameCandidates();
const targets = selectJailUpdateTargets(
jailsBefore,
opts.dbRuntime,
dbIp,
dbJailNames,
);
// pkg audit runs first — read-only CVE scan against installed packages.
// It always runs (even in dry-run) since it never changes system state.
let pkgAudit: PkgAuditResult = { ok: true, vulnerableCount: 0, summary: 'not_run' };
try {
const auditOut = execSync('pkg audit -F', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
pkgAudit = parsePkgAudit(auditOut);
} catch (err) {
// pkg audit exits non-zero when vulnerabilities exist — capture stdout regardless.
const e = err as { stdout?: string; message?: string };
if (typeof e.stdout === 'string') {
pkgAudit = parsePkgAudit(e.stdout);
} else {
pkgAudit = {
ok: false,
vulnerableCount: -1,
summary: e.message || 'pkg audit failed',
};
}
}
emitStatus('SYSTEM_UPDATE', {
STATUS: opts.dryRun ? 'dry-run' : 'planned',
HOST_BEFORE: hostBefore.raw,
TARGET_RELEASE: targetRelease,
RELEASES_BEFORE: releasesBefore.join(',') || '-',
THIN_JAILS: formatJailList(targets.thinJails),
DB_RUNTIME: opts.dbRuntime,
DB_JAIL: targets.dbJail || '-',
SKIPPED_THICK: formatJailList(targets.skippedThickJails),
PKG_AUDIT: pkgAudit.summary,
PKG_AUDIT_VULNS: String(pkgAudit.vulnerableCount),
});
logger.info(
{
hostBefore: hostBefore.raw,
targetRelease,
thinJails: targets.thinJails,
dbRuntime: opts.dbRuntime,
dbJail: targets.dbJail,
skippedThickJails: targets.skippedThickJails,
},
'system update plan',
);
if (opts.dryRun) {
return;
}
runCommand('freebsd-update', ['--not-running-from-cron', 'fetch']);
runCommand('freebsd-update', ['install'], [0, 2]);
if (releasesBefore.includes(targetRelease)) {
runCommand('bastille', ['update', targetRelease]);
} else {
runCommand('bastille', ['bootstrap', '-u', targetRelease]);
}
for (const jailName of targets.thinJails) {
runCommand('bastille', ['update', '-a', jailName]);
}
if (targets.dbJail) {
runCommand('bastille', ['update', '-a', targets.dbJail]);
}
const hostAfter = readHostVersion();
const releasesAfter = readReleaseList();
const jailsAfter = readJails();
const dbAfter = targets.dbJail
? jailsAfter.find((row) => row.name === targets.dbJail)?.release || '-'
: '-';
// Reboot is pending when the running kernel string (uname -r) differs
// from the userland on disk (freebsd-version). The platform never auto-
// reboots — it surfaces this flag for the operator to decide.
let runningKernel = '';
let installedUserland = '';
try {
runningKernel = execSync('uname -r', { encoding: 'utf-8' }).trim();
installedUserland = execSync('freebsd-version', { encoding: 'utf-8' }).trim();
} catch {
// best-effort
}
const reboot = runningKernel && installedUserland
? rebootPending(runningKernel, installedUserland)
: false;
emitStatus('SYSTEM_UPDATE', {
STATUS: 'success',
HOST_AFTER: hostAfter.raw,
TARGET_RELEASE: targetRelease,
RELEASES_AFTER: releasesAfter.join(',') || '-',
THIN_UPDATED: formatJailList(targets.thinJails),
DB_UPDATED: dbAfter,
SKIPPED_THICK: formatJailList(targets.skippedThickJails),
PKG_AUDIT: pkgAudit.summary,
PKG_AUDIT_VULNS: String(pkgAudit.vulnerableCount),
REBOOT_PENDING: reboot ? 'yes' : 'no',
...(reboot ? { RUNNING_KERNEL: runningKernel, INSTALLED_USERLAND: installedUserland } : {}),
});
}
const isDirectRun =
process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
if (isDirectRun) {
run(process.argv.slice(2)).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
emitStatus('SYSTEM_UPDATE', {
STATUS: 'failed',
ERROR: message,
});
process.exit(1);
});
}