@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +4 -4
- package/src/capabilities/public-web-publish.test.ts +15 -15
- package/src/cli/commands/alerts-list.ts +10 -0
- package/src/cli/commands/alerts-poll.ts +12 -6
- package/src/cli/commands/alerts-sweep.ts +22 -81
- package/src/cli/commands/backup-sweep.ts +65 -0
- package/src/cli/commands/module-operations.test.ts +93 -0
- package/src/cli/commands/module-operations.ts +134 -0
- package/src/cli/commands/module-upgrade.test.ts +32 -20
- package/src/cli/commands/module-upgrade.ts +37 -32
- package/src/cli/commands/monitor.ts +26 -6
- package/src/cli/commands/system-audit.ts +9 -68
- package/src/cli/completion.ts +18 -1
- package/src/cli/index.ts +11 -0
- package/src/db/schema.ts +5 -3
- package/src/hooks/capability-loader.ts +0 -12
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +18 -51
- package/src/services/alerting/delivery-loop.test.ts +5 -1
- package/src/services/alerting/format.test.ts +0 -1
- package/src/services/alerting/inbound-poller.test.ts +44 -8
- package/src/services/alerting/inbound-poller.ts +65 -28
- package/src/services/alerting/notify-deps.ts +113 -0
- package/src/services/alerting/run-monitor.ts +0 -1
- package/src/services/alerting/store.test.ts +1 -1
- package/src/services/alerting/store.ts +0 -2
- package/src/services/alerting/sweep-runner.test.ts +11 -2
- package/src/services/alerting/sweep-runner.ts +14 -7
- package/src/services/audit/backup-source.ts +54 -0
- package/src/services/audit/backups.test.ts +7 -2
- package/src/services/audit/backups.ts +10 -18
- package/src/services/backup-cipher.test.ts +188 -0
- package/src/services/backup-cipher.ts +178 -0
- package/src/services/backup-create.ts +20 -30
- package/src/services/backup-envelope-roundtrip.test.ts +6 -26
- package/src/services/backup-restore.ts +10 -16
- package/src/services/backup-schedule.ts +35 -0
- package/src/services/backup-sweep.test.ts +148 -0
- package/src/services/backup-sweep.ts +124 -0
- package/src/services/deploy-posture.ts +15 -2
- package/src/services/machine-probe.test.ts +50 -0
- package/src/services/machine-probe.ts +73 -0
- package/src/services/module-operations.test.ts +67 -6
- package/src/services/module-operations.ts +69 -19
- package/src/services/module-subscriptions.test.ts +33 -2
- package/src/services/module-subscriptions.ts +10 -1
- package/src/services/module-validator/typescript-build.test.ts +20 -1
- package/src/services/module-validator/typescript-build.ts +9 -5
- package/src/services/restore-from-file.ts +6 -21
- package/src/templates/generator.test.ts +88 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
+
});
|