@celilo/cli 0.3.30 → 0.4.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +6 -5
  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
@@ -0,0 +1,355 @@
1
+ /**
2
+ * File-direct restore + system-file swap. Powers the `celilo restore`
3
+ * top-level convenience command (Phase 4 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md).
4
+ *
5
+ * The generic `restoreModuleBackup` reads from a configured storage
6
+ * destination — which on a fresh-bootstrap box doesn't exist yet.
7
+ * `restoreFromArtifactFile` takes a local file path directly: decrypts
8
+ * + extracts + validates the envelope, then invokes the module's
9
+ * on_restore hook through the same plumbing (cross_module_write_root
10
+ * apply, in-flight refusal, operation tracking).
11
+ *
12
+ * After the hook returns, `applyStagedSystemFiles` picks up the
13
+ * celilo.db + master.key that celilo-mgmt's on_restore staged under
14
+ * restore_dir/system/, closes the live DB, copies them into place,
15
+ * and runs Drizzle migrations on the restored DB. This is the chunk
16
+ * of the restore the hook itself cannot do (live DB is open).
17
+ */
18
+
19
+ import { execSync } from 'node:child_process';
20
+ import {
21
+ chmodSync,
22
+ copyFileSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ readdirSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from 'node:fs';
30
+ import { tmpdir } from 'node:os';
31
+ import { dirname, join } from 'node:path';
32
+ import { eq } from 'drizzle-orm';
33
+ import { getDbPath, getMasterKeyPath } from '../config/paths';
34
+ import { closeDb, getDb } from '../db/client';
35
+ import { runMigrations } from '../db/migrate';
36
+ import { modules } from '../db/schema';
37
+ import { invokeHook } from '../hooks/executor';
38
+ import { createConsoleLogger } from '../hooks/logger';
39
+ import type { ModuleManifest } from '../manifest/schema';
40
+ import { decryptSecret } from '../secrets/encryption';
41
+ import { getOrCreateMasterKey } from '../secrets/master-key';
42
+ import { shellEscape } from '../utils/shell';
43
+ import { assertCompatibleSchema, parseManifest } from './backup-manifest';
44
+ import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read';
45
+ import { getModuleSystems } from './deployed-systems';
46
+ import {
47
+ completeOperation,
48
+ failOperation,
49
+ refuseIfInFlight,
50
+ startOperation,
51
+ } from './module-operations';
52
+
53
+ export interface RestoreFromFileResult {
54
+ success: boolean;
55
+ error?: string;
56
+ /** Module IDs whose cross-module TF state was applied. */
57
+ crossModuleApplied?: string[];
58
+ /** Was celilo.db swapped into place? */
59
+ systemDbApplied?: boolean;
60
+ /** Was master.key swapped into place? */
61
+ masterKeyApplied?: boolean;
62
+ /** Was the fleet SSH keypair restored to <dataDir>/.ssh/? */
63
+ sshKeyApplied?: boolean;
64
+ }
65
+
66
+ export interface RestoreFromFileOptions {
67
+ /** Allow restoring onto a populated target. Default false (refuse). */
68
+ force?: boolean;
69
+ }
70
+
71
+ /**
72
+ * Decrypt + extract a backup artifact file, invoke the module's
73
+ * on_restore hook, apply cross-module write-back, and apply staged
74
+ * system files (celilo.db + master.key). Returns a result describing
75
+ * what landed.
76
+ *
77
+ * The module identified by the artifact's manifest.json MUST already
78
+ * exist in the DB. The `celilo restore` CLI handler (Phase 4) ensures
79
+ * this — on truly fresh bootstrap, bootstrap.sh imports celilo-mgmt
80
+ * from the registry before invoking restore.
81
+ */
82
+ export async function restoreFromArtifactFile(
83
+ filePath: string,
84
+ _options: RestoreFromFileOptions = {},
85
+ ): Promise<RestoreFromFileResult> {
86
+ // refuseIfInFlight() before anything else — a restore alongside an
87
+ // active deploy would race with whichever process holds DB locks.
88
+ refuseIfInFlight();
89
+
90
+ if (!existsSync(filePath)) {
91
+ return { success: false, error: `Artifact not found: ${filePath}` };
92
+ }
93
+
94
+ // 1. Decrypt the encrypted envelope (JSON wrapper, identical to the
95
+ // shape backup-create.ts writes).
96
+ let masterKey: Buffer;
97
+ try {
98
+ masterKey = await getOrCreateMasterKey();
99
+ } catch (err) {
100
+ return {
101
+ success: false,
102
+ error: `Cannot read master key: ${err instanceof Error ? err.message : String(err)}. The current master key must match the one the artifact was encrypted with.`,
103
+ };
104
+ }
105
+
106
+ let encryptedJson: unknown;
107
+ try {
108
+ encryptedJson = JSON.parse(readFileSync(filePath, 'utf-8'));
109
+ } catch (err) {
110
+ return {
111
+ success: false,
112
+ error: `Artifact is not valid JSON: ${err instanceof Error ? err.message : String(err)}. The file may be corrupted or not a celilo backup artifact.`,
113
+ };
114
+ }
115
+
116
+ const tempDir = join(tmpdir(), `celilo-restore-from-file-${Date.now()}`);
117
+ const envelopeDir = join(tempDir, 'envelope');
118
+
119
+ try {
120
+ mkdirSync(envelopeDir, { recursive: true });
121
+
122
+ // Decrypt the JSON wrapper into the inner tar bytes.
123
+ let tarData: Buffer;
124
+ try {
125
+ const base64 = decryptSecret(encryptedJson as Parameters<typeof decryptSecret>[0], masterKey);
126
+ tarData = Buffer.from(base64, 'base64');
127
+ } catch (err) {
128
+ return {
129
+ success: false,
130
+ error: `Decryption failed: ${err instanceof Error ? err.message : String(err)}. The master key may not match the one used at backup time.`,
131
+ };
132
+ }
133
+
134
+ // Extract the envelope tar.
135
+ const tarPath = join(tempDir, 'envelope.tar');
136
+ writeFileSync(tarPath, tarData);
137
+ execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
138
+
139
+ // 2. Read + validate the envelope manifest.
140
+ const manifestPath = join(envelopeDir, 'manifest.json');
141
+ if (!existsSync(manifestPath)) {
142
+ return {
143
+ success: false,
144
+ error:
145
+ 'Artifact has no manifest.json. It may be from an older celilo (pre-envelope format) or corrupted.',
146
+ };
147
+ }
148
+ const envelopeManifest = parseManifest(readFileSync(manifestPath, 'utf-8'));
149
+ assertCompatibleSchema(envelopeManifest);
150
+ if (envelopeManifest.kind !== 'module') {
151
+ return {
152
+ success: false,
153
+ error: `Artifact kind is '${envelopeManifest.kind}', expected 'module'. (System artifacts shouldn't reach restore-from-file; use 'celilo backup restore' for those.)`,
154
+ };
155
+ }
156
+
157
+ const moduleId = envelopeManifest.moduleId;
158
+ if (!moduleId) {
159
+ return {
160
+ success: false,
161
+ error: "Artifact manifest doesn't name a moduleId. The artifact may be corrupted.",
162
+ };
163
+ }
164
+
165
+ const restoreDataDir = join(envelopeDir, 'data');
166
+ if (!existsSync(restoreDataDir)) {
167
+ return { success: false, error: 'Artifact has no data/ directory.' };
168
+ }
169
+
170
+ // 3. Look up the module's on_restore hook. The DB must already
171
+ // have the module — bootstrap.sh's job is to import celilo-mgmt
172
+ // before reaching restore.
173
+ const db = getDb();
174
+ const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
175
+ if (!mod) {
176
+ return {
177
+ success: false,
178
+ error: `Module '${moduleId}' is not imported. Run 'celilo module import ${moduleId}' first (or use the bootstrap.sh script which handles this for you).`,
179
+ };
180
+ }
181
+ const manifest = mod.manifestData as unknown as ModuleManifest;
182
+ const hookDef = manifest.hooks?.on_restore;
183
+ if (!hookDef) {
184
+ return {
185
+ success: false,
186
+ error: `Module '${moduleId}' has no on_restore hook defined.`,
187
+ };
188
+ }
189
+
190
+ // 4. Set up cross-module write-back staging if the privilege is
191
+ // granted. Same pattern as restoreModuleBackup.
192
+ const opId = startOperation(moduleId, 'restore');
193
+ const hookInputs: Record<string, unknown> = {
194
+ restore_dir: restoreDataDir,
195
+ schema_version: envelopeManifest.dataSchemaVersion ?? '',
196
+ };
197
+ let crossModuleWriteRoot: string | undefined;
198
+ if (moduleHasCrossModuleRead(manifest)) {
199
+ crossModuleWriteRoot = join(tempDir, 'cross-module-write');
200
+ mkdirSync(crossModuleWriteRoot, { recursive: true });
201
+ hookInputs.cross_module_write_root = crossModuleWriteRoot;
202
+ }
203
+
204
+ // 5. Invoke the hook.
205
+ const logger = createConsoleLogger(mod.id, 'on_restore');
206
+ const hookResult = await invokeHook(
207
+ mod.sourcePath,
208
+ 'on_restore',
209
+ manifest.celilo_contract,
210
+ hookDef,
211
+ hookInputs,
212
+ {},
213
+ {},
214
+ logger,
215
+ { debug: false, systems: getModuleSystems(mod.id, db) },
216
+ );
217
+ if (!hookResult.success) {
218
+ const errMsg = hookResult.error ?? 'on_restore hook failed';
219
+ failOperation(opId, errMsg);
220
+ return { success: false, error: errMsg };
221
+ }
222
+
223
+ // 6. Apply cross-module write-back (atomic rotate-into-place).
224
+ let crossModuleApplied: string[] = [];
225
+ if (crossModuleWriteRoot) {
226
+ try {
227
+ const applyResult = applyCrossModuleWriteRoot(crossModuleWriteRoot);
228
+ crossModuleApplied = applyResult.applied;
229
+ } catch (err) {
230
+ const errMsg = `cross_module_write_root apply failed: ${err instanceof Error ? err.message : String(err)}`;
231
+ failOperation(opId, errMsg);
232
+ return { success: false, error: errMsg };
233
+ }
234
+ }
235
+
236
+ // 7. Apply staged system files (celilo.db + master.key).
237
+ // These were staged by celilo-mgmt's on_restore at
238
+ // restore_dir/system/. The live process has the DB open, so
239
+ // this swap must happen AFTER the hook returns (which is now).
240
+ const systemStaging = join(restoreDataDir, 'system');
241
+ const apply = applyStagedSystemFiles(systemStaging);
242
+
243
+ completeOperation(opId);
244
+
245
+ return {
246
+ success: true,
247
+ crossModuleApplied,
248
+ systemDbApplied: apply.dbApplied,
249
+ masterKeyApplied: apply.keyApplied,
250
+ sshKeyApplied: apply.sshApplied,
251
+ };
252
+ } finally {
253
+ try {
254
+ rmSync(tempDir, { recursive: true, force: true });
255
+ } catch {
256
+ /* best-effort cleanup */
257
+ }
258
+ }
259
+ }
260
+
261
+ export interface StagedSystemApplyResult {
262
+ dbApplied: boolean;
263
+ keyApplied: boolean;
264
+ sshApplied: boolean;
265
+ }
266
+
267
+ /**
268
+ * Picks up the celilo.db + master.key that celilo-mgmt's on_restore
269
+ * staged under <restore_dir>/system/, closes the live DB, and copies
270
+ * each into its live path. Then runs Drizzle migrations on the
271
+ * restored DB so a snapshot from an older celilo can still be opened.
272
+ *
273
+ * Skips files that aren't present — restoring just a master.key (no
274
+ * DB) is a valid use case (e.g., recovering from a lost-key scenario
275
+ * without a full system restore).
276
+ *
277
+ * Exported for direct use by callers that don't go through
278
+ * restoreFromArtifactFile (tests, future system-only restore paths).
279
+ */
280
+ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemApplyResult {
281
+ let dbApplied = false;
282
+ let keyApplied = false;
283
+ let sshApplied = false;
284
+
285
+ if (!existsSync(systemStagingDir)) {
286
+ return { dbApplied, keyApplied, sshApplied };
287
+ }
288
+
289
+ const stagedDb = join(systemStagingDir, 'celilo.db');
290
+ const stagedKey = join(systemStagingDir, 'master.key');
291
+ const stagedSsh = join(systemStagingDir, 'ssh');
292
+
293
+ // master.key first: it's not load-bearing for the running process
294
+ // (already in memory if the daemon's running). Safe to swap before
295
+ // closing the DB.
296
+ if (existsSync(stagedKey)) {
297
+ const livePath = getMasterKeyPath();
298
+ mkdirSync(join(livePath, '..'), { recursive: true });
299
+ copyFileSync(stagedKey, livePath);
300
+ keyApplied = true;
301
+ }
302
+
303
+ // Fleet SSH keypair → <dataDir>/.ssh/, restoring strict perms (the dir
304
+ // 0700, private key 0600, public 0644). celilo uses this key to reach
305
+ // managed machines; the DB only carries the public half, so without the
306
+ // private half on disk the restored box can't authenticate to the fleet.
307
+ if (existsSync(stagedSsh)) {
308
+ // The fleet key lives next to the DB (on_install writes it to
309
+ // dirname(db_path)/.ssh), so follow the DB's location — not getDataDir()
310
+ // — in case CELILO_DB_PATH points somewhere custom.
311
+ const liveSshDir = join(dirname(getDbPath()), '.ssh');
312
+ mkdirSync(liveSshDir, { recursive: true, mode: 0o700 });
313
+ chmodSync(liveSshDir, 0o700);
314
+ for (const entry of readdirSync(stagedSsh)) {
315
+ const dest = join(liveSshDir, entry);
316
+ copyFileSync(join(stagedSsh, entry), dest);
317
+ chmodSync(dest, entry.endsWith('.pub') ? 0o644 : 0o600);
318
+ }
319
+ sshApplied = true;
320
+ }
321
+
322
+ // celilo.db: must close the live DB connection before replacing
323
+ // the file or SQLite gets confused (macOS holds a vnode handle).
324
+ if (existsSync(stagedDb)) {
325
+ closeDb();
326
+ const livePath = getDbPath();
327
+ mkdirSync(join(livePath, '..'), { recursive: true });
328
+ copyFileSync(stagedDb, livePath);
329
+
330
+ // Stale WAL/SHM siblings would corrupt the restored DB.
331
+ for (const suffix of ['-wal', '-shm']) {
332
+ try {
333
+ rmSync(`${livePath}${suffix}`, { force: true });
334
+ } catch {
335
+ /* may not exist */
336
+ }
337
+ }
338
+ dbApplied = true;
339
+ }
340
+
341
+ return { dbApplied, keyApplied, sshApplied };
342
+ }
343
+
344
+ /**
345
+ * Migrate the restored DB to the current celilo build's schema. Called
346
+ * by the CLI handler after applyStagedSystemFiles so a backup from an
347
+ * older celilo still opens cleanly. Pure I/O wrapper around
348
+ * `runMigrations` exposed here so the same orchestration lives next
349
+ * to the swap logic.
350
+ */
351
+ export async function migrateRestoredDb(): Promise<void> {
352
+ const dbPath = getDbPath();
353
+ if (!existsSync(dbPath)) return;
354
+ await runMigrations(dbPath);
355
+ }
@@ -0,0 +1,127 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { closeDb, getDb } from '../db/client';
6
+ import { runMigrations } from '../db/migrate';
7
+ import { modules } from '../db/schema';
8
+ import {
9
+ NonEmptyRestoreTargetError,
10
+ assertRestoreTargetEmpty,
11
+ surveyRestoreTarget,
12
+ } from './restore-preflight';
13
+
14
+ describe('restore pre-flight', () => {
15
+ let dir: string;
16
+
17
+ beforeEach(async () => {
18
+ dir = mkdtempSync(join(tmpdir(), 'celilo-preflight-test-'));
19
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
20
+ process.env.CELILO_DATA_DIR = dir;
21
+ await runMigrations(process.env.CELILO_DB_PATH);
22
+ });
23
+
24
+ afterEach(() => {
25
+ closeDb();
26
+ process.env.CELILO_DB_PATH = undefined;
27
+ process.env.CELILO_DATA_DIR = undefined;
28
+ try {
29
+ rmSync(dir, { recursive: true, force: true });
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ });
34
+
35
+ it('reports empty when DB has no modules and no TF state on disk', () => {
36
+ const report = surveyRestoreTarget();
37
+ expect(report.empty).toBe(true);
38
+ expect(report.modulesInDb).toEqual([]);
39
+ expect(report.modulesWithTerraformState).toEqual([]);
40
+ });
41
+
42
+ it('reports modulesInDb when modules exist in the DB', () => {
43
+ const db = getDb();
44
+ db.insert(modules)
45
+ .values({
46
+ id: 'homebridge',
47
+ name: 'homebridge',
48
+ version: '0.1.0',
49
+ sourcePath: '/tmp/fake',
50
+ manifestData: {} as Record<string, unknown>,
51
+ })
52
+ .run();
53
+
54
+ const report = surveyRestoreTarget();
55
+ expect(report.empty).toBe(false);
56
+ expect(report.modulesInDb).toEqual(['homebridge']);
57
+ });
58
+
59
+ it('reports modulesWithTerraformState when a tfstate file exists on disk', () => {
60
+ const tfDir = join(dir, 'modules', 'caddy', 'generated', 'terraform');
61
+ mkdirSync(tfDir, { recursive: true });
62
+ writeFileSync(join(tfDir, 'terraform.tfstate'), '{}');
63
+
64
+ const report = surveyRestoreTarget();
65
+ expect(report.empty).toBe(false);
66
+ expect(report.modulesWithTerraformState).toEqual(['caddy']);
67
+ });
68
+
69
+ it('treats an empty terraform/ dir as no state', () => {
70
+ const tfDir = join(dir, 'modules', 'half-installed', 'generated', 'terraform');
71
+ mkdirSync(tfDir, { recursive: true });
72
+
73
+ const report = surveyRestoreTarget();
74
+ expect(report.empty).toBe(true);
75
+ expect(report.modulesWithTerraformState).toEqual([]);
76
+ });
77
+
78
+ describe('assertRestoreTargetEmpty', () => {
79
+ it('does not throw on a fresh target', () => {
80
+ expect(() => assertRestoreTargetEmpty()).not.toThrow();
81
+ });
82
+
83
+ it('throws NonEmptyRestoreTargetError on a populated DB', () => {
84
+ const db = getDb();
85
+ db.insert(modules)
86
+ .values({
87
+ id: 'homebridge',
88
+ name: 'homebridge',
89
+ version: '0.1.0',
90
+ sourcePath: '/tmp/fake',
91
+ manifestData: {} as Record<string, unknown>,
92
+ })
93
+ .run();
94
+
95
+ expect(() => assertRestoreTargetEmpty()).toThrow(NonEmptyRestoreTargetError);
96
+ });
97
+
98
+ it('error message names the conflicting modules + suggests --force', () => {
99
+ const db = getDb();
100
+ db.insert(modules)
101
+ .values({
102
+ id: 'homebridge',
103
+ name: 'homebridge',
104
+ version: '0.1.0',
105
+ sourcePath: '/tmp/fake',
106
+ manifestData: {} as Record<string, unknown>,
107
+ })
108
+ .run();
109
+
110
+ const tfDir = join(dir, 'modules', 'caddy', 'generated', 'terraform');
111
+ mkdirSync(tfDir, { recursive: true });
112
+ writeFileSync(join(tfDir, 'terraform.tfstate'), '{}');
113
+
114
+ try {
115
+ assertRestoreTargetEmpty();
116
+ } catch (err) {
117
+ expect(err).toBeInstanceOf(NonEmptyRestoreTargetError);
118
+ const msg = (err as Error).message;
119
+ expect(msg).toContain('homebridge');
120
+ expect(msg).toContain('caddy');
121
+ expect(msg).toContain('--force');
122
+ return;
123
+ }
124
+ throw new Error('expected assertRestoreTargetEmpty to throw');
125
+ });
126
+ });
127
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Pre-flight check for the celilo restore command. Refuses to run on a
3
+ * target that already holds celilo state -- restoring onto a working
4
+ * management server would clobber live state with no recovery path.
5
+ * Operator passes --force to override.
6
+ *
7
+ * Two signals count as "non-empty":
8
+ * 1. The celilo DB exists AND has rows in the modules table (i.e.
9
+ * the operator has imported at least one module).
10
+ * 2. Any <getModuleStoragePath()>/<id>/generated/terraform/ directory
11
+ * exists (i.e. at least one module has been deployed).
12
+ *
13
+ * Either signal is sufficient. Both are checked so a half-deployed
14
+ * target (DB cleared but disk artifacts left over) still gets caught.
15
+ *
16
+ * Phase 4 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md (Design Decision 9).
17
+ */
18
+
19
+ import { existsSync, readdirSync, statSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { getModuleStoragePath } from '../config/paths';
22
+ import { getDb } from '../db/client';
23
+ import { modules } from '../db/schema';
24
+
25
+ export interface PreflightReport {
26
+ /** True if the target looks empty enough to restore onto. */
27
+ empty: boolean;
28
+ /** Module IDs found in the DB (if any). Empty list if the DB doesn't exist. */
29
+ modulesInDb: string[];
30
+ /** Module IDs with a generated/terraform/ directory on disk. */
31
+ modulesWithTerraformState: string[];
32
+ }
33
+
34
+ /**
35
+ * Survey the target. Pure -- no mutations. Callers decide whether to
36
+ * proceed (raw `empty` check) or to surface the findings to the
37
+ * operator and require --force confirmation.
38
+ */
39
+ export function surveyRestoreTarget(): PreflightReport {
40
+ let modulesInDb: string[] = [];
41
+ try {
42
+ const db = getDb();
43
+ modulesInDb = db
44
+ .select({ id: modules.id })
45
+ .from(modules)
46
+ .all()
47
+ .map((row) => row.id);
48
+ } catch {
49
+ // No DB / no migrations / file doesn't exist -- equivalent to "no
50
+ // modules in DB" for pre-flight purposes.
51
+ }
52
+
53
+ const storageRoot = getModuleStoragePath();
54
+ const modulesWithTerraformState: string[] = [];
55
+ if (existsSync(storageRoot)) {
56
+ for (const entry of readdirSync(storageRoot, { withFileTypes: true })) {
57
+ if (!entry.isDirectory()) continue;
58
+ const tfDir = join(storageRoot, entry.name, 'generated', 'terraform');
59
+ if (existsSync(tfDir)) {
60
+ try {
61
+ // Treat empty terraform/ dirs as "no state" -- a leftover
62
+ // dir without files isn't load-bearing.
63
+ const tfStat = statSync(tfDir);
64
+ if (tfStat.isDirectory() && readdirSync(tfDir).length > 0) {
65
+ modulesWithTerraformState.push(entry.name);
66
+ }
67
+ } catch {
68
+ /* tfDir vanished mid-survey; skip */
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ return {
75
+ empty: modulesInDb.length === 0 && modulesWithTerraformState.length === 0,
76
+ modulesInDb,
77
+ modulesWithTerraformState,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Thrown by assertRestoreTargetEmpty() when the survey finds state.
83
+ * Carries the full report so the CLI handler can format an actionable
84
+ * message without re-surveying.
85
+ */
86
+ export class NonEmptyRestoreTargetError extends Error {
87
+ constructor(public readonly report: PreflightReport) {
88
+ const lines: string[] = ['Refusing to restore: target is not empty.'];
89
+ if (report.modulesInDb.length > 0) {
90
+ lines.push(
91
+ ` ${report.modulesInDb.length} module(s) registered in DB: ${report.modulesInDb.join(', ')}`,
92
+ );
93
+ }
94
+ if (report.modulesWithTerraformState.length > 0) {
95
+ lines.push(
96
+ ` ${report.modulesWithTerraformState.length} module(s) with deployed terraform state: ${report.modulesWithTerraformState.join(', ')}`,
97
+ );
98
+ }
99
+ lines.push('');
100
+ lines.push(
101
+ 'Restoring would clobber the existing state. Pass --force to proceed (irreversible).',
102
+ );
103
+ super(lines.join('\n'));
104
+ this.name = 'NonEmptyRestoreTargetError';
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Throw NonEmptyRestoreTargetError when the target has state. Caller
110
+ * (the celilo restore CLI handler) catches and exits with the error
111
+ * message + non-zero status when --force isn't set.
112
+ */
113
+ export function assertRestoreTargetEmpty(): void {
114
+ const report = surveyRestoreTarget();
115
+ if (!report.empty) {
116
+ throw new NonEmptyRestoreTargetError(report);
117
+ }
118
+ }
@@ -3,7 +3,7 @@
3
3
  * Works with AWS S3, MinIO, Backblaze B2, Wasabi, and any S3-compatible service.
4
4
  */
5
5
 
6
- import { createReadStream, createWriteStream } from 'node:fs';
6
+ import { createWriteStream, readFileSync } from 'node:fs';
7
7
  import { mkdir } from 'node:fs/promises';
8
8
  import { dirname } from 'node:path';
9
9
  import { Readable } from 'node:stream';
@@ -49,7 +49,15 @@ export function createS3StorageProvider(config: S3StorageConfig): StorageProvide
49
49
 
50
50
  return {
51
51
  async upload(localPath: string, remotePath: string): Promise<void> {
52
- const body = createReadStream(localPath);
52
+ // Upload a Buffer, NOT a read stream (ISS-0016). A streamed Body fails
53
+ // with "The request body terminated unexpectedly": the SDK can't replay a
54
+ // one-shot Node stream across the retries/redirects S3 issues (e.g. a
55
+ // region 301), and with no ContentLength it falls back to aws-chunked
56
+ // encoding on top. A Buffer is replayable and self-describing — the same
57
+ // reason the string-bodied verify write succeeds where the stream upload
58
+ // did not. Backup envelopes are small (state + key, not provider
59
+ // binaries — ISS-0015), so buffering is fine.
60
+ const body = readFileSync(localPath);
53
61
  await client.send(
54
62
  new PutObjectCommand({
55
63
  Bucket: bucket,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Resolve a deployed module's "system identity" — the hostname and IP of the
3
+ * machine / container-service instance it runs on. Used to populate
4
+ * system.created / system.destroyed event payloads (D5) and to enumerate hosts
5
+ * for a dns_internal provider's deploy-time DNS backfill.
6
+ */
7
+
8
+ import type { DbClient } from '../db/client';
9
+ import { getModuleSystems } from './deployed-systems';
10
+
11
+ export interface SystemIdentity {
12
+ hostname: string;
13
+ /** IPv4 address, CIDR stripped. */
14
+ ip: string;
15
+ }
16
+
17
+ /**
18
+ * Get a module's hostname and IP from its recorded deployed systems
19
+ * (v2/MODULE_SYSTEMS_ADDRESSING.md). Returns null for a module with no host
20
+ * (config-only providers like namecheap). Single-system modules have one; this
21
+ * returns the first (Phase C iterates all systems for per-system events).
22
+ */
23
+ export async function getModuleHostAndIp(
24
+ moduleId: string,
25
+ db: DbClient,
26
+ ): Promise<SystemIdentity | null> {
27
+ const systems = getModuleSystems(moduleId, db);
28
+ if (!systems[0]) return null;
29
+ return { hostname: systems[0].hostname, ip: systems[0].ipv4_address };
30
+ }