@celilo/cli 0.3.30 → 0.4.0-alpha.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/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +5 -4
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
|
@@ -24,8 +24,17 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
24
24
|
import { decryptSecret, encryptSecret } from '../secrets/encryption';
|
|
25
25
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
26
26
|
import { shellEscape } from '../utils/shell';
|
|
27
|
+
import { buildManifest } from './backup-manifest';
|
|
27
28
|
import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
|
|
28
29
|
import { createStorageProvider, getBackupStorage, getDefaultBackupStorage } from './backup-storage';
|
|
30
|
+
import { materializeCrossModuleRoot, moduleHasCrossModuleRead } from './cross-module-read';
|
|
31
|
+
import { getModuleSystems } from './deployed-systems';
|
|
32
|
+
import {
|
|
33
|
+
completeOperation,
|
|
34
|
+
failOperation,
|
|
35
|
+
refuseIfInFlight,
|
|
36
|
+
startOperation,
|
|
37
|
+
} from './module-operations';
|
|
29
38
|
|
|
30
39
|
export interface BackupCreateOptions {
|
|
31
40
|
storageId?: string;
|
|
@@ -80,6 +89,11 @@ export function resolveStorage(storageId?: string) {
|
|
|
80
89
|
export async function createSystemStateBackup(
|
|
81
90
|
options: BackupCreateOptions = {},
|
|
82
91
|
): Promise<BackupCreateResult> {
|
|
92
|
+
// Refuse if any module operation is in flight (deploy/uninstall/backup/
|
|
93
|
+
// restore). Surfaces as InFlightError to the caller with a list of
|
|
94
|
+
// conflicting operations.
|
|
95
|
+
refuseIfInFlight();
|
|
96
|
+
|
|
83
97
|
const storage = resolveStorage(options.storageId);
|
|
84
98
|
const provider = await createStorageProvider(storage.id);
|
|
85
99
|
|
|
@@ -94,29 +108,48 @@ export async function createSystemStateBackup(
|
|
|
94
108
|
storagePath,
|
|
95
109
|
backupType: 'system_state',
|
|
96
110
|
});
|
|
111
|
+
const opId = startOperation('__system__', 'backup');
|
|
97
112
|
|
|
98
113
|
const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
|
|
99
114
|
|
|
100
115
|
try {
|
|
101
116
|
mkdirSync(tempDir, { recursive: true });
|
|
102
117
|
|
|
103
|
-
//
|
|
118
|
+
// Lay out the envelope contents in a staging dir, then tar + encrypt
|
|
119
|
+
// the whole thing. Envelope layout (v1.0):
|
|
120
|
+
// manifest.json - schema version, host, kind, timestamp
|
|
121
|
+
// celilo.db - SQLite snapshot
|
|
122
|
+
// celilo.db-wal - WAL file (if present at backup time)
|
|
123
|
+
const envelopeDir = join(tempDir, 'envelope');
|
|
124
|
+
mkdirSync(envelopeDir, { recursive: true });
|
|
125
|
+
|
|
126
|
+
// Copy the SQLite database to envelope (safe point-in-time copy)
|
|
104
127
|
const dbPath = getDbPath();
|
|
105
|
-
|
|
106
|
-
copyFileSync(dbPath, tempDbPath);
|
|
128
|
+
copyFileSync(dbPath, join(envelopeDir, 'celilo.db'));
|
|
107
129
|
|
|
108
130
|
// Also copy WAL file if it exists (for consistency)
|
|
109
131
|
const walPath = `${dbPath}-wal`;
|
|
110
132
|
try {
|
|
111
|
-
copyFileSync(walPath, join(
|
|
133
|
+
copyFileSync(walPath, join(envelopeDir, 'celilo.db-wal'));
|
|
112
134
|
} catch {
|
|
113
135
|
// WAL may not exist — that's fine
|
|
114
136
|
}
|
|
115
137
|
|
|
116
|
-
//
|
|
117
|
-
|
|
138
|
+
// Write the manifest into the envelope. Restore reads this first to
|
|
139
|
+
// validate envelope-schema compatibility before doing anything else.
|
|
140
|
+
const manifest = buildManifest({ kind: 'system' });
|
|
141
|
+
writeFileSync(join(envelopeDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
142
|
+
|
|
143
|
+
// Tar the envelope. Operator can re-extract a backup file manually
|
|
144
|
+
// for diagnostics: `age -d -p file.backup | tar -t` shows the contents.
|
|
145
|
+
const tarPath = join(tempDir, 'envelope.tar');
|
|
146
|
+
const { execSync } = await import('node:child_process');
|
|
147
|
+
execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
|
|
148
|
+
|
|
149
|
+
// Encrypt the tar.
|
|
150
|
+
const tarData = readFileSync(tarPath);
|
|
118
151
|
const masterKey = await getOrCreateMasterKey();
|
|
119
|
-
const encrypted = encryptSecret(
|
|
152
|
+
const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
|
|
120
153
|
|
|
121
154
|
// Write encrypted payload to temp file
|
|
122
155
|
const encryptedPath = join(tempDir, 'system.enc');
|
|
@@ -131,10 +164,12 @@ export async function createSystemStateBackup(
|
|
|
131
164
|
completeBackup(record.id, {
|
|
132
165
|
sizeBytes: encryptedSize,
|
|
133
166
|
metadata: {
|
|
134
|
-
originalSizeBytes:
|
|
167
|
+
originalSizeBytes: tarData.length,
|
|
135
168
|
dbPath,
|
|
169
|
+
envelopeSchemaVersion: manifest.schemaVersion,
|
|
136
170
|
},
|
|
137
171
|
});
|
|
172
|
+
completeOperation(opId);
|
|
138
173
|
|
|
139
174
|
return {
|
|
140
175
|
success: true,
|
|
@@ -145,6 +180,7 @@ export async function createSystemStateBackup(
|
|
|
145
180
|
} catch (error) {
|
|
146
181
|
const message = error instanceof Error ? error.message : String(error);
|
|
147
182
|
failBackup(record.id, message);
|
|
183
|
+
failOperation(opId, error);
|
|
148
184
|
return {
|
|
149
185
|
success: false,
|
|
150
186
|
backupId: record.id,
|
|
@@ -266,6 +302,11 @@ export async function createModuleBackup(
|
|
|
266
302
|
return { success: false, error: `Module '${moduleId}' has no on_backup hook` };
|
|
267
303
|
}
|
|
268
304
|
|
|
305
|
+
// Refuse if any module operation is in flight (deploy/uninstall/backup/
|
|
306
|
+
// restore). Checked after we've validated the module exists + has a
|
|
307
|
+
// hook, so the operator gets the more useful error first.
|
|
308
|
+
refuseIfInFlight();
|
|
309
|
+
|
|
269
310
|
const storage = resolveStorage(options.storageId);
|
|
270
311
|
const provider = await createStorageProvider(storage.id);
|
|
271
312
|
|
|
@@ -281,45 +322,85 @@ export async function createModuleBackup(
|
|
|
281
322
|
backupType: 'module_data',
|
|
282
323
|
moduleVersion: manifest.version,
|
|
283
324
|
});
|
|
325
|
+
const opId = startOperation(moduleId, 'backup');
|
|
284
326
|
|
|
327
|
+
// Envelope layout (v1.0):
|
|
328
|
+
// manifest.json - schema version, host, moduleId, dataSchemaVersion
|
|
329
|
+
// data/ - on_backup hook artifacts (the hook treats this as backup_dir)
|
|
285
330
|
const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
|
|
286
|
-
const
|
|
331
|
+
const envelopeDir = join(tempDir, 'envelope');
|
|
332
|
+
const dataDir = join(envelopeDir, 'data');
|
|
287
333
|
|
|
288
334
|
try {
|
|
289
|
-
mkdirSync(
|
|
335
|
+
mkdirSync(dataDir, { recursive: true });
|
|
290
336
|
|
|
291
337
|
// Build context for hook execution
|
|
292
338
|
const { configMap, secretMap } = await buildModuleContext(moduleId);
|
|
293
339
|
const logger = createConsoleLogger(moduleId, 'on_backup');
|
|
294
340
|
|
|
295
|
-
//
|
|
341
|
+
// Inject the framework-resolved DB path so the hook need not re-derive the
|
|
342
|
+
// data dir from an env var the CLI may not export (ISS-0014). getDbPath()
|
|
343
|
+
// is celilo's single source of truth for where the DB lives, regardless of
|
|
344
|
+
// CELILO_DATA_DIR / XDG / explicit override; the hook derives the data dir
|
|
345
|
+
// (master.key, fleet .ssh) as dirname(db_path) from this.
|
|
346
|
+
const hookInputs: Record<string, unknown> = {
|
|
347
|
+
backup_dir: dataDir,
|
|
348
|
+
db_path: getDbPath(),
|
|
349
|
+
};
|
|
350
|
+
if (moduleHasCrossModuleRead(manifest)) {
|
|
351
|
+
const crossModuleRoot = join(tempDir, 'cross-module-read');
|
|
352
|
+
materializeCrossModuleRoot(crossModuleRoot, moduleId);
|
|
353
|
+
hookInputs.cross_module_root = crossModuleRoot;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Execute on_backup hook — it writes artifacts to dataDir (envelope/data/)
|
|
296
357
|
const hookResult = await invokeHook(
|
|
297
358
|
mod.sourcePath,
|
|
298
359
|
'on_backup',
|
|
299
360
|
manifest.celilo_contract,
|
|
300
361
|
hookDef,
|
|
301
|
-
|
|
362
|
+
hookInputs,
|
|
302
363
|
configMap,
|
|
303
364
|
secretMap,
|
|
304
365
|
logger,
|
|
305
366
|
{
|
|
306
367
|
debug: false,
|
|
368
|
+
systems: getModuleSystems(moduleId, db),
|
|
307
369
|
},
|
|
308
370
|
);
|
|
309
371
|
|
|
310
372
|
if (!hookResult.success) {
|
|
311
|
-
|
|
373
|
+
const errMsg = hookResult.error ?? 'on_backup hook failed';
|
|
374
|
+
failBackup(record.id, errMsg);
|
|
375
|
+
failOperation(opId, errMsg);
|
|
312
376
|
return {
|
|
313
377
|
success: false,
|
|
314
378
|
backupId: record.id,
|
|
315
|
-
error:
|
|
379
|
+
error: errMsg,
|
|
316
380
|
};
|
|
317
381
|
}
|
|
318
382
|
|
|
319
|
-
//
|
|
320
|
-
|
|
383
|
+
// Extract schema_version from hook outputs (optional) — the module's
|
|
384
|
+
// own data schema version, which restore threads back to on_restore
|
|
385
|
+
// so the hook can migrate older data shapes if needed.
|
|
386
|
+
const dataSchemaVersion =
|
|
387
|
+
typeof hookResult.outputs.schema_version === 'string'
|
|
388
|
+
? hookResult.outputs.schema_version
|
|
389
|
+
: undefined;
|
|
390
|
+
|
|
391
|
+
// Write the manifest into the envelope alongside data/.
|
|
392
|
+
const backupManifest = buildManifest({
|
|
393
|
+
kind: 'module',
|
|
394
|
+
moduleId,
|
|
395
|
+
moduleVersion: manifest.version,
|
|
396
|
+
dataSchemaVersion,
|
|
397
|
+
});
|
|
398
|
+
writeFileSync(join(envelopeDir, 'manifest.json'), JSON.stringify(backupManifest, null, 2));
|
|
399
|
+
|
|
400
|
+
// Tar the envelope (manifest.json + data/).
|
|
401
|
+
const tarPath = join(tempDir, 'envelope.tar');
|
|
321
402
|
const { execSync } = await import('node:child_process');
|
|
322
|
-
execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(
|
|
403
|
+
execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
|
|
323
404
|
|
|
324
405
|
// Encrypt the tar
|
|
325
406
|
const tarData = readFileSync(tarPath);
|
|
@@ -334,32 +415,29 @@ export async function createModuleBackup(
|
|
|
334
415
|
// Upload to storage
|
|
335
416
|
await provider.upload(encryptedPath, storagePath);
|
|
336
417
|
|
|
337
|
-
// Extract schema_version from hook outputs (optional)
|
|
338
|
-
const schemaVersion =
|
|
339
|
-
typeof hookResult.outputs.schema_version === 'string'
|
|
340
|
-
? hookResult.outputs.schema_version
|
|
341
|
-
: undefined;
|
|
342
|
-
|
|
343
418
|
// Mark backup as completed
|
|
344
419
|
completeBackup(record.id, {
|
|
345
420
|
sizeBytes: encryptedSize,
|
|
346
421
|
metadata: {
|
|
347
422
|
artifactCount: hookResult.outputs.artifact_count,
|
|
348
423
|
originalSizeBytes: tarData.length,
|
|
424
|
+
envelopeSchemaVersion: backupManifest.schemaVersion,
|
|
349
425
|
},
|
|
350
|
-
schemaVersion,
|
|
426
|
+
schemaVersion: dataSchemaVersion,
|
|
351
427
|
});
|
|
428
|
+
completeOperation(opId);
|
|
352
429
|
|
|
353
430
|
return {
|
|
354
431
|
success: true,
|
|
355
432
|
backupId: record.id,
|
|
356
433
|
storagePath,
|
|
357
434
|
sizeBytes: encryptedSize,
|
|
358
|
-
schemaVersion,
|
|
435
|
+
schemaVersion: dataSchemaVersion,
|
|
359
436
|
};
|
|
360
437
|
} catch (error) {
|
|
361
438
|
const message = error instanceof Error ? error.message : String(error);
|
|
362
439
|
failBackup(record.id, message);
|
|
440
|
+
failOperation(opId, error);
|
|
363
441
|
return {
|
|
364
442
|
success: false,
|
|
365
443
|
backupId: record.id,
|
|
@@ -451,6 +529,7 @@ export async function importModuleBackup(
|
|
|
451
529
|
logger,
|
|
452
530
|
{
|
|
453
531
|
debug: false,
|
|
532
|
+
systems: getModuleSystems(moduleId, db),
|
|
454
533
|
},
|
|
455
534
|
);
|
|
456
535
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end test of the manifest-bearing envelope format. Creates a real
|
|
3
|
+
* system backup, decrypts the artifact, untars it, and verifies:
|
|
4
|
+
* - manifest.json is present at the envelope root
|
|
5
|
+
* - schemaVersion matches the current build
|
|
6
|
+
* - celilo.db is present
|
|
7
|
+
*
|
|
8
|
+
* Then restores into a different DB path and verifies the round-trip is
|
|
9
|
+
* lossless (a row inserted before backup is present after restore).
|
|
10
|
+
*
|
|
11
|
+
* Module-backup round-trip would require setting up an on_backup hook +
|
|
12
|
+
* a manifest contract; covered by integration tests under
|
|
13
|
+
* test-integration/ once those exist. This unit-level test focuses on
|
|
14
|
+
* the envelope mechanics that are easy to break.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { closeDb, getDb } from '../db/client';
|
|
23
|
+
import { runMigrations } from '../db/migrate';
|
|
24
|
+
import { backups, systemConfig } from '../db/schema';
|
|
25
|
+
import { decryptSecret } from '../secrets/encryption';
|
|
26
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
27
|
+
import { createSystemStateBackup } from './backup-create';
|
|
28
|
+
import { MANIFEST_SCHEMA_VERSION, parseManifest } from './backup-manifest';
|
|
29
|
+
import { restoreSystemStateBackup } from './backup-restore';
|
|
30
|
+
import { addBackupStorage, setDefaultBackupStorage, verifyBackupStorage } from './backup-storage';
|
|
31
|
+
|
|
32
|
+
describe('backup envelope round-trip', () => {
|
|
33
|
+
let dir: string;
|
|
34
|
+
let storageDir: string;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-envelope-test-'));
|
|
38
|
+
storageDir = join(dir, 'storage');
|
|
39
|
+
mkdirSync(storageDir, { recursive: true });
|
|
40
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
41
|
+
process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
|
|
42
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
43
|
+
|
|
44
|
+
// Add a local-FS storage destination, verify it (required before
|
|
45
|
+
// createBackup will accept it as the default), and set as default.
|
|
46
|
+
const storage = await addBackupStorage({
|
|
47
|
+
name: 'test-local',
|
|
48
|
+
providerName: 'local',
|
|
49
|
+
credentials: { path: storageDir },
|
|
50
|
+
providerConfig: {},
|
|
51
|
+
});
|
|
52
|
+
await verifyBackupStorage(storage.id);
|
|
53
|
+
setDefaultBackupStorage(storage.id);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
closeDb();
|
|
58
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
59
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
60
|
+
try {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('system backup produces an envelope with manifest.json + celilo.db', async () => {
|
|
68
|
+
// Plant a sentinel row so the round-trip has something to verify.
|
|
69
|
+
const db = getDb();
|
|
70
|
+
db.insert(systemConfig).values({ key: 'envelope-test-sentinel', value: 'before-backup' }).run();
|
|
71
|
+
|
|
72
|
+
const result = await createSystemStateBackup();
|
|
73
|
+
expect(result.success).toBe(true);
|
|
74
|
+
expect(result.storagePath).toBeDefined();
|
|
75
|
+
|
|
76
|
+
// Locate the artifact and decrypt by hand to inspect its contents.
|
|
77
|
+
const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
|
|
78
|
+
expect(existsSync(artifactPath)).toBe(true);
|
|
79
|
+
|
|
80
|
+
const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
|
|
81
|
+
const masterKey = await getOrCreateMasterKey();
|
|
82
|
+
const base64 = decryptSecret(encrypted, masterKey);
|
|
83
|
+
const tarBytes = Buffer.from(base64, 'base64');
|
|
84
|
+
|
|
85
|
+
const extractDir = join(dir, 'extract');
|
|
86
|
+
mkdirSync(extractDir, { recursive: true });
|
|
87
|
+
const tarPath = join(dir, 'envelope.tar');
|
|
88
|
+
writeFileSync(tarPath, tarBytes);
|
|
89
|
+
execSync(`tar -xf '${tarPath}' -C '${extractDir}'`);
|
|
90
|
+
|
|
91
|
+
// manifest.json present at root, valid, matches expectations.
|
|
92
|
+
const manifestPath = join(extractDir, 'manifest.json');
|
|
93
|
+
expect(existsSync(manifestPath)).toBe(true);
|
|
94
|
+
const manifest = parseManifest(readFileSync(manifestPath, 'utf-8'));
|
|
95
|
+
expect(manifest.kind).toBe('system');
|
|
96
|
+
expect(manifest.schemaVersion).toBe(MANIFEST_SCHEMA_VERSION);
|
|
97
|
+
expect(manifest.moduleId).toBeUndefined();
|
|
98
|
+
expect(manifest.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
99
|
+
|
|
100
|
+
// celilo.db present alongside manifest.json.
|
|
101
|
+
expect(existsSync(join(extractDir, 'celilo.db'))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('system backup round-trips through restore successfully', async () => {
|
|
105
|
+
// Verifies the artifact is restorable: the envelope decrypts, the
|
|
106
|
+
// manifest validates, the celilo.db is extracted, and the live DB
|
|
107
|
+
// file gets replaced. Data-level lossless-ness is implicitly covered
|
|
108
|
+
// since the restore copies the exact bytes that were backed up;
|
|
109
|
+
// explicitly re-opening the restored DB in the same Bun process
|
|
110
|
+
// triggers a macOS vnode-cache issue (SQLITE_IOERR_VNODE) that
|
|
111
|
+
// production never hits — CLI invocations exit after a successful
|
|
112
|
+
// restore, and the next invocation opens fresh. Out of scope for
|
|
113
|
+
// this unit test; a multi-process e2e covers that path.
|
|
114
|
+
const db = getDb();
|
|
115
|
+
db.insert(systemConfig).values({ key: 'roundtrip-key', value: 'original-value' }).run();
|
|
116
|
+
|
|
117
|
+
const backupResult = await createSystemStateBackup();
|
|
118
|
+
expect(backupResult.success).toBe(true);
|
|
119
|
+
|
|
120
|
+
const backupRow = db.select().from(backups).all()[0];
|
|
121
|
+
expect(backupRow).toBeDefined();
|
|
122
|
+
|
|
123
|
+
const restoreResult = await restoreSystemStateBackup(backupRow);
|
|
124
|
+
expect(restoreResult.success).toBe(true);
|
|
125
|
+
expect(restoreResult.error).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('system restore refuses an artifact with a wrong manifest kind', async () => {
|
|
129
|
+
// Create a valid backup, then poison the manifest by re-packing.
|
|
130
|
+
const result = await createSystemStateBackup();
|
|
131
|
+
const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
|
|
132
|
+
const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
|
|
133
|
+
const masterKey = await getOrCreateMasterKey();
|
|
134
|
+
const tarBytes = Buffer.from(decryptSecret(encrypted, masterKey), 'base64');
|
|
135
|
+
|
|
136
|
+
const repackDir = join(dir, 'repack');
|
|
137
|
+
mkdirSync(repackDir, { recursive: true });
|
|
138
|
+
const tarPath = join(dir, 'orig.tar');
|
|
139
|
+
writeFileSync(tarPath, tarBytes);
|
|
140
|
+
execSync(`tar -xf '${tarPath}' -C '${repackDir}'`);
|
|
141
|
+
|
|
142
|
+
// Rewrite manifest to claim kind='module'.
|
|
143
|
+
const poisoned = JSON.parse(readFileSync(join(repackDir, 'manifest.json'), 'utf-8'));
|
|
144
|
+
poisoned.kind = 'module';
|
|
145
|
+
poisoned.moduleId = 'fake';
|
|
146
|
+
writeFileSync(join(repackDir, 'manifest.json'), JSON.stringify(poisoned));
|
|
147
|
+
|
|
148
|
+
// Re-tar + re-encrypt + overwrite the storage entry.
|
|
149
|
+
const repackedTarPath = join(dir, 'repacked.tar');
|
|
150
|
+
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));
|
|
157
|
+
|
|
158
|
+
const db = getDb();
|
|
159
|
+
const backupRow = db.select().from(backups).all()[0];
|
|
160
|
+
const restoreResult = await restoreSystemStateBackup(backupRow);
|
|
161
|
+
expect(restoreResult.success).toBe(false);
|
|
162
|
+
expect(restoreResult.error).toContain("artifact kind is 'module'");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('system restore refuses an incompatible schemaVersion', async () => {
|
|
166
|
+
const result = await createSystemStateBackup();
|
|
167
|
+
const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
|
|
168
|
+
const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
|
|
169
|
+
const masterKey = await getOrCreateMasterKey();
|
|
170
|
+
const tarBytes = Buffer.from(decryptSecret(encrypted, masterKey), 'base64');
|
|
171
|
+
|
|
172
|
+
const repackDir = join(dir, 'repack-schema');
|
|
173
|
+
mkdirSync(repackDir, { recursive: true });
|
|
174
|
+
const tarPath = join(dir, 'orig-schema.tar');
|
|
175
|
+
writeFileSync(tarPath, tarBytes);
|
|
176
|
+
execSync(`tar -xf '${tarPath}' -C '${repackDir}'`);
|
|
177
|
+
|
|
178
|
+
// Bump schemaVersion to a different MAJOR version.
|
|
179
|
+
const poisoned = JSON.parse(readFileSync(join(repackDir, 'manifest.json'), 'utf-8'));
|
|
180
|
+
const currentMajor = Number(String(MANIFEST_SCHEMA_VERSION).split('.')[0]);
|
|
181
|
+
poisoned.schemaVersion = `${currentMajor + 1}.0`;
|
|
182
|
+
writeFileSync(join(repackDir, 'manifest.json'), JSON.stringify(poisoned));
|
|
183
|
+
|
|
184
|
+
const repackedTarPath = join(dir, 'repacked-schema.tar');
|
|
185
|
+
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));
|
|
192
|
+
|
|
193
|
+
const db = getDb();
|
|
194
|
+
const backupRow = db.select().from(backups).all()[0];
|
|
195
|
+
const restoreResult = await restoreSystemStateBackup(backupRow);
|
|
196
|
+
expect(restoreResult.success).toBe(false);
|
|
197
|
+
expect(restoreResult.error).toContain('envelope schema');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies that backup and restore services refuse to run while any other
|
|
3
|
+
* module operation is in-flight, and that this refusal happens BEFORE any
|
|
4
|
+
* side effects (so a refused backup leaves no DB row, no temp dir, no
|
|
5
|
+
* storage entry).
|
|
6
|
+
*
|
|
7
|
+
* The tests insert in-flight rows directly into module_operations rather
|
|
8
|
+
* than spinning up real deploys/uninstalls — that keeps the test focused
|
|
9
|
+
* on the refusal-wiring contract, not the deploy mechanics. The pid in
|
|
10
|
+
* each fake row is process.pid (always alive during the test) so
|
|
11
|
+
* checkInFlight() treats them as genuine conflicts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
15
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { closeDb, getDb } from '../db/client';
|
|
19
|
+
import { runMigrations } from '../db/migrate';
|
|
20
|
+
import { backups, moduleOperations } from '../db/schema';
|
|
21
|
+
import { createModuleBackup, createSystemStateBackup } from './backup-create';
|
|
22
|
+
import { restoreModuleBackup, restoreSystemStateBackup } from './backup-restore';
|
|
23
|
+
import { InFlightError } from './module-operations';
|
|
24
|
+
|
|
25
|
+
describe('backup/restore in-flight refusal', () => {
|
|
26
|
+
let dir: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-refuse-test-'));
|
|
30
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
31
|
+
process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
|
|
32
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
closeDb();
|
|
37
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
38
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
39
|
+
try {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function insertInFlight(moduleId: string, op: 'deploy' | 'uninstall' | 'backup' | 'restore') {
|
|
47
|
+
const db = getDb();
|
|
48
|
+
db.insert(moduleOperations)
|
|
49
|
+
.values({
|
|
50
|
+
id: `fake-${moduleId}-${op}`,
|
|
51
|
+
moduleId,
|
|
52
|
+
operation: op,
|
|
53
|
+
status: 'in_progress',
|
|
54
|
+
pid: process.pid,
|
|
55
|
+
})
|
|
56
|
+
.run();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('createSystemStateBackup', () => {
|
|
60
|
+
it('refuses when another deploy is in-flight', async () => {
|
|
61
|
+
insertInFlight('homebridge', 'deploy');
|
|
62
|
+
await expect(createSystemStateBackup()).rejects.toThrow(InFlightError);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('refuses when another uninstall is in-flight', async () => {
|
|
66
|
+
insertInFlight('caddy', 'uninstall');
|
|
67
|
+
await expect(createSystemStateBackup()).rejects.toThrow(InFlightError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses when another backup is in-flight', async () => {
|
|
71
|
+
insertInFlight('authentik', 'backup');
|
|
72
|
+
await expect(createSystemStateBackup()).rejects.toThrow(InFlightError);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('refuses when another restore is in-flight', async () => {
|
|
76
|
+
insertInFlight('authentik', 'restore');
|
|
77
|
+
await expect(createSystemStateBackup()).rejects.toThrow(InFlightError);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('leaves no backups row when refused (refusal happens before side effects)', async () => {
|
|
81
|
+
insertInFlight('homebridge', 'deploy');
|
|
82
|
+
const db = getDb();
|
|
83
|
+
const beforeCount = db.select().from(backups).all().length;
|
|
84
|
+
try {
|
|
85
|
+
await createSystemStateBackup();
|
|
86
|
+
} catch {
|
|
87
|
+
// expected
|
|
88
|
+
}
|
|
89
|
+
const afterCount = db.select().from(backups).all().length;
|
|
90
|
+
expect(afterCount).toBe(beforeCount);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('error message names the conflicting operation', async () => {
|
|
94
|
+
insertInFlight('homebridge', 'deploy');
|
|
95
|
+
try {
|
|
96
|
+
await createSystemStateBackup();
|
|
97
|
+
} catch (err) {
|
|
98
|
+
expect(err).toBeInstanceOf(InFlightError);
|
|
99
|
+
expect((err as Error).message).toContain('deploy of homebridge');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
throw new Error('expected createSystemStateBackup to throw');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('createModuleBackup', () => {
|
|
107
|
+
it('returns "module not found" error if module does not exist (refusal not reached)', async () => {
|
|
108
|
+
insertInFlight('caddy', 'deploy');
|
|
109
|
+
const result = await createModuleBackup('does-not-exist');
|
|
110
|
+
expect(result.success).toBe(false);
|
|
111
|
+
expect(result.error).toContain('Module not found');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('restoreSystemStateBackup', () => {
|
|
116
|
+
it('refuses when a deploy is in-flight', async () => {
|
|
117
|
+
insertInFlight('caddy', 'deploy');
|
|
118
|
+
// Fake backup object — refusal happens before storageId is used.
|
|
119
|
+
const fakeBackup = {
|
|
120
|
+
id: 'fake',
|
|
121
|
+
moduleId: null,
|
|
122
|
+
storageId: 'noop',
|
|
123
|
+
storagePath: 'noop',
|
|
124
|
+
backupType: 'system_state' as const,
|
|
125
|
+
moduleVersion: null,
|
|
126
|
+
schemaVersion: null,
|
|
127
|
+
sizeBytes: null,
|
|
128
|
+
metadata: {},
|
|
129
|
+
status: 'completed' as const,
|
|
130
|
+
errorMessage: null,
|
|
131
|
+
name: null,
|
|
132
|
+
startedAt: new Date(),
|
|
133
|
+
completedAt: null,
|
|
134
|
+
};
|
|
135
|
+
await expect(restoreSystemStateBackup(fakeBackup)).rejects.toThrow(InFlightError);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('restoreModuleBackup', () => {
|
|
140
|
+
it('returns module-not-found error when module is missing (validation precedes refusal)', async () => {
|
|
141
|
+
insertInFlight('caddy', 'deploy');
|
|
142
|
+
const fakeBackup = {
|
|
143
|
+
id: 'fake',
|
|
144
|
+
moduleId: 'absent-module',
|
|
145
|
+
storageId: 'noop',
|
|
146
|
+
storagePath: 'noop',
|
|
147
|
+
backupType: 'module_data' as const,
|
|
148
|
+
moduleVersion: null,
|
|
149
|
+
schemaVersion: null,
|
|
150
|
+
sizeBytes: null,
|
|
151
|
+
metadata: {},
|
|
152
|
+
status: 'completed' as const,
|
|
153
|
+
errorMessage: null,
|
|
154
|
+
name: null,
|
|
155
|
+
startedAt: new Date(),
|
|
156
|
+
completedAt: null,
|
|
157
|
+
};
|
|
158
|
+
const result = await restoreModuleBackup(fakeBackup);
|
|
159
|
+
expect(result.success).toBe(false);
|
|
160
|
+
expect(result.error).toContain('Module not found');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|