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
352 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
}
|