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
This commit is contained in:
parent
5e0bd9eb12
commit
5c54aea011
5 changed files with 332 additions and 0 deletions
|
|
@ -27,6 +27,8 @@ const STEPS: Record<
|
||||||
ollama: () => import('./ollama.js'),
|
ollama: () => import('./ollama.js'),
|
||||||
'llama-cpp': () => import('./llama-cpp.js'),
|
'llama-cpp': () => import('./llama-cpp.js'),
|
||||||
sanoid: () => import('./sanoid.js'),
|
sanoid: () => import('./sanoid.js'),
|
||||||
|
'system-update': () => import('./system-update.js'),
|
||||||
|
'system-update-cron': () => import('./system-update-cron.js'),
|
||||||
service: () => import('./service.js'),
|
service: () => import('./service.js'),
|
||||||
hostd: () => import('./hostd.js'),
|
hostd: () => import('./hostd.js'),
|
||||||
verify: () => import('./verify.js'),
|
verify: () => import('./verify.js'),
|
||||||
|
|
|
||||||
58
setup/system-update-cron.test.ts
Normal file
58
setup/system-update-cron.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCronEntry,
|
||||||
|
buildWrapper,
|
||||||
|
parseArgs,
|
||||||
|
} from './system-update-cron.js';
|
||||||
|
|
||||||
|
describe('parseArgs', () => {
|
||||||
|
it('defaults to 06:50 daily and process cwd', () => {
|
||||||
|
const result = parseArgs([]);
|
||||||
|
expect(result.schedule).toBe('50 6 * * *');
|
||||||
|
expect(result.projectRoot.startsWith('/')).toBe(true);
|
||||||
|
expect(result.npxPath).toBe('/usr/local/bin/npx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom schedule', () => {
|
||||||
|
expect(
|
||||||
|
parseArgs(['--schedule', '30 5 * * *', '--project-root', '/opt/clawdie']).schedule,
|
||||||
|
).toBe('30 5 * * *');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed schedule', () => {
|
||||||
|
expect(() => parseArgs(['--schedule', 'every minute'])).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a relative project root', () => {
|
||||||
|
expect(() => parseArgs(['--project-root', 'relative/path'])).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildWrapper', () => {
|
||||||
|
it('produces an executable shell script that exec\'s the system-update step', () => {
|
||||||
|
const wrapper = buildWrapper({
|
||||||
|
schedule: '50 6 * * *',
|
||||||
|
projectRoot: '/home/clawdie/Clawdie-AI',
|
||||||
|
npxPath: '/usr/local/bin/npx',
|
||||||
|
});
|
||||||
|
expect(wrapper.startsWith('#!/bin/sh')).toBe(true);
|
||||||
|
expect(wrapper).toContain('cd "/home/clawdie/Clawdie-AI"');
|
||||||
|
expect(wrapper).toContain('"/usr/local/bin/npx" tsx setup/system-update.ts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCronEntry', () => {
|
||||||
|
it('emits a single cron line at the configured schedule', () => {
|
||||||
|
const entry = buildCronEntry({
|
||||||
|
schedule: '50 6 * * *',
|
||||||
|
projectRoot: '/home/clawdie/Clawdie-AI',
|
||||||
|
npxPath: '/usr/local/bin/npx',
|
||||||
|
});
|
||||||
|
const lines = entry.split('\n').filter((line) => line && !line.startsWith('#'));
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
expect(lines[0]).toMatch(
|
||||||
|
/^50 6 \* \* \* root \/usr\/local\/sbin\/clawdie-system-update >> \/var\/log\/clawdie-system-update\.log 2>&1$/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
161
setup/system-update-cron.ts
Normal file
161
setup/system-update-cron.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseArgs,
|
parseArgs,
|
||||||
parseFreeBSDVersion,
|
parseFreeBSDVersion,
|
||||||
|
parsePkgAudit,
|
||||||
parseReleaseList,
|
parseReleaseList,
|
||||||
|
rebootPending,
|
||||||
selectJailUpdateTargets,
|
selectJailUpdateTargets,
|
||||||
} from './system-update.js';
|
} from './system-update.js';
|
||||||
|
|
||||||
|
|
@ -54,6 +56,47 @@ describe('parseReleaseList', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parsePkgAudit', () => {
|
||||||
|
it('reports clean when no problems are found', () => {
|
||||||
|
expect(parsePkgAudit('0 problem(s) found.')).toEqual({
|
||||||
|
ok: true,
|
||||||
|
vulnerableCount: 0,
|
||||||
|
summary: '0 problem(s) found.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports vulnerable count from a real-shaped audit output', () => {
|
||||||
|
const raw = [
|
||||||
|
'curl-8.7.1 is vulnerable:',
|
||||||
|
' CVE-2024-1234',
|
||||||
|
'',
|
||||||
|
'1 problem(s) found.',
|
||||||
|
].join('\n');
|
||||||
|
const result = parsePkgAudit(raw);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.vulnerableCount).toBe(1);
|
||||||
|
expect(result.summary).toBe('1 problem(s) found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats empty output as clean', () => {
|
||||||
|
expect(parsePkgAudit('').vulnerableCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rebootPending', () => {
|
||||||
|
it('returns false when running kernel matches installed userland', () => {
|
||||||
|
expect(rebootPending('15.0-RELEASE-p4', '15.0-RELEASE-p4')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when patch levels differ', () => {
|
||||||
|
expect(rebootPending('15.0-RELEASE-p4', '15.0-RELEASE-p8')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores surrounding whitespace', () => {
|
||||||
|
expect(rebootPending('15.0-RELEASE-p4\n', ' 15.0-RELEASE-p4 ')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('selectJailUpdateTargets', () => {
|
describe('selectJailUpdateTargets', () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,30 @@ export function parseReleaseList(raw: string): string[] {
|
||||||
.filter(Boolean);
|
.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(
|
export function selectJailUpdateTargets(
|
||||||
rows: BastilleJailRow[],
|
rows: BastilleJailRow[],
|
||||||
dbRuntime: DbRuntime,
|
dbRuntime: DbRuntime,
|
||||||
|
|
@ -207,6 +231,29 @@ export async function run(argv: string[]): Promise<void> {
|
||||||
dbJailNames,
|
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', {
|
emitStatus('SYSTEM_UPDATE', {
|
||||||
STATUS: opts.dryRun ? 'dry-run' : 'planned',
|
STATUS: opts.dryRun ? 'dry-run' : 'planned',
|
||||||
HOST_BEFORE: hostBefore.raw,
|
HOST_BEFORE: hostBefore.raw,
|
||||||
|
|
@ -216,6 +263,8 @@ export async function run(argv: string[]): Promise<void> {
|
||||||
DB_RUNTIME: opts.dbRuntime,
|
DB_RUNTIME: opts.dbRuntime,
|
||||||
DB_JAIL: targets.dbJail || '-',
|
DB_JAIL: targets.dbJail || '-',
|
||||||
SKIPPED_THICK: formatJailList(targets.skippedThickJails),
|
SKIPPED_THICK: formatJailList(targets.skippedThickJails),
|
||||||
|
PKG_AUDIT: pkgAudit.summary,
|
||||||
|
PKG_AUDIT_VULNS: String(pkgAudit.vulnerableCount),
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -258,6 +307,21 @@ export async function run(argv: string[]): Promise<void> {
|
||||||
? jailsAfter.find((row) => row.name === targets.dbJail)?.release || '-'
|
? 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', {
|
emitStatus('SYSTEM_UPDATE', {
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
HOST_AFTER: hostAfter.raw,
|
HOST_AFTER: hostAfter.raw,
|
||||||
|
|
@ -266,6 +330,10 @@ export async function run(argv: string[]): Promise<void> {
|
||||||
THIN_UPDATED: formatJailList(targets.thinJails),
|
THIN_UPDATED: formatJailList(targets.thinJails),
|
||||||
DB_UPDATED: dbAfter,
|
DB_UPDATED: dbAfter,
|
||||||
SKIPPED_THICK: formatJailList(targets.skippedThickJails),
|
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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue