@celilo/cli 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +4 -4
  6. package/src/capabilities/public-web-publish.test.ts +15 -15
  7. package/src/cli/commands/alerts-list.ts +10 -0
  8. package/src/cli/commands/alerts-poll.ts +12 -6
  9. package/src/cli/commands/alerts-sweep.ts +22 -81
  10. package/src/cli/commands/backup-sweep.ts +65 -0
  11. package/src/cli/commands/module-operations.test.ts +93 -0
  12. package/src/cli/commands/module-operations.ts +134 -0
  13. package/src/cli/commands/module-upgrade.test.ts +32 -20
  14. package/src/cli/commands/module-upgrade.ts +37 -32
  15. package/src/cli/commands/monitor.ts +26 -6
  16. package/src/cli/commands/system-audit.ts +9 -68
  17. package/src/cli/completion.ts +18 -1
  18. package/src/cli/index.ts +11 -0
  19. package/src/db/schema.ts +5 -3
  20. package/src/hooks/capability-loader.ts +0 -12
  21. package/src/manifest/schema.ts +4 -1
  22. package/src/module/packaging/build.ts +4 -0
  23. package/src/services/alerting/builtin-source.ts +18 -51
  24. package/src/services/alerting/delivery-loop.test.ts +5 -1
  25. package/src/services/alerting/format.test.ts +0 -1
  26. package/src/services/alerting/inbound-poller.test.ts +44 -8
  27. package/src/services/alerting/inbound-poller.ts +65 -28
  28. package/src/services/alerting/notify-deps.ts +113 -0
  29. package/src/services/alerting/run-monitor.ts +0 -1
  30. package/src/services/alerting/store.test.ts +1 -1
  31. package/src/services/alerting/store.ts +0 -2
  32. package/src/services/alerting/sweep-runner.test.ts +11 -2
  33. package/src/services/alerting/sweep-runner.ts +14 -7
  34. package/src/services/audit/backup-source.ts +54 -0
  35. package/src/services/audit/backups.test.ts +7 -2
  36. package/src/services/audit/backups.ts +10 -18
  37. package/src/services/backup-cipher.test.ts +188 -0
  38. package/src/services/backup-cipher.ts +178 -0
  39. package/src/services/backup-create.ts +20 -30
  40. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  41. package/src/services/backup-restore.ts +10 -16
  42. package/src/services/backup-schedule.ts +35 -0
  43. package/src/services/backup-sweep.test.ts +148 -0
  44. package/src/services/backup-sweep.ts +124 -0
  45. package/src/services/deploy-posture.ts +15 -2
  46. package/src/services/machine-probe.test.ts +50 -0
  47. package/src/services/machine-probe.ts +73 -0
  48. package/src/services/module-operations.test.ts +67 -6
  49. package/src/services/module-operations.ts +69 -19
  50. package/src/services/module-subscriptions.test.ts +33 -2
  51. package/src/services/module-subscriptions.ts +10 -1
  52. package/src/services/module-validator/typescript-build.test.ts +20 -1
  53. package/src/services/module-validator/typescript-build.ts +9 -5
  54. package/src/services/restore-from-file.ts +6 -21
  55. package/src/templates/generator.test.ts +88 -0
  56. package/src/templates/generator.ts +119 -16
@@ -3,15 +3,7 @@
3
3
  * Orchestrates the backup workflow: create temp files, encrypt, upload to storage.
4
4
  */
5
5
 
6
- import {
7
- copyFileSync,
8
- existsSync,
9
- mkdirSync,
10
- readFileSync,
11
- rmSync,
12
- statSync,
13
- writeFileSync,
14
- } from 'node:fs';
6
+ import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
7
  import { tmpdir } from 'node:os';
16
8
  import { join } from 'node:path';
17
9
  import { eq } from 'drizzle-orm';
@@ -21,11 +13,13 @@ import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
21
13
  import { invokeHook } from '../hooks/executor';
22
14
  import { createConsoleLogger } from '../hooks/logger';
23
15
  import type { ModuleManifest } from '../manifest/schema';
24
- import { decryptSecret, encryptSecret } from '../secrets/encryption';
16
+ import { decryptSecret } from '../secrets/encryption';
25
17
  import { getOrCreateMasterKey } from '../secrets/master-key';
26
18
  import { shellEscape } from '../utils/shell';
19
+ import { encryptFileToFile } from './backup-cipher';
27
20
  import { buildManifest } from './backup-manifest';
28
21
  import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
22
+ import type { BackupSchedule } from './backup-schedule';
29
23
  import {
30
24
  createStorageProvider,
31
25
  getBackupStorage,
@@ -55,7 +49,7 @@ export interface BackupCreateResult {
55
49
  error?: string;
56
50
  }
57
51
 
58
- export type BackupSchedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
52
+ export type { BackupSchedule } from './backup-schedule';
59
53
 
60
54
  /**
61
55
  * Resolve the target storage destination
@@ -155,15 +149,13 @@ export async function createSystemStateBackup(
155
149
  const { execSync } = await import('node:child_process');
156
150
  execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
157
151
 
158
- // Encrypt the tar.
159
- const tarData = readFileSync(tarPath);
152
+ // Encrypt the tar, streamed — see backup-cipher.ts. The plaintext is
153
+ // never held in memory, so a large fleet DB can't OOM the snapshot.
160
154
  const masterKey = await getOrCreateMasterKey();
161
- const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
162
-
163
- // Write encrypted payload to temp file
164
155
  const encryptedPath = join(tempDir, 'system.enc');
165
- writeFileSync(encryptedPath, JSON.stringify(encrypted));
156
+ await encryptFileToFile(tarPath, encryptedPath, masterKey);
166
157
 
158
+ const tarSize = statSync(tarPath).size;
167
159
  const encryptedSize = statSync(encryptedPath).size;
168
160
 
169
161
  // Upload to storage
@@ -173,7 +165,7 @@ export async function createSystemStateBackup(
173
165
  completeBackup(record.id, {
174
166
  sizeBytes: encryptedSize,
175
167
  metadata: {
176
- originalSizeBytes: tarData.length,
168
+ originalSizeBytes: tarSize,
177
169
  dbPath,
178
170
  envelopeSchemaVersion: manifest.schemaVersion,
179
171
  },
@@ -411,14 +403,14 @@ export async function createModuleBackup(
411
403
  const { execSync } = await import('node:child_process');
412
404
  execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
413
405
 
414
- // Encrypt the tar
415
- const tarData = readFileSync(tarPath);
406
+ // Encrypt the tar, streamed — see backup-cipher.ts. Module artifacts run
407
+ // to hundreds of MB (forgejo's are ~774 MB); holding one in memory is
408
+ // what OOM-killed that backup.
416
409
  const masterKey = await getOrCreateMasterKey();
417
- const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
418
-
419
410
  const encryptedPath = join(tempDir, 'backup.tar.enc');
420
- writeFileSync(encryptedPath, JSON.stringify(encrypted));
411
+ await encryptFileToFile(tarPath, encryptedPath, masterKey);
421
412
 
413
+ const tarSize = statSync(tarPath).size;
422
414
  const encryptedSize = statSync(encryptedPath).size;
423
415
 
424
416
  // Upload to storage
@@ -429,7 +421,7 @@ export async function createModuleBackup(
429
421
  sizeBytes: encryptedSize,
430
422
  metadata: {
431
423
  artifactCount: hookResult.outputs.artifact_count,
432
- originalSizeBytes: tarData.length,
424
+ originalSizeBytes: tarSize,
433
425
  envelopeSchemaVersion: backupManifest.schemaVersion,
434
426
  },
435
427
  schemaVersion: dataSchemaVersion,
@@ -566,14 +558,12 @@ export async function importModuleBackup(
566
558
  const { execSync } = await import('node:child_process');
567
559
  execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(artifactDir)} .`);
568
560
 
569
- // Encrypt the tar
570
- const tarData = readFileSync(tarPath);
561
+ // Encrypt the tar, streamed — see backup-cipher.ts.
571
562
  const masterKey = await getOrCreateMasterKey();
572
- const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
573
-
574
563
  const encryptedPath = join(tempDir, 'backup.tar.enc');
575
- writeFileSync(encryptedPath, JSON.stringify(encrypted));
564
+ await encryptFileToFile(tarPath, encryptedPath, masterKey);
576
565
 
566
+ const tarSize = statSync(tarPath).size;
577
567
  const encryptedSize = statSync(encryptedPath).size;
578
568
 
579
569
  // Upload to storage
@@ -584,7 +574,7 @@ export async function importModuleBackup(
584
574
  sizeBytes: encryptedSize,
585
575
  metadata: {
586
576
  ...analyzedMetadata,
587
- originalSizeBytes: tarData.length,
577
+ originalSizeBytes: tarSize,
588
578
  },
589
579
  schemaVersion: analyzedSchemaVersion,
590
580
  });
@@ -22,8 +22,8 @@ import { join } from 'node:path';
22
22
  import { closeDb, getDb } from '../db/client';
23
23
  import { runMigrations } from '../db/migrate';
24
24
  import { backups, systemConfig } from '../db/schema';
25
- import { decryptSecret } from '../secrets/encryption';
26
25
  import { getOrCreateMasterKey } from '../secrets/master-key';
26
+ import { decryptFileToFile, encryptFileToFile } from './backup-cipher';
27
27
  import { createSystemStateBackup } from './backup-create';
28
28
  import { MANIFEST_SCHEMA_VERSION, parseManifest } from './backup-manifest';
29
29
  import { restoreSystemStateBackup } from './backup-restore';
@@ -77,15 +77,11 @@ describe('backup envelope round-trip', () => {
77
77
  const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
78
78
  expect(existsSync(artifactPath)).toBe(true);
79
79
 
80
- const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
81
80
  const masterKey = await getOrCreateMasterKey();
82
- const base64 = decryptSecret(encrypted, masterKey);
83
- const tarBytes = Buffer.from(base64, 'base64');
84
-
85
81
  const extractDir = join(dir, 'extract');
86
82
  mkdirSync(extractDir, { recursive: true });
87
83
  const tarPath = join(dir, 'envelope.tar');
88
- writeFileSync(tarPath, tarBytes);
84
+ await decryptFileToFile(artifactPath, tarPath, masterKey);
89
85
  execSync(`tar -xf '${tarPath}' -C '${extractDir}'`);
90
86
 
91
87
  // manifest.json present at root, valid, matches expectations.
@@ -129,14 +125,11 @@ describe('backup envelope round-trip', () => {
129
125
  // Create a valid backup, then poison the manifest by re-packing.
130
126
  const result = await createSystemStateBackup();
131
127
  const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
132
- const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
133
128
  const masterKey = await getOrCreateMasterKey();
134
- const tarBytes = Buffer.from(decryptSecret(encrypted, masterKey), 'base64');
135
-
136
129
  const repackDir = join(dir, 'repack');
137
130
  mkdirSync(repackDir, { recursive: true });
138
131
  const tarPath = join(dir, 'orig.tar');
139
- writeFileSync(tarPath, tarBytes);
132
+ await decryptFileToFile(artifactPath, tarPath, masterKey);
140
133
  execSync(`tar -xf '${tarPath}' -C '${repackDir}'`);
141
134
 
142
135
  // Rewrite manifest to claim kind='module'.
@@ -148,12 +141,7 @@ describe('backup envelope round-trip', () => {
148
141
  // Re-tar + re-encrypt + overwrite the storage entry.
149
142
  const repackedTarPath = join(dir, 'repacked.tar');
150
143
  execSync(`tar -cf '${repackedTarPath}' -C '${repackDir}' .`);
151
- const { encryptSecret } = await import('../secrets/encryption');
152
- const repackedEncrypted = encryptSecret(
153
- readFileSync(repackedTarPath).toString('base64'),
154
- masterKey,
155
- );
156
- writeFileSync(artifactPath, JSON.stringify(repackedEncrypted));
144
+ await encryptFileToFile(repackedTarPath, artifactPath, masterKey);
157
145
 
158
146
  const db = getDb();
159
147
  const backupRow = db.select().from(backups).all()[0];
@@ -165,14 +153,11 @@ describe('backup envelope round-trip', () => {
165
153
  it('system restore refuses an incompatible schemaVersion', async () => {
166
154
  const result = await createSystemStateBackup();
167
155
  const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
168
- const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
169
156
  const masterKey = await getOrCreateMasterKey();
170
- const tarBytes = Buffer.from(decryptSecret(encrypted, masterKey), 'base64');
171
-
172
157
  const repackDir = join(dir, 'repack-schema');
173
158
  mkdirSync(repackDir, { recursive: true });
174
159
  const tarPath = join(dir, 'orig-schema.tar');
175
- writeFileSync(tarPath, tarBytes);
160
+ await decryptFileToFile(artifactPath, tarPath, masterKey);
176
161
  execSync(`tar -xf '${tarPath}' -C '${repackDir}'`);
177
162
 
178
163
  // Bump schemaVersion to a different MAJOR version.
@@ -183,12 +168,7 @@ describe('backup envelope round-trip', () => {
183
168
 
184
169
  const repackedTarPath = join(dir, 'repacked-schema.tar');
185
170
  execSync(`tar -cf '${repackedTarPath}' -C '${repackDir}' .`);
186
- const { encryptSecret } = await import('../secrets/encryption');
187
- const repackedEncrypted = encryptSecret(
188
- readFileSync(repackedTarPath).toString('base64'),
189
- masterKey,
190
- );
191
- writeFileSync(artifactPath, JSON.stringify(repackedEncrypted));
171
+ await encryptFileToFile(repackedTarPath, artifactPath, masterKey);
192
172
 
193
173
  const db = getDb();
194
174
  const backupRow = db.select().from(backups).all()[0];
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { execSync } from 'node:child_process';
8
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { eq } from 'drizzle-orm';
@@ -19,6 +19,7 @@ import type { ModuleManifest } from '../manifest/schema';
19
19
  import { decryptSecret } from '../secrets/encryption';
20
20
  import { getOrCreateMasterKey } from '../secrets/master-key';
21
21
  import { shellEscape } from '../utils/shell';
22
+ import { decryptFileToFile } from './backup-cipher';
22
23
  import { assertCompatibleSchema, parseManifest } from './backup-manifest';
23
24
  import { createStorageProvider } from './backup-storage';
24
25
  import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read';
@@ -61,17 +62,13 @@ export async function restoreSystemStateBackup(backup: Backup): Promise<RestoreR
61
62
  const encryptedPath = join(tempDir, 'system.enc');
62
63
  await provider.download(backup.storagePath, encryptedPath);
63
64
 
64
- // Decrypt → tar
65
- const encryptedData = JSON.parse(readFileSync(encryptedPath, 'utf-8'));
65
+ // Decrypt → tar, streamed. Reads both the current format and the legacy
66
+ // JSON envelope (see backup-cipher.ts).
66
67
  const masterKey = await getOrCreateMasterKey();
67
- const base64Data = decryptSecret(encryptedData, masterKey);
68
- const tarData = Buffer.from(base64Data, 'base64');
69
-
70
- // Extract the envelope tar (manifest.json + celilo.db [+celilo.db-wal])
71
68
  const envelopeDir = join(tempDir, 'envelope');
72
69
  mkdirSync(envelopeDir, { recursive: true });
73
70
  const tarPath = join(tempDir, 'envelope.tar');
74
- writeFileSync(tarPath, tarData);
71
+ await decryptFileToFile(encryptedPath, tarPath, masterKey);
75
72
  execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
76
73
 
77
74
  // Read + validate manifest BEFORE touching the live DB. An
@@ -215,17 +212,14 @@ export async function restoreModuleBackup(
215
212
  const encryptedPath = join(tempDir, 'backup.tar.enc');
216
213
  await provider.download(backup.storagePath, encryptedPath);
217
214
 
218
- // Decrypt → tar
219
- const encryptedData = JSON.parse(readFileSync(encryptedPath, 'utf-8'));
220
- const masterKey = await getOrCreateMasterKey();
221
- const base64Data = decryptSecret(encryptedData, masterKey);
222
- const tarData = Buffer.from(base64Data, 'base64');
223
-
224
- // Write tar and extract into envelopeDir. The envelope contains:
215
+ // Decrypt → tar, streamed. Reads both the current format and the legacy
216
+ // JSON envelope (see backup-cipher.ts). Extract into envelopeDir; the
217
+ // envelope contains:
225
218
  // manifest.json - validated below
226
219
  // data/ - on_backup hook artifacts (passed to on_restore)
220
+ const masterKey = await getOrCreateMasterKey();
227
221
  const tarPath = join(tempDir, 'envelope.tar');
228
- writeFileSync(tarPath, tarData);
222
+ await decryptFileToFile(encryptedPath, tarPath, masterKey);
229
223
  execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
230
224
 
231
225
  // Read + validate envelope manifest BEFORE invoking the hook.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * What backup cadence a module actually has.
3
+ *
4
+ * One accessor, deliberately: the drift audit decides whether to ALERT
5
+ * that a backup is stale, and the backup sweep decides whether to RUN
6
+ * one. If those two read the manifest differently, a module can be
7
+ * alerted-on-but-never-backed-up — an alert no human action can clear.
8
+ *
9
+ * Absent means `daily`, not `manual`. Treating "the author didn't say"
10
+ * as "never check and never run" is what let celilo-mgmt go 55 days
11
+ * without a backup and forgejo and signal go without one entirely, all
12
+ * silently. Opting out is a decision worth writing down, so it takes an
13
+ * explicit `schedule: manual`.
14
+ */
15
+
16
+ import type { ModuleManifest } from '../manifest/schema';
17
+
18
+ export type BackupSchedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
19
+
20
+ /** Used when a manifest declares an `on_backup` hook but no cadence. */
21
+ export const DEFAULT_BACKUP_SCHEDULE: BackupSchedule = 'daily';
22
+
23
+ export function effectiveBackupSchedule(manifest: ModuleManifest): BackupSchedule {
24
+ const declared = manifest.backup?.schedule;
25
+ switch (declared) {
26
+ case 'hourly':
27
+ case 'daily':
28
+ case 'weekly':
29
+ case 'monthly':
30
+ case 'manual':
31
+ return declared;
32
+ default:
33
+ return DEFAULT_BACKUP_SCHEDULE;
34
+ }
35
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModuleManifest } from '../manifest/schema';
3
+ import type { BackupSchedule } from './backup-schedule';
4
+ import {
5
+ BACKUP_SWEEP_PATTERN,
6
+ BACKUP_SWEEP_SUBSCRIBER,
7
+ type BackupSweepDeps,
8
+ type BackupSweepModule,
9
+ ensureBackupSweepSubscriber,
10
+ runBackupSweep,
11
+ } from './backup-sweep';
12
+ import { InFlightError } from './module-operations';
13
+
14
+ function moduleWith(id: string, schedule?: BackupSchedule): BackupSweepModule {
15
+ const manifest = {
16
+ id,
17
+ hooks: { on_backup: { script: 'backup.ts' } },
18
+ ...(schedule ? { backup: { schedule } } : {}),
19
+ } as unknown as ModuleManifest;
20
+ return { id, manifest };
21
+ }
22
+
23
+ function deps(
24
+ modules: BackupSweepModule[],
25
+ overrides: Partial<BackupSweepDeps> = {},
26
+ ): BackupSweepDeps & { pruned: string[] } {
27
+ const pruned: string[] = [];
28
+ return {
29
+ pruned,
30
+ listEligible: () => modules,
31
+ isDue: () => true,
32
+ backup: async () => ({ success: true }),
33
+ prune: async ({ id }) => {
34
+ pruned.push(id);
35
+ },
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ describe('runBackupSweep', () => {
41
+ test('backs up a module whose declared cadence is due', async () => {
42
+ const d = deps([moduleWith('authentik', 'daily')]);
43
+ const report = await runBackupSweep(d);
44
+
45
+ expect(report.backedUp).toEqual(['authentik']);
46
+ expect(report.failures).toEqual([]);
47
+ expect(d.pruned).toEqual(['authentik']);
48
+ });
49
+
50
+ test('skips a module that is not yet due', async () => {
51
+ const report = await runBackupSweep(
52
+ deps([moduleWith('authentik', 'daily')], { isDue: () => false }),
53
+ );
54
+
55
+ expect(report.backedUp).toEqual([]);
56
+ expect(report.skippedNotDue).toEqual(['authentik']);
57
+ });
58
+
59
+ test('never auto-backs-up an explicit manual schedule', async () => {
60
+ const report = await runBackupSweep(deps([moduleWith('scratch', 'manual')]));
61
+
62
+ expect(report.backedUp).toEqual([]);
63
+ expect(report.skippedManual).toEqual(['scratch']);
64
+ });
65
+
66
+ test('an undeclared schedule is backed up, not treated as manual', async () => {
67
+ // The regression this whole subsystem exists for: forgejo and signal
68
+ // declare no `backup:` block and had never been backed up.
69
+ const report = await runBackupSweep(deps([moduleWith('forgejo')]));
70
+
71
+ expect(report.backedUp).toEqual(['forgejo']);
72
+ expect(report.skippedManual).toEqual([]);
73
+ });
74
+
75
+ test('a held operation lock is a skip, not a failure, and stops the pass', async () => {
76
+ const d = deps([moduleWith('authentik', 'daily'), moduleWith('forgejo', 'daily')], {
77
+ backup: async () => {
78
+ throw new InFlightError([]);
79
+ },
80
+ });
81
+ const report = await runBackupSweep(d);
82
+
83
+ expect(report.skippedLocked).toEqual(['authentik']);
84
+ expect(report.failures).toEqual([]);
85
+ expect(report.backedUp).toEqual([]);
86
+ expect(d.pruned).toEqual([]);
87
+ });
88
+
89
+ test('a failed backup is recorded and the remaining modules still run', async () => {
90
+ const report = await runBackupSweep(
91
+ deps([moduleWith('authentik', 'daily'), moduleWith('forgejo', 'daily')], {
92
+ backup: async (moduleId) =>
93
+ moduleId === 'authentik' ? { success: false, error: 'hook exited 1' } : { success: true },
94
+ }),
95
+ );
96
+
97
+ expect(report.failures).toEqual([{ moduleId: 'authentik', error: 'hook exited 1' }]);
98
+ expect(report.backedUp).toEqual(['forgejo']);
99
+ });
100
+
101
+ test('a thrown storage error fails only that module', async () => {
102
+ const report = await runBackupSweep(
103
+ deps([moduleWith('authentik', 'daily'), moduleWith('forgejo', 'daily')], {
104
+ backup: async (moduleId) => {
105
+ if (moduleId === 'authentik') throw new Error('Storage not verified');
106
+ return { success: true };
107
+ },
108
+ }),
109
+ );
110
+
111
+ expect(report.failures).toEqual([{ moduleId: 'authentik', error: 'Storage not verified' }]);
112
+ expect(report.backedUp).toEqual(['forgejo']);
113
+ });
114
+
115
+ test('does not prune when a backup failed', async () => {
116
+ const d = deps([moduleWith('authentik', 'daily')], {
117
+ backup: async () => ({ success: false, error: 'nope' }),
118
+ });
119
+ await runBackupSweep(d);
120
+
121
+ expect(d.pruned).toEqual([]);
122
+ });
123
+ });
124
+
125
+ describe('ensureBackupSweepSubscriber', () => {
126
+ test('registers the hourly sweep handler', () => {
127
+ const calls: Array<{
128
+ name: string;
129
+ pattern: string;
130
+ handler: string;
131
+ registeredBy?: string;
132
+ }> = [];
133
+ ensureBackupSweepSubscriber({
134
+ subscribe: (options) => calls.push(options),
135
+ });
136
+
137
+ expect(calls).toEqual([
138
+ {
139
+ name: BACKUP_SWEEP_SUBSCRIBER,
140
+ pattern: BACKUP_SWEEP_PATTERN,
141
+ handler: 'celilo backup sweep',
142
+ registeredBy: 'celilo-backup',
143
+ },
144
+ ]);
145
+ // 1h is the coarsest tick that can still serve an `hourly` cadence.
146
+ expect(BACKUP_SWEEP_PATTERN).toBe('timer.tick.1h');
147
+ });
148
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The thing that actually runs backups on a schedule.
3
+ *
4
+ * A manifest's `backup.schedule` was decorative until this existed: the drift
5
+ * audit reported staleness and `celilo module backup` needed a human. This is
6
+ * the pass that makes a module declaring `daily` get backed up daily with
7
+ * nobody watching.
8
+ *
9
+ * No new scheduling mechanism. Registered as an ordinary bus subscriber whose
10
+ * handler is `celilo backup sweep` against `timer.tick.1h`, exactly like the
11
+ * alerting sweep (`services/alerting/monitors.ts`) — the timer already exists,
12
+ * already survives restarts, and already has retry and dedup. One hour is the
13
+ * coarsest tick that can still serve an `hourly` cadence; `1d` cannot.
14
+ *
15
+ * Dependencies are injected so the pass itself tests with no database, no
16
+ * storage, and no hook execution.
17
+ */
18
+
19
+ import type { ModuleManifest } from '../manifest/schema';
20
+ import { type BackupSchedule, effectiveBackupSchedule } from './backup-schedule';
21
+ import { InFlightError } from './module-operations';
22
+
23
+ export const BACKUP_SWEEP_SUBSCRIBER = 'celilo-backup-sweep';
24
+ export const BACKUP_SWEEP_PATTERN = 'timer.tick.1h';
25
+
26
+ export interface SubscriberRegistrar {
27
+ subscribe(options: {
28
+ name: string;
29
+ pattern: string;
30
+ handler: string;
31
+ registeredBy?: string;
32
+ }): unknown;
33
+ }
34
+
35
+ /**
36
+ * Idempotent: `bus.subscribe` upserts by name, so this is safe to call on
37
+ * every module install and update.
38
+ */
39
+ export function ensureBackupSweepSubscriber(bus: SubscriberRegistrar): void {
40
+ bus.subscribe({
41
+ name: BACKUP_SWEEP_SUBSCRIBER,
42
+ pattern: BACKUP_SWEEP_PATTERN,
43
+ handler: 'celilo backup sweep',
44
+ registeredBy: 'celilo-backup',
45
+ });
46
+ }
47
+
48
+ export interface BackupSweepModule {
49
+ id: string;
50
+ manifest: ModuleManifest;
51
+ }
52
+
53
+ export interface BackupSweepDeps {
54
+ /** Installed modules that declare an `on_backup` hook. */
55
+ listEligible(): BackupSweepModule[];
56
+ isDue(moduleId: string, schedule: BackupSchedule): boolean;
57
+ backup(moduleId: string): Promise<{ success: boolean; error?: string }>;
58
+ /** Apply the module's declared retention. No-op when it declares none. */
59
+ prune(module: BackupSweepModule): Promise<void>;
60
+ }
61
+
62
+ export interface BackupSweepReport {
63
+ backedUp: string[];
64
+ /** Explicit `schedule: manual` — the author opted out. */
65
+ skippedManual: string[];
66
+ skippedNotDue: string[];
67
+ /** Another module operation held the lock. Not a failure; retried next tick. */
68
+ skippedLocked: string[];
69
+ failures: Array<{ moduleId: string; error: string }>;
70
+ }
71
+
72
+ export async function runBackupSweep(deps: BackupSweepDeps): Promise<BackupSweepReport> {
73
+ const report: BackupSweepReport = {
74
+ backedUp: [],
75
+ skippedManual: [],
76
+ skippedNotDue: [],
77
+ skippedLocked: [],
78
+ failures: [],
79
+ };
80
+
81
+ for (const module of deps.listEligible()) {
82
+ const schedule = effectiveBackupSchedule(module.manifest);
83
+ if (schedule === 'manual') {
84
+ report.skippedManual.push(module.id);
85
+ continue;
86
+ }
87
+ if (!deps.isDue(module.id, schedule)) {
88
+ report.skippedNotDue.push(module.id);
89
+ continue;
90
+ }
91
+
92
+ let result: { success: boolean; error?: string };
93
+ try {
94
+ result = await deps.backup(module.id);
95
+ } catch (error) {
96
+ // Refusing to run while a deploy/restore is in flight is correct, and a
97
+ // scheduled run must not bypass it. The lock is global, so once it is
98
+ // held every remaining module would refuse identically — stop the pass
99
+ // rather than collect the same refusal N times. The next tick retries.
100
+ if (error instanceof InFlightError) {
101
+ report.skippedLocked.push(module.id);
102
+ break;
103
+ }
104
+ // Everything else — an unverified storage destination, a missing default
105
+ // — throws before a backup row exists. Record it against the module and
106
+ // keep going; one module's failure must not cancel the rest.
107
+ report.failures.push({
108
+ moduleId: module.id,
109
+ error: error instanceof Error ? error.message : String(error),
110
+ });
111
+ continue;
112
+ }
113
+
114
+ if (!result.success) {
115
+ report.failures.push({ moduleId: module.id, error: result.error ?? 'backup failed' });
116
+ continue;
117
+ }
118
+
119
+ report.backedUp.push(module.id);
120
+ await deps.prune(module);
121
+ }
122
+
123
+ return report;
124
+ }
@@ -3,14 +3,27 @@
3
3
  * openspec/changes/build-bus-poll-cd/proposal.md).
4
4
  *
5
5
  * CD is CI-driven, so no operator is present to pass `--no-backup`. The posture
6
- * (fast = skip backup + extended verify; safe = backup + full verify) is
7
- * DERIVED, with this precedence:
6
+ * (fast = skip the pre-deploy backup; safe = back up first) is DERIVED, with
7
+ * this precedence:
8
8
  * 1. per-module `upgrade_policy: always-safe` — a hard floor (always safe).
9
9
  * 2. per-release `deploy_posture` stamped in the .netapp release metadata.
10
10
  * 3. per-module `upgrade_policy: always-fast`.
11
11
  * 4. default: the semver delta of installed → next (revision/patch = fast;
12
12
  * minor/major = safe).
13
13
  *
14
+ * Posture gates the BACKUP ONLY. Both paths run the same post-deploy verify
15
+ * (module-upgrade.ts calls runModuleHealthCheck either way), and neither
16
+ * gates the deploy: verify is the last step, so a failing health check reports
17
+ * "upgraded but verify failed" on an upgrade that has already landed — there
18
+ * is no rollback and no auto-restore. This comment previously claimed safe
19
+ * posture ran a "full verify" against fast's "extended verify"; no such
20
+ * distinction has ever existed in the code, and believing it makes
21
+ * `auto_upgrade` sound far more guarded than it is.
22
+ *
23
+ * `upgrade_policy` (like `auto_upgrade`) is OPERATOR CONFIG, not a manifest
24
+ * key — `celilo module config set <module> upgrade_policy always-safe`. The
25
+ * manifest schema is `.strict()` and declares neither.
26
+ *
14
27
  * celilo reads version NUMBERS, never changesets — the default needs only the
15
28
  * versions. All functions here are pure (Rule 10).
16
29
  */
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The local management box must never be reported unreachable.
3
+ *
4
+ * celilo runs there as the `celilo` user and deliberately holds no SSH key for
5
+ * itself, so probing `root@127.0.0.1` fails with `Permission denied (publickey)`
6
+ * on a completely healthy host. That produced a permanently firing
7
+ * `machines_reachable` alert against celilo-mgr — and that monitor is
8
+ * unsuppressible by design, so nothing could explain it away.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { auditMachinesReachable } from './audit/machines-reachable';
13
+ import { LOCAL_MACHINE_IP } from './ssh-key-manager';
14
+
15
+ describe('local machine reachability', () => {
16
+ test('LOCAL_MACHINE_IP is the loopback address the machine pool records', () => {
17
+ // The probe's skip is keyed on this exact value, so pin it.
18
+ expect(LOCAL_MACHINE_IP).toBe('127.0.0.1');
19
+ });
20
+
21
+ test('a reachable local box produces no finding', async () => {
22
+ const findings = await auditMachinesReachable({
23
+ results: [
24
+ { id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
25
+ { id: 'briq', hostname: 'briq', ipAddress: '192.168.0.254', reachable: true },
26
+ ],
27
+ });
28
+
29
+ expect(findings).toEqual([]);
30
+ });
31
+
32
+ test('an unreachable remote machine still produces a finding', async () => {
33
+ // The skip must not blunt the check for the machines it exists to watch.
34
+ const findings = await auditMachinesReachable({
35
+ results: [
36
+ { id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
37
+ {
38
+ id: 'briq',
39
+ hostname: 'briq',
40
+ ipAddress: '192.168.0.254',
41
+ reachable: false,
42
+ message: 'connection refused',
43
+ },
44
+ ],
45
+ });
46
+
47
+ expect(findings).toHaveLength(1);
48
+ expect(findings[0].message).toContain('briq');
49
+ });
50
+ });