clawdie-ai/setup/system-update.test.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

159 lines
3.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
parseArgs,
parseFreeBSDVersion,
parsePkgAudit,
parseReleaseList,
rebootPending,
selectJailUpdateTargets,
} from './system-update.js';
describe('parseArgs', () => {
it('defaults to host runtime and no dry-run', () => {
expect(parseArgs([])).toEqual({ dryRun: false, dbRuntime: 'host' });
});
it('accepts --dry-run', () => {
expect(parseArgs(['--dry-run'])).toEqual({
dryRun: true,
dbRuntime: 'host',
});
});
it('accepts --db-runtime jail', () => {
expect(parseArgs(['--db-runtime', 'jail'])).toEqual({
dryRun: false,
dbRuntime: 'jail',
});
});
});
describe('parseFreeBSDVersion', () => {
it('parses family and patchlevel', () => {
expect(parseFreeBSDVersion('15.0-RELEASE-p8')).toEqual({
raw: '15.0-RELEASE-p8',
family: '15.0-RELEASE',
patchLevel: 8,
});
});
it('handles versions without patch suffix', () => {
expect(parseFreeBSDVersion('15.0-RELEASE')).toEqual({
raw: '15.0-RELEASE',
family: '15.0-RELEASE',
patchLevel: null,
});
});
});
describe('parseReleaseList', () => {
it('parses non-empty release lines', () => {
expect(parseReleaseList('\n15.0-RELEASE-p4\n\n15.0-RELEASE-p8\n')).toEqual([
'15.0-RELEASE-p4',
'15.0-RELEASE-p8',
]);
});
});
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', () => {
const rows = [
{
jid: '1',
name: 'git',
ip: '192.168.72.2',
state: 'running' as const,
type: 'thin',
release: '15.0-RELEASE-p4',
raw: '',
},
{
jid: '2',
name: 'cms',
ip: '192.168.72.3',
state: 'running' as const,
type: 'thick',
release: '15.0-RELEASE-p4',
raw: '',
},
{
jid: '3',
name: 'clawdie-db',
ip: '192.168.72.5',
state: 'running' as const,
type: 'thick',
release: '15.0-RELEASE-p4',
raw: '',
},
];
it('updates thin jails and skips unrelated thick jails in host mode', () => {
expect(selectJailUpdateTargets(rows, 'host', '192.168.72.5')).toEqual({
thinJails: ['git'],
dbJail: null,
skippedThickJails: ['cms', 'clawdie-db'],
});
});
it('selects the db jail separately in jail mode', () => {
expect(selectJailUpdateTargets(rows, 'jail', '192.168.72.5')).toEqual({
thinJails: ['git'],
dbJail: 'clawdie-db',
skippedThickJails: ['cms'],
});
});
it('falls back to canonical db jail names when the deployed IP does not match', () => {
expect(
selectJailUpdateTargets(rows, 'jail', '192.168.72.250', [
'clawdie-db',
'db',
]),
).toEqual({
thinJails: ['git'],
dbJail: 'clawdie-db',
skippedThickJails: ['cms'],
});
});
});