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
159 lines
3.9 KiB
TypeScript
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'],
|
|
});
|
|
});
|
|
});
|