@celilo/cli 0.3.30-alpha.0 → 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.
Files changed (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +3 -3
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. 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
- // Copy the SQLite database to temp (safe point-in-time copy)
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
- const tempDbPath = join(tempDir, 'celilo.db');
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(tempDir, 'celilo.db-wal'));
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
- // Read the database file and encrypt it
117
- const dbData = readFileSync(tempDbPath);
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(dbData.toString('base64'), masterKey);
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: dbData.length,
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 backupDir = join(tempDir, 'artifacts');
331
+ const envelopeDir = join(tempDir, 'envelope');
332
+ const dataDir = join(envelopeDir, 'data');
287
333
 
288
334
  try {
289
- mkdirSync(backupDir, { recursive: true });
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
- // Execute on_backup hook it writes artifacts to backupDir
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
- { backup_dir: backupDir },
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
- failBackup(record.id, hookResult.error ?? 'on_backup hook failed');
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: hookResult.error ?? 'on_backup hook failed',
379
+ error: errMsg,
316
380
  };
317
381
  }
318
382
 
319
- // Tar the backup artifacts
320
- const tarPath = join(tempDir, 'backup.tar');
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(backupDir)} .`);
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
+ });