@celilo/cli 0.14.4 → 0.16.1

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 (64) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +19 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +3 -3
  6. package/src/cli/commands/alerts-list.ts +10 -0
  7. package/src/cli/commands/alerts-poll.ts +12 -6
  8. package/src/cli/commands/alerts-sweep.ts +22 -81
  9. package/src/cli/commands/backup-sweep.ts +65 -0
  10. package/src/cli/commands/module-config.test.ts +77 -1
  11. package/src/cli/commands/module-config.ts +45 -3
  12. package/src/cli/commands/module-journal.test.ts +47 -0
  13. package/src/cli/commands/module-journal.ts +98 -0
  14. package/src/cli/commands/module-operations.test.ts +93 -0
  15. package/src/cli/commands/module-operations.ts +134 -0
  16. package/src/cli/commands/module-upgrade.test.ts +32 -20
  17. package/src/cli/commands/module-upgrade.ts +37 -32
  18. package/src/cli/commands/monitor.ts +26 -6
  19. package/src/cli/commands/system-audit.ts +3 -30
  20. package/src/cli/completion.ts +20 -1
  21. package/src/cli/generate-zsh-completion.ts +4 -0
  22. package/src/cli/index.ts +14 -0
  23. package/src/db/schema.ts +5 -3
  24. package/src/manifest/schema.ts +4 -1
  25. package/src/module/packaging/build.ts +4 -0
  26. package/src/services/alerting/builtin-source.ts +17 -2
  27. package/src/services/alerting/delivery-loop.test.ts +5 -1
  28. package/src/services/alerting/format.test.ts +0 -1
  29. package/src/services/alerting/inbound-poller.test.ts +235 -8
  30. package/src/services/alerting/inbound-poller.ts +95 -34
  31. package/src/services/alerting/inbound.test.ts +213 -2
  32. package/src/services/alerting/inbound.ts +161 -32
  33. package/src/services/alerting/interview-responder.test.ts +0 -32
  34. package/src/services/alerting/interview-responder.ts +6 -17
  35. package/src/services/alerting/notify-deps.ts +113 -0
  36. package/src/services/alerting/run-monitor.ts +0 -1
  37. package/src/services/alerting/store.test.ts +1 -1
  38. package/src/services/alerting/store.ts +0 -2
  39. package/src/services/alerting/sweep-runner.test.ts +11 -2
  40. package/src/services/alerting/sweep-runner.ts +14 -7
  41. package/src/services/alerting/tokens.ts +39 -1
  42. package/src/services/audit/backup-source.ts +54 -0
  43. package/src/services/audit/backups.test.ts +7 -2
  44. package/src/services/audit/backups.ts +10 -18
  45. package/src/services/backup-cipher.test.ts +188 -0
  46. package/src/services/backup-cipher.ts +178 -0
  47. package/src/services/backup-create.ts +20 -30
  48. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  49. package/src/services/backup-restore.ts +10 -16
  50. package/src/services/backup-schedule.ts +35 -0
  51. package/src/services/backup-sweep.test.ts +148 -0
  52. package/src/services/backup-sweep.ts +124 -0
  53. package/src/services/deploy-posture.ts +15 -2
  54. package/src/services/module-journal.test.ts +302 -0
  55. package/src/services/module-journal.ts +160 -0
  56. package/src/services/module-operations.test.ts +67 -6
  57. package/src/services/module-operations.ts +69 -19
  58. package/src/services/module-subscriptions.test.ts +33 -2
  59. package/src/services/module-subscriptions.ts +10 -1
  60. package/src/services/module-validator/typescript-build.test.ts +20 -1
  61. package/src/services/module-validator/typescript-build.ts +9 -5
  62. package/src/services/restore-from-file.ts +6 -21
  63. package/src/templates/generator.test.ts +88 -0
  64. package/src/templates/generator.ts +119 -16
@@ -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
  */