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
161 lines
4.7 KiB
TypeScript
161 lines
4.7 KiB
TypeScript
/**
|
|
* Step: system-update-cron — Install /etc/cron.d/clawdie-system-update so
|
|
* the platform fetches FreeBSD security patches, audits installed packages,
|
|
* and patches every thin jail every morning before the 8am operator report.
|
|
*
|
|
* The platform never auto-reboots. If a kernel patch lands, system-update
|
|
* surfaces REBOOT_PENDING in the structured status; the operator decides.
|
|
*
|
|
* Schedule: 06:50 daily. Output of the run is appended to the system-update
|
|
* log so the morning report can pick it up.
|
|
*
|
|
* Must run on the FreeBSD host as root.
|
|
*/
|
|
import { execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { logger } from '../src/logger.js';
|
|
import { commandExists, getPlatform, isRoot } from './platform.js';
|
|
import { emitStatus } from './status.js';
|
|
|
|
const CRON_PATH = '/etc/cron.d/clawdie-system-update';
|
|
const WRAPPER_PATH = '/usr/local/sbin/clawdie-system-update';
|
|
const LOG_PATH = '/var/log/clawdie-system-update.log';
|
|
const DEFAULT_SCHEDULE = '50 6 * * *';
|
|
|
|
export interface CronArgs {
|
|
schedule: string;
|
|
projectRoot: string;
|
|
npxPath: string;
|
|
}
|
|
|
|
export function parseArgs(args: string[]): CronArgs {
|
|
let schedule = process.env.SYSTEM_UPDATE_CRON || DEFAULT_SCHEDULE;
|
|
let projectRoot = process.cwd();
|
|
let npxPath = '/usr/local/bin/npx';
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg === '--schedule') {
|
|
schedule = (args[i + 1] || '').trim();
|
|
i++;
|
|
continue;
|
|
}
|
|
if (arg === '--project-root') {
|
|
projectRoot = (args[i + 1] || '').trim();
|
|
i++;
|
|
continue;
|
|
}
|
|
if (arg === '--npx') {
|
|
npxPath = (args[i + 1] || '').trim();
|
|
i++;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
|
|
if (!schedule || schedule.split(/\s+/u).filter(Boolean).length !== 5) {
|
|
throw new Error(`Invalid cron schedule: '${schedule}' (need 5 fields)`);
|
|
}
|
|
if (!path.isAbsolute(projectRoot)) {
|
|
throw new Error(`--project-root must be absolute: ${projectRoot}`);
|
|
}
|
|
|
|
return { schedule, projectRoot, npxPath };
|
|
}
|
|
|
|
export function buildWrapper(args: CronArgs): string {
|
|
return [
|
|
'#!/bin/sh',
|
|
'# clawdie-system-update — managed by setup/system-update-cron.ts',
|
|
'# Do not edit by hand; regenerate via npx tsx setup/index.ts --step system-update-cron',
|
|
'set -eu',
|
|
`cd "${args.projectRoot}" || exit 1`,
|
|
`exec "${args.npxPath}" tsx setup/system-update.ts "$@"`,
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
export function buildCronEntry(args: CronArgs): string {
|
|
return [
|
|
'# Clawdie — daily system update (managed by setup/system-update-cron.ts)',
|
|
'# Runs before the 08:00 operator status report so the morning summary',
|
|
'# reflects the night\'s patch state. Never auto-reboots.',
|
|
`${args.schedule} root ${WRAPPER_PATH} >> ${LOG_PATH} 2>&1`,
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function writeIfChanged(filePath: string, content: string, mode: number): boolean {
|
|
const existing = fs.existsSync(filePath)
|
|
? fs.readFileSync(filePath, 'utf-8')
|
|
: '';
|
|
if (existing === content) return false;
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content, { mode });
|
|
return true;
|
|
}
|
|
|
|
export async function run(argv: string[]): Promise<void> {
|
|
if (getPlatform() !== 'freebsd') {
|
|
emitStatus('SETUP_SYSTEM_UPDATE_CRON', {
|
|
STATUS: 'skipped',
|
|
REASON: 'not_freebsd',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!isRoot()) {
|
|
emitStatus('SETUP_SYSTEM_UPDATE_CRON', {
|
|
STATUS: 'failed',
|
|
ERROR: 'root_required',
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
const args = parseArgs(argv);
|
|
|
|
// Sanity-check that npx is reachable.
|
|
if (!fs.existsSync(args.npxPath) && !commandExists('npx')) {
|
|
emitStatus('SETUP_SYSTEM_UPDATE_CRON', {
|
|
STATUS: 'failed',
|
|
ERROR: `npx_not_found_at_${args.npxPath}`,
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
const wrapper = buildWrapper(args);
|
|
const cron = buildCronEntry(args);
|
|
|
|
const wrapperChanged = writeIfChanged(WRAPPER_PATH, wrapper, 0o755);
|
|
if (wrapperChanged) {
|
|
logger.info({ path: WRAPPER_PATH }, 'system-update wrapper written');
|
|
}
|
|
|
|
const cronChanged = writeIfChanged(CRON_PATH, cron, 0o644);
|
|
if (cronChanged) {
|
|
logger.info({ path: CRON_PATH }, 'system-update cron entry written');
|
|
}
|
|
|
|
// Touch the log file so cron's append works on first run.
|
|
if (!fs.existsSync(LOG_PATH)) {
|
|
fs.writeFileSync(LOG_PATH, '', { mode: 0o640 });
|
|
try {
|
|
execSync(`chown root:wheel ${LOG_PATH}`, { stdio: 'ignore' });
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
emitStatus('SETUP_SYSTEM_UPDATE_CRON', {
|
|
STATUS: 'success',
|
|
SCHEDULE: args.schedule,
|
|
CRON_PATH,
|
|
WRAPPER_PATH,
|
|
LOG_PATH,
|
|
PROJECT_ROOT: args.projectRoot,
|
|
WRAPPER_CHANGED: wrapperChanged ? 'yes' : 'no',
|
|
CRON_CHANGED: cronChanged ? 'yes' : 'no',
|
|
});
|
|
}
|