@celilo/cli 0.1.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 (267) hide show
  1. package/README.md +1566 -0
  2. package/bin/celilo +16 -0
  3. package/drizzle/0000_complex_puma.sql +179 -0
  4. package/drizzle/0001_dizzy_wolfpack.sql +2 -0
  5. package/drizzle/0002_web_routes.sql +16 -0
  6. package/drizzle/0003_backup_storage.sql +32 -0
  7. package/drizzle/meta/0000_snapshot.json +1151 -0
  8. package/drizzle/meta/0001_snapshot.json +1167 -0
  9. package/drizzle/meta/0002_snapshot.json +1257 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +64 -0
  12. package/schemas/system_config.json +106 -0
  13. package/src/__integration__/container-services-cli.integration.test.ts +246 -0
  14. package/src/ansible/dependencies.test.ts +309 -0
  15. package/src/ansible/dependencies.ts +896 -0
  16. package/src/ansible/inventory.test.ts +463 -0
  17. package/src/ansible/inventory.ts +445 -0
  18. package/src/ansible/secrets.ts +222 -0
  19. package/src/ansible/validation.test.ts +92 -0
  20. package/src/ansible/validation.ts +272 -0
  21. package/src/api-clients/digitalocean.ts +94 -0
  22. package/src/api-clients/proxmox.ts +655 -0
  23. package/src/capabilities/logging-wrapper.test.ts +217 -0
  24. package/src/capabilities/lookup.test.ts +149 -0
  25. package/src/capabilities/lookup.ts +89 -0
  26. package/src/capabilities/public-web-helpers.test.ts +198 -0
  27. package/src/capabilities/public-web-publish.test.ts +458 -0
  28. package/src/capabilities/registration.test.ts +395 -0
  29. package/src/capabilities/registration.ts +200 -0
  30. package/src/capabilities/route-validation.test.ts +121 -0
  31. package/src/capabilities/route-validation.ts +96 -0
  32. package/src/capabilities/secret-ref.test.ts +313 -0
  33. package/src/capabilities/secret-validation.ts +157 -0
  34. package/src/capabilities/secrets.test.ts +750 -0
  35. package/src/capabilities/secrets.ts +244 -0
  36. package/src/capabilities/validation.test.ts +613 -0
  37. package/src/capabilities/validation.ts +160 -0
  38. package/src/capabilities/well-known.test.ts +238 -0
  39. package/src/capabilities/well-known.ts +222 -0
  40. package/src/cli/cli.test.ts +654 -0
  41. package/src/cli/command-registry.ts +742 -0
  42. package/src/cli/command-tree-parser.test.ts +180 -0
  43. package/src/cli/command-tree-parser.ts +193 -0
  44. package/src/cli/commands/backup-create.ts +137 -0
  45. package/src/cli/commands/backup-delete.ts +74 -0
  46. package/src/cli/commands/backup-import.ts +97 -0
  47. package/src/cli/commands/backup-list.ts +132 -0
  48. package/src/cli/commands/backup-name.ts +73 -0
  49. package/src/cli/commands/backup-prune.ts +98 -0
  50. package/src/cli/commands/backup-restore.ts +122 -0
  51. package/src/cli/commands/capability-info.ts +121 -0
  52. package/src/cli/commands/capability-list.ts +47 -0
  53. package/src/cli/commands/completion.ts +87 -0
  54. package/src/cli/commands/hook-run.ts +176 -0
  55. package/src/cli/commands/ipam.ts +607 -0
  56. package/src/cli/commands/machine-add.ts +235 -0
  57. package/src/cli/commands/machine-earmark.ts +82 -0
  58. package/src/cli/commands/machine-list.ts +77 -0
  59. package/src/cli/commands/machine-remove.ts +90 -0
  60. package/src/cli/commands/machine-status.ts +131 -0
  61. package/src/cli/commands/module-audit.ts +51 -0
  62. package/src/cli/commands/module-build.ts +60 -0
  63. package/src/cli/commands/module-config.ts +170 -0
  64. package/src/cli/commands/module-deploy.ts +71 -0
  65. package/src/cli/commands/module-generate.ts +236 -0
  66. package/src/cli/commands/module-health.ts +108 -0
  67. package/src/cli/commands/module-import.ts +80 -0
  68. package/src/cli/commands/module-list.ts +43 -0
  69. package/src/cli/commands/module-logs.ts +73 -0
  70. package/src/cli/commands/module-remove.ts +162 -0
  71. package/src/cli/commands/module-show.ts +208 -0
  72. package/src/cli/commands/module-status.ts +131 -0
  73. package/src/cli/commands/module-types.ts +189 -0
  74. package/src/cli/commands/module-upgrade.ts +192 -0
  75. package/src/cli/commands/package.ts +68 -0
  76. package/src/cli/commands/secret-list.ts +99 -0
  77. package/src/cli/commands/secret-set.ts +134 -0
  78. package/src/cli/commands/service-add-digitalocean.ts +133 -0
  79. package/src/cli/commands/service-add-proxmox.ts +342 -0
  80. package/src/cli/commands/service-config-get.ts +83 -0
  81. package/src/cli/commands/service-config-set.ts +145 -0
  82. package/src/cli/commands/service-list.ts +74 -0
  83. package/src/cli/commands/service-reconfigure.ts +230 -0
  84. package/src/cli/commands/service-remove.ts +103 -0
  85. package/src/cli/commands/service-verify.ts +240 -0
  86. package/src/cli/commands/status.ts +216 -0
  87. package/src/cli/commands/storage-add-local.ts +106 -0
  88. package/src/cli/commands/storage-add-s3.ts +114 -0
  89. package/src/cli/commands/storage-list.ts +72 -0
  90. package/src/cli/commands/storage-remove.ts +54 -0
  91. package/src/cli/commands/storage-set-default.ts +44 -0
  92. package/src/cli/commands/storage-verify.ts +54 -0
  93. package/src/cli/commands/system-config.ts +168 -0
  94. package/src/cli/commands/system-init.ts +314 -0
  95. package/src/cli/commands/system-secret-get.ts +98 -0
  96. package/src/cli/commands/system-secret-set.ts +76 -0
  97. package/src/cli/commands/system-vault-password.ts +34 -0
  98. package/src/cli/completion.test.ts +37 -0
  99. package/src/cli/completion.ts +482 -0
  100. package/src/cli/fuel-gauge.test.ts +208 -0
  101. package/src/cli/fuel-gauge.ts +405 -0
  102. package/src/cli/generate-zsh-completion.test.ts +95 -0
  103. package/src/cli/generate-zsh-completion.ts +497 -0
  104. package/src/cli/index.ts +1583 -0
  105. package/src/cli/interactive-config.test.ts +201 -0
  106. package/src/cli/interactive-config.ts +62 -0
  107. package/src/cli/parser.test.ts +227 -0
  108. package/src/cli/parser.ts +244 -0
  109. package/src/cli/prompts.test.ts +33 -0
  110. package/src/cli/prompts.ts +121 -0
  111. package/src/cli/types.ts +38 -0
  112. package/src/cli/validators.test.ts +235 -0
  113. package/src/cli/validators.ts +188 -0
  114. package/src/config/env.ts +41 -0
  115. package/src/config/paths.test.ts +172 -0
  116. package/src/config/paths.ts +108 -0
  117. package/src/db/client.ts +190 -0
  118. package/src/db/migrate.ts +30 -0
  119. package/src/db/schema.test.ts +221 -0
  120. package/src/db/schema.ts +434 -0
  121. package/src/hooks/capability-loader-firewall.test.ts +246 -0
  122. package/src/hooks/capability-loader.test.ts +100 -0
  123. package/src/hooks/capability-loader.ts +520 -0
  124. package/src/hooks/define-hook.test.ts +488 -0
  125. package/src/hooks/executor.test.ts +462 -0
  126. package/src/hooks/executor.ts +469 -0
  127. package/src/hooks/logger.test.ts +54 -0
  128. package/src/hooks/logger.ts +95 -0
  129. package/src/hooks/test-fixtures/failing-hook.ts +13 -0
  130. package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
  131. package/src/hooks/test-fixtures/success-hook.ts +20 -0
  132. package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
  133. package/src/hooks/test-fixtures/void-hook.ts +13 -0
  134. package/src/hooks/types.ts +89 -0
  135. package/src/infrastructure/property-extractor.test.ts +194 -0
  136. package/src/infrastructure/property-extractor.ts +151 -0
  137. package/src/ipam/allocator.test.ts +442 -0
  138. package/src/ipam/allocator.ts +369 -0
  139. package/src/ipam/auto-allocator.test.ts +247 -0
  140. package/src/ipam/auto-allocator.ts +270 -0
  141. package/src/ipam/subnet-parser.test.ts +107 -0
  142. package/src/ipam/subnet-parser.ts +136 -0
  143. package/src/manifest/contracts/index.ts +61 -0
  144. package/src/manifest/contracts/v1.ts +118 -0
  145. package/src/manifest/json-schema-roundtrip.test.ts +99 -0
  146. package/src/manifest/schema.ts +367 -0
  147. package/src/manifest/template-validator.test.ts +231 -0
  148. package/src/manifest/template-validator.ts +322 -0
  149. package/src/manifest/validate.test.ts +1180 -0
  150. package/src/manifest/validate.ts +415 -0
  151. package/src/module/import.test.ts +355 -0
  152. package/src/module/import.ts +676 -0
  153. package/src/module/packaging/audit.ts +169 -0
  154. package/src/module/packaging/build.ts +228 -0
  155. package/src/module/packaging/checksum.ts +41 -0
  156. package/src/module/packaging/extract.ts +234 -0
  157. package/src/module/packaging/signature.ts +47 -0
  158. package/src/secrets/encryption.test.ts +284 -0
  159. package/src/secrets/encryption.ts +162 -0
  160. package/src/secrets/generators.test.ts +112 -0
  161. package/src/secrets/generators.ts +127 -0
  162. package/src/secrets/master-key.test.ts +159 -0
  163. package/src/secrets/master-key.ts +114 -0
  164. package/src/secrets/storage.test.ts +115 -0
  165. package/src/secrets/storage.ts +106 -0
  166. package/src/secrets/vault.test.ts +35 -0
  167. package/src/secrets/vault.ts +42 -0
  168. package/src/services/backup-create.ts +532 -0
  169. package/src/services/backup-metadata.ts +198 -0
  170. package/src/services/backup-restore.ts +229 -0
  171. package/src/services/backup-retention.ts +84 -0
  172. package/src/services/backup-storage.ts +281 -0
  173. package/src/services/build-stream.test.ts +122 -0
  174. package/src/services/build-stream.ts +201 -0
  175. package/src/services/config-interview.ts +694 -0
  176. package/src/services/container-service.test.ts +298 -0
  177. package/src/services/container-service.ts +401 -0
  178. package/src/services/cross-module-data-manager.test.ts +405 -0
  179. package/src/services/cross-module-data-manager.ts +412 -0
  180. package/src/services/deploy-ansible.ts +88 -0
  181. package/src/services/deploy-planner.ts +153 -0
  182. package/src/services/deploy-preflight.ts +274 -0
  183. package/src/services/deploy-ssh.ts +131 -0
  184. package/src/services/deploy-terraform.test.ts +55 -0
  185. package/src/services/deploy-terraform.ts +445 -0
  186. package/src/services/deploy-validation.ts +311 -0
  187. package/src/services/dns-auto-register.ts +211 -0
  188. package/src/services/health-runner.ts +184 -0
  189. package/src/services/infrastructure-selector.test.ts +485 -0
  190. package/src/services/infrastructure-selector.ts +245 -0
  191. package/src/services/infrastructure-variable-resolver.test.ts +751 -0
  192. package/src/services/infrastructure-variable-resolver.ts +234 -0
  193. package/src/services/machine-detector.ts +328 -0
  194. package/src/services/machine-pool.test.ts +405 -0
  195. package/src/services/machine-pool.ts +316 -0
  196. package/src/services/manifest-validation.ts +120 -0
  197. package/src/services/module-build.test.ts +290 -0
  198. package/src/services/module-build.ts +431 -0
  199. package/src/services/module-config.test.ts +237 -0
  200. package/src/services/module-config.ts +298 -0
  201. package/src/services/module-deploy.ts +862 -0
  202. package/src/services/module-types-drift.test.ts +73 -0
  203. package/src/services/module-types-generator.test.ts +288 -0
  204. package/src/services/module-types-generator.ts +189 -0
  205. package/src/services/proxmox-state-recovery.ts +140 -0
  206. package/src/services/schema-validation.ts +155 -0
  207. package/src/services/secret-schema-loader.test.ts +311 -0
  208. package/src/services/secret-schema-loader.ts +239 -0
  209. package/src/services/ssh-key-manager.test.ts +283 -0
  210. package/src/services/ssh-key-manager.ts +193 -0
  211. package/src/services/storage-providers/local.ts +105 -0
  212. package/src/services/storage-providers/s3.ts +182 -0
  213. package/src/services/storage-providers/types.ts +24 -0
  214. package/src/services/system-config-schema-types.ts +25 -0
  215. package/src/services/system-config-validator.test.ts +160 -0
  216. package/src/services/system-config-validator.ts +74 -0
  217. package/src/services/system-init.test.ts +153 -0
  218. package/src/services/system-init.ts +253 -0
  219. package/src/services/terraform-safety.ts +174 -0
  220. package/src/services/zone-detector.test.ts +110 -0
  221. package/src/services/zone-detector.ts +102 -0
  222. package/src/services/zone-policy.test.ts +97 -0
  223. package/src/services/zone-policy.ts +126 -0
  224. package/src/templates/generator.test.ts +645 -0
  225. package/src/templates/generator.ts +1119 -0
  226. package/src/templates/types.ts +62 -0
  227. package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
  228. package/src/test-utils/cli-context-interactive.test.ts +152 -0
  229. package/src/test-utils/cli-context-server.test.ts +66 -0
  230. package/src/test-utils/cli-context.test.ts +273 -0
  231. package/src/test-utils/cli-context.ts +677 -0
  232. package/src/test-utils/cli-result.test.ts +282 -0
  233. package/src/test-utils/cli-result.ts +241 -0
  234. package/src/test-utils/cli.ts +55 -0
  235. package/src/test-utils/completion-harness.test.ts +126 -0
  236. package/src/test-utils/completion-harness.ts +82 -0
  237. package/src/test-utils/database.test.ts +182 -0
  238. package/src/test-utils/database.ts +126 -0
  239. package/src/test-utils/filesystem.test.ts +208 -0
  240. package/src/test-utils/filesystem.ts +142 -0
  241. package/src/test-utils/fixtures.test.ts +123 -0
  242. package/src/test-utils/fixtures.ts +160 -0
  243. package/src/test-utils/golden-diff.ts +197 -0
  244. package/src/test-utils/index.ts +77 -0
  245. package/src/test-utils/integration.ts +81 -0
  246. package/src/test-utils/module-fixtures.ts +468 -0
  247. package/src/test-utils/modules.test.ts +144 -0
  248. package/src/test-utils/modules.ts +183 -0
  249. package/src/test-utils/setup-test-db.ts +90 -0
  250. package/src/test-utils/value-extractor.test.ts +231 -0
  251. package/src/test-utils/value-extractor.ts +228 -0
  252. package/src/types/infrastructure.ts +157 -0
  253. package/src/utils/shell.test.ts +365 -0
  254. package/src/utils/shell.ts +159 -0
  255. package/src/validation/schemas.ts +166 -0
  256. package/src/variables/ansible-resolver.test.ts +142 -0
  257. package/src/variables/ansible-resolver.ts +69 -0
  258. package/src/variables/capability-self-ref.test.ts +220 -0
  259. package/src/variables/context.test.ts +1265 -0
  260. package/src/variables/context.ts +624 -0
  261. package/src/variables/declarative-derivation.test.ts +743 -0
  262. package/src/variables/declarative-derivation.ts +200 -0
  263. package/src/variables/parser.test.ts +231 -0
  264. package/src/variables/parser.ts +76 -0
  265. package/src/variables/resolver.test.ts +458 -0
  266. package/src/variables/resolver.ts +282 -0
  267. package/src/variables/types.ts +59 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Backup metadata service - CRUD operations for backup records.
3
+ * Tracks both system state and module data backups.
4
+ */
5
+
6
+ import { randomUUID } from 'node:crypto';
7
+ import { desc, eq, like } from 'drizzle-orm';
8
+ import { getDb } from '../db/client';
9
+ import { type Backup, type BackupStatus, type BackupType, backups } from '../db/schema';
10
+
11
+ /** Short ID length used for display and lookup */
12
+ export const SHORT_ID_LENGTH = 8;
13
+
14
+ export interface CreateBackupParams {
15
+ moduleId?: string | null;
16
+ storageId: string;
17
+ storagePath: string;
18
+ backupType: BackupType;
19
+ moduleVersion?: string;
20
+ }
21
+
22
+ export interface BackupRecord extends Backup {
23
+ id: string;
24
+ }
25
+
26
+ /**
27
+ * Create a new backup record (status: in_progress)
28
+ */
29
+ export function createBackupRecord(params: CreateBackupParams): BackupRecord {
30
+ const db = getDb();
31
+ const id = randomUUID();
32
+ const now = new Date();
33
+
34
+ const values = {
35
+ id,
36
+ moduleId: params.moduleId ?? null,
37
+ storageId: params.storageId,
38
+ storagePath: params.storagePath,
39
+ backupType: params.backupType,
40
+ moduleVersion: params.moduleVersion ?? null,
41
+ schemaVersion: null,
42
+ sizeBytes: null,
43
+ metadata: {},
44
+ status: 'in_progress' as BackupStatus,
45
+ errorMessage: null,
46
+ startedAt: now,
47
+ completedAt: null,
48
+ };
49
+
50
+ db.insert(backups).values(values).run();
51
+
52
+ return { ...values, startedAt: now, completedAt: null } as BackupRecord;
53
+ }
54
+
55
+ /**
56
+ * Mark a backup as completed with final metadata
57
+ */
58
+ export function completeBackup(
59
+ id: string,
60
+ params: {
61
+ sizeBytes: number;
62
+ metadata?: Record<string, unknown>;
63
+ schemaVersion?: string;
64
+ },
65
+ ): void {
66
+ const db = getDb();
67
+ db.update(backups)
68
+ .set({
69
+ status: 'completed' as BackupStatus,
70
+ sizeBytes: params.sizeBytes,
71
+ metadata: params.metadata ?? {},
72
+ schemaVersion: params.schemaVersion ?? null,
73
+ completedAt: new Date(),
74
+ })
75
+ .where(eq(backups.id, id))
76
+ .run();
77
+ }
78
+
79
+ /**
80
+ * Mark a backup as failed
81
+ */
82
+ export function failBackup(id: string, errorMessage: string): void {
83
+ const db = getDb();
84
+ db.update(backups)
85
+ .set({
86
+ status: 'failed' as BackupStatus,
87
+ errorMessage,
88
+ completedAt: new Date(),
89
+ })
90
+ .where(eq(backups.id, id))
91
+ .run();
92
+ }
93
+
94
+ /**
95
+ * Get a backup by ID or short ID prefix.
96
+ * Supports both full UUIDs and 8-char short IDs.
97
+ */
98
+ export function getBackup(id: string): Backup | null {
99
+ const db = getDb();
100
+
101
+ // Try exact match first
102
+ const exact = db.select().from(backups).where(eq(backups.id, id)).limit(1).all();
103
+ if (exact.length > 0) return exact[0];
104
+
105
+ // Try prefix match for short IDs
106
+ if (id.length < 36) {
107
+ const prefixed = db
108
+ .select()
109
+ .from(backups)
110
+ .where(like(backups.id, `${id}%`))
111
+ .all();
112
+ if (prefixed.length === 1) return prefixed[0];
113
+ if (prefixed.length > 1) {
114
+ throw new Error(
115
+ `Ambiguous backup ID '${id}' matches ${prefixed.length} backups. Use a longer prefix.`,
116
+ );
117
+ }
118
+ }
119
+
120
+ // Try name match (case-insensitive)
121
+ const byName = db.select().from(backups).where(like(backups.name, id)).all();
122
+ if (byName.length === 1) return byName[0];
123
+ if (byName.length > 1) {
124
+ throw new Error(
125
+ `Ambiguous backup name '${id}' matches ${byName.length} backups. Use a unique name or backup ID.`,
126
+ );
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * List backups with optional module filter and limit
134
+ */
135
+ export function listBackups(options?: {
136
+ moduleId?: string;
137
+ limit?: number;
138
+ }): Backup[] {
139
+ const db = getDb();
140
+ const limit = options?.limit ?? 20;
141
+
142
+ if (options?.moduleId) {
143
+ return db
144
+ .select()
145
+ .from(backups)
146
+ .where(eq(backups.moduleId, options.moduleId))
147
+ .orderBy(desc(backups.startedAt))
148
+ .limit(limit)
149
+ .all();
150
+ }
151
+
152
+ return db.select().from(backups).orderBy(desc(backups.startedAt)).limit(limit).all();
153
+ }
154
+
155
+ /**
156
+ * List completed backups for a specific module, ordered newest first
157
+ */
158
+ export function listCompletedBackupsForModule(moduleId: string): Backup[] {
159
+ const db = getDb();
160
+ return db
161
+ .select()
162
+ .from(backups)
163
+ .where(eq(backups.moduleId, moduleId))
164
+ .orderBy(desc(backups.startedAt))
165
+ .all()
166
+ .filter((b) => b.status === 'completed');
167
+ }
168
+
169
+ /**
170
+ * Set or clear a human-readable name on a backup
171
+ */
172
+ export function updateBackupName(id: string, name: string | null): void {
173
+ const db = getDb();
174
+ db.update(backups).set({ name }).where(eq(backups.id, id)).run();
175
+ }
176
+
177
+ /**
178
+ * Delete a backup record
179
+ */
180
+ export function deleteBackupRecord(id: string): void {
181
+ const db = getDb();
182
+ db.delete(backups).where(eq(backups.id, id)).run();
183
+ }
184
+
185
+ /**
186
+ * Format bytes into human-readable size
187
+ */
188
+ export function formatSize(bytes: number | null): string {
189
+ if (bytes === null || bytes === 0) return '0 B';
190
+ const units = ['B', 'KB', 'MB', 'GB'];
191
+ let size = bytes;
192
+ let unitIndex = 0;
193
+ while (size >= 1024 && unitIndex < units.length - 1) {
194
+ size /= 1024;
195
+ unitIndex++;
196
+ }
197
+ return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
198
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Backup restore service.
3
+ * Downloads, decrypts, extracts backup archives and executes on_restore hooks.
4
+ * For system state backups, restores the Celilo database.
5
+ */
6
+
7
+ import { execSync } from 'node:child_process';
8
+ import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { eq } from 'drizzle-orm';
12
+ import { getDbPath } from '../config/paths';
13
+ import { closeDb, getDb } from '../db/client';
14
+ import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
15
+ import type { Backup } from '../db/schema';
16
+ import { invokeHook } from '../hooks/executor';
17
+ import { createConsoleLogger } from '../hooks/logger';
18
+ import type { ModuleManifest } from '../manifest/schema';
19
+ import { decryptSecret } from '../secrets/encryption';
20
+ import { getOrCreateMasterKey } from '../secrets/master-key';
21
+ import { shellEscape } from '../utils/shell';
22
+ import { createStorageProvider } from './backup-storage';
23
+
24
+ export interface RestoreResult {
25
+ success: boolean;
26
+ error?: string;
27
+ healthCheckPassed?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Restore a system state backup (replaces the Celilo database)
32
+ */
33
+ export async function restoreSystemStateBackup(backup: Backup): Promise<RestoreResult> {
34
+ const provider = await createStorageProvider(backup.storageId);
35
+ const tempDir = join(tmpdir(), `celilo-restore-${backup.id}`);
36
+
37
+ try {
38
+ mkdirSync(tempDir, { recursive: true });
39
+
40
+ // Download encrypted archive
41
+ const encryptedPath = join(tempDir, 'system.enc');
42
+ await provider.download(backup.storagePath, encryptedPath);
43
+
44
+ // Decrypt
45
+ const encryptedData = JSON.parse(readFileSync(encryptedPath, 'utf-8'));
46
+ const masterKey = await getOrCreateMasterKey();
47
+ const base64Data = decryptSecret(encryptedData, masterKey);
48
+ const dbData = Buffer.from(base64Data, 'base64');
49
+
50
+ // Write restored database to temp file first
51
+ const restoredDbPath = join(tempDir, 'celilo.db');
52
+ writeFileSync(restoredDbPath, dbData);
53
+
54
+ // Close current database connection
55
+ closeDb();
56
+
57
+ // Replace the database file
58
+ const dbPath = getDbPath();
59
+ copyFileSync(restoredDbPath, dbPath);
60
+
61
+ // Remove WAL/SHM files (they're from the old DB state)
62
+ try {
63
+ rmSync(`${dbPath}-wal`, { force: true });
64
+ } catch {
65
+ // May not exist
66
+ }
67
+ try {
68
+ rmSync(`${dbPath}-shm`, { force: true });
69
+ } catch {
70
+ // May not exist
71
+ }
72
+
73
+ return { success: true };
74
+ } catch (error) {
75
+ return {
76
+ success: false,
77
+ error: `System restore failed: ${error instanceof Error ? error.message : String(error)}`,
78
+ };
79
+ } finally {
80
+ try {
81
+ rmSync(tempDir, { recursive: true, force: true });
82
+ } catch {
83
+ // Best effort cleanup
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Build config and secret maps for a module
90
+ */
91
+ async function buildModuleContext(moduleId: string): Promise<{
92
+ configMap: Record<string, unknown>;
93
+ secretMap: Record<string, string>;
94
+ }> {
95
+ const db = getDb();
96
+
97
+ const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
98
+ const configMap: Record<string, unknown> = {};
99
+ for (const c of configs) {
100
+ configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
101
+ }
102
+
103
+ const secretRecords = db
104
+ .select()
105
+ .from(secretsTable)
106
+ .where(eq(secretsTable.moduleId, moduleId))
107
+ .all();
108
+ const masterKey = await getOrCreateMasterKey();
109
+ const secretMap: Record<string, string> = {};
110
+ for (const s of secretRecords) {
111
+ secretMap[s.name] = decryptSecret(
112
+ { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag },
113
+ masterKey,
114
+ );
115
+ }
116
+
117
+ return { configMap, secretMap };
118
+ }
119
+
120
+ /**
121
+ * Restore a module data backup.
122
+ * Downloads, decrypts, extracts, then executes the module's on_restore hook.
123
+ * Optionally runs a health check after restore.
124
+ */
125
+ export async function restoreModuleBackup(
126
+ backup: Backup,
127
+ options: { runHealthCheck?: boolean } = {},
128
+ ): Promise<RestoreResult> {
129
+ if (!backup.moduleId) {
130
+ return { success: false, error: 'Backup has no associated module' };
131
+ }
132
+
133
+ const db = getDb();
134
+ const mod = db.select().from(modules).where(eq(modules.id, backup.moduleId)).get();
135
+ if (!mod) {
136
+ return { success: false, error: `Module not found: ${backup.moduleId}` };
137
+ }
138
+
139
+ const manifest = mod.manifestData as unknown as ModuleManifest;
140
+ const hookDef = manifest.hooks?.on_restore;
141
+ if (!hookDef) {
142
+ return {
143
+ success: false,
144
+ error: `Module '${mod.id}' has no on_restore hook defined`,
145
+ };
146
+ }
147
+
148
+ const provider = await createStorageProvider(backup.storageId);
149
+ const tempDir = join(tmpdir(), `celilo-restore-${backup.id}`);
150
+ const restoreDir = join(tempDir, 'artifacts');
151
+
152
+ try {
153
+ mkdirSync(restoreDir, { recursive: true });
154
+
155
+ // Download encrypted archive
156
+ const encryptedPath = join(tempDir, 'backup.tar.enc');
157
+ await provider.download(backup.storagePath, encryptedPath);
158
+
159
+ // Decrypt
160
+ const encryptedData = JSON.parse(readFileSync(encryptedPath, 'utf-8'));
161
+ const masterKey = await getOrCreateMasterKey();
162
+ const base64Data = decryptSecret(encryptedData, masterKey);
163
+ const tarData = Buffer.from(base64Data, 'base64');
164
+
165
+ // Write tar and extract
166
+ const tarPath = join(tempDir, 'backup.tar');
167
+ writeFileSync(tarPath, tarData);
168
+ execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(restoreDir)}`);
169
+
170
+ // Build context for hook execution
171
+ const { configMap, secretMap } = await buildModuleContext(mod.id);
172
+ const logger = createConsoleLogger(mod.id, 'on_restore');
173
+
174
+ // Execute on_restore hook
175
+ const hookResult = await invokeHook(
176
+ mod.sourcePath,
177
+ 'on_restore',
178
+ manifest.celilo_contract,
179
+ hookDef,
180
+ {
181
+ restore_dir: restoreDir,
182
+ schema_version: backup.schemaVersion ?? '',
183
+ },
184
+ configMap,
185
+ secretMap,
186
+ logger,
187
+ {
188
+ debug: false,
189
+ },
190
+ );
191
+
192
+ if (!hookResult.success) {
193
+ return {
194
+ success: false,
195
+ error: hookResult.error ?? 'on_restore hook failed',
196
+ };
197
+ }
198
+
199
+ // Run health check if requested and the module has one
200
+ let healthCheckPassed: boolean | undefined;
201
+ if (options.runHealthCheck && manifest.hooks?.health_check) {
202
+ try {
203
+ const { runModuleHealthCheck } = await import('./health-runner');
204
+ const healthResult = await runModuleHealthCheck(mod.id, db, {
205
+ noInteractive: true,
206
+ });
207
+ healthCheckPassed = healthResult.status === 'healthy';
208
+ } catch {
209
+ healthCheckPassed = false;
210
+ }
211
+ }
212
+
213
+ return {
214
+ success: true,
215
+ healthCheckPassed,
216
+ };
217
+ } catch (error) {
218
+ return {
219
+ success: false,
220
+ error: `Restore failed: ${error instanceof Error ? error.message : String(error)}`,
221
+ };
222
+ } finally {
223
+ try {
224
+ rmSync(tempDir, { recursive: true, force: true });
225
+ } catch {
226
+ // Best effort cleanup
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Backup retention service.
3
+ * Enforces retention policies defined in module manifests.
4
+ * Policies are count-based (keep last N) and age-based (delete older than X days).
5
+ * Whichever limit is hit first triggers deletion.
6
+ */
7
+
8
+ import type { Backup } from '../db/schema';
9
+ import { deleteBackupRecord, listCompletedBackupsForModule } from './backup-metadata';
10
+ import { createStorageProvider } from './backup-storage';
11
+
12
+ export interface RetentionPolicy {
13
+ count: number;
14
+ maxAgeDays: number;
15
+ }
16
+
17
+ export interface PruneResult {
18
+ moduleId: string;
19
+ deleted: number;
20
+ deletedPaths: string[];
21
+ }
22
+
23
+ /**
24
+ * Identify backups that should be pruned per the retention policy
25
+ */
26
+ export function identifyExpiredBackups(backupsList: Backup[], policy: RetentionPolicy): Backup[] {
27
+ const now = Date.now();
28
+ const maxAgeMs = policy.maxAgeDays * 24 * 60 * 60 * 1000;
29
+ const expired: Backup[] = [];
30
+
31
+ // Backups are already sorted newest-first from the query
32
+ for (let i = 0; i < backupsList.length; i++) {
33
+ const backup = backupsList[i];
34
+ const age = now - new Date(backup.startedAt).getTime();
35
+ const exceedsCount = i >= policy.count;
36
+ const exceedsAge = age > maxAgeMs;
37
+
38
+ if (exceedsCount || exceedsAge) {
39
+ expired.push(backup);
40
+ }
41
+ }
42
+
43
+ return expired;
44
+ }
45
+
46
+ /**
47
+ * Prune expired backups for a module.
48
+ * Deletes from storage and removes database records.
49
+ */
50
+ export async function pruneBackupsForModule(
51
+ moduleId: string,
52
+ policy: RetentionPolicy,
53
+ dryRun = false,
54
+ ): Promise<PruneResult> {
55
+ const backupsList = listCompletedBackupsForModule(moduleId);
56
+ const expired = identifyExpiredBackups(backupsList, policy);
57
+
58
+ if (dryRun || expired.length === 0) {
59
+ return {
60
+ moduleId,
61
+ deleted: expired.length,
62
+ deletedPaths: expired.map((b) => b.storagePath),
63
+ };
64
+ }
65
+
66
+ const deletedPaths: string[] = [];
67
+
68
+ for (const backup of expired) {
69
+ try {
70
+ const provider = await createStorageProvider(backup.storageId);
71
+ await provider.delete(backup.storagePath);
72
+ } catch {
73
+ // Storage deletion may fail if file already removed — continue
74
+ }
75
+ deleteBackupRecord(backup.id);
76
+ deletedPaths.push(backup.storagePath);
77
+ }
78
+
79
+ return {
80
+ moduleId,
81
+ deleted: deletedPaths.length,
82
+ deletedPaths,
83
+ };
84
+ }