@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,283 @@
1
+ /**
2
+ * Tests for SSH key manager
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
6
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { closeDb } from '../db/client';
10
+ import { runMigrations } from '../db/migrate';
11
+ import { addMachine } from './machine-pool';
12
+ import {
13
+ ManagedSshKey,
14
+ cleanupTemporarySshKeys,
15
+ deleteTemporarySshKey,
16
+ writeTemporarySshKey,
17
+ } from './ssh-key-manager';
18
+
19
+ describe('ssh-key-manager', () => {
20
+ let testDbPath: string;
21
+ let testDir: string;
22
+ let dataDir: string;
23
+
24
+ beforeEach(async () => {
25
+ // Create temp directory for test database and data
26
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-test-'));
27
+ testDbPath = join(testDir, 'test.db');
28
+ dataDir = join(testDir, 'data');
29
+
30
+ // Set environment variables
31
+ process.env.CELILO_DB_PATH = testDbPath;
32
+ process.env.CELILO_DATA_DIR = dataDir;
33
+
34
+ // Initialize database and run migrations
35
+ await runMigrations(testDbPath);
36
+
37
+ // Create a dummy master key for encryption
38
+ const masterKeyPath = join(dataDir, 'master.key');
39
+ process.env.CELILO_MASTER_KEY_PATH = masterKeyPath;
40
+ const fs = await import('node:fs/promises');
41
+ await fs.mkdir(dataDir, { recursive: true });
42
+ await fs.writeFile(masterKeyPath, 'a'.repeat(64), 'utf8');
43
+ });
44
+
45
+ afterEach(() => {
46
+ // Close database connection
47
+ closeDb();
48
+
49
+ // Clean up test directory
50
+ if (testDir) {
51
+ rmSync(testDir, { recursive: true, force: true });
52
+ }
53
+
54
+ // Clear environment variables
55
+ process.env.CELILO_DB_PATH = undefined;
56
+ process.env.CELILO_DATA_DIR = undefined;
57
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
58
+ });
59
+
60
+ describe('writeTemporarySshKey', () => {
61
+ it('creates temp directory and writes key file', async () => {
62
+ const machine = await addMachine({
63
+ hostname: 'test-machine',
64
+ zone: 'internal',
65
+ ipAddress: '192.168.1.100',
66
+ sshUser: 'root',
67
+ sshKey: 'test-ssh-key-content',
68
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
69
+ role: 'host',
70
+ interfaces: [],
71
+ assignedModuleIds: [],
72
+ });
73
+
74
+ const keyPath = await writeTemporarySshKey(machine.id);
75
+
76
+ // Check file exists
77
+ expect(existsSync(keyPath)).toBe(true);
78
+
79
+ // Check file content is decrypted
80
+ const content = readFileSync(keyPath, 'utf8');
81
+ expect(content).toBe('test-ssh-key-content');
82
+
83
+ // Check file permissions (should be 0o600)
84
+ const stats = statSync(keyPath);
85
+ const mode = stats.mode & 0o777;
86
+ expect(mode).toBe(0o600);
87
+ });
88
+
89
+ it('returns correct temp key path', async () => {
90
+ const machine = await addMachine({
91
+ hostname: 'test-machine',
92
+ zone: 'internal',
93
+ ipAddress: '192.168.1.100',
94
+ sshUser: 'root',
95
+ sshKey: 'test-ssh-key-content',
96
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
97
+ role: 'host',
98
+ interfaces: [],
99
+ assignedModuleIds: [],
100
+ });
101
+
102
+ const keyPath = await writeTemporarySshKey(machine.id);
103
+
104
+ expect(keyPath).toContain('tmp/ansible-keys');
105
+ expect(keyPath).toContain(`machine-${machine.id}.key`);
106
+ });
107
+ });
108
+
109
+ describe('deleteTemporarySshKey', () => {
110
+ it('removes temp key file', async () => {
111
+ const machine = await addMachine({
112
+ hostname: 'test-machine',
113
+ zone: 'internal',
114
+ ipAddress: '192.168.1.100',
115
+ sshUser: 'root',
116
+ sshKey: 'test-ssh-key-content',
117
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
118
+ role: 'host',
119
+ interfaces: [],
120
+ assignedModuleIds: [],
121
+ });
122
+
123
+ const keyPath = await writeTemporarySshKey(machine.id);
124
+ expect(existsSync(keyPath)).toBe(true);
125
+
126
+ deleteTemporarySshKey(machine.id);
127
+ expect(existsSync(keyPath)).toBe(false);
128
+ });
129
+
130
+ it('does not throw if key file does not exist', () => {
131
+ expect(() => deleteTemporarySshKey('non-existent-machine')).not.toThrow();
132
+ });
133
+ });
134
+
135
+ describe('cleanupTemporarySshKeys', () => {
136
+ it('removes all temp key files', async () => {
137
+ const machine1 = await addMachine({
138
+ hostname: 'machine1',
139
+ zone: 'internal',
140
+ ipAddress: '192.168.1.100',
141
+ sshUser: 'root',
142
+ sshKey: 'key1',
143
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
144
+ role: 'host',
145
+ interfaces: [],
146
+ assignedModuleIds: [],
147
+ });
148
+
149
+ const machine2 = await addMachine({
150
+ hostname: 'machine2',
151
+ zone: 'internal',
152
+ ipAddress: '192.168.1.101',
153
+ sshUser: 'root',
154
+ sshKey: 'key2',
155
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
156
+ role: 'host',
157
+ interfaces: [],
158
+ assignedModuleIds: [],
159
+ });
160
+
161
+ const keyPath1 = await writeTemporarySshKey(machine1.id);
162
+ const keyPath2 = await writeTemporarySshKey(machine2.id);
163
+
164
+ expect(existsSync(keyPath1)).toBe(true);
165
+ expect(existsSync(keyPath2)).toBe(true);
166
+
167
+ cleanupTemporarySshKeys();
168
+
169
+ expect(existsSync(keyPath1)).toBe(false);
170
+ expect(existsSync(keyPath2)).toBe(false);
171
+ });
172
+
173
+ it('does not throw if temp directory does not exist', () => {
174
+ expect(() => cleanupTemporarySshKeys()).not.toThrow();
175
+ });
176
+ });
177
+
178
+ describe('ManagedSshKey', () => {
179
+ it('writes key and provides path', async () => {
180
+ const machine = await addMachine({
181
+ hostname: 'test-machine',
182
+ zone: 'internal',
183
+ ipAddress: '192.168.1.100',
184
+ sshUser: 'root',
185
+ sshKey: 'test-key',
186
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
187
+ role: 'host',
188
+ interfaces: [],
189
+ assignedModuleIds: [],
190
+ });
191
+
192
+ const managedKey = new ManagedSshKey(machine.id);
193
+ const keyPath = await managedKey.write();
194
+
195
+ expect(existsSync(keyPath)).toBe(true);
196
+ expect(managedKey.getPath()).toBe(keyPath);
197
+ });
198
+
199
+ it('cleans up key file', async () => {
200
+ const machine = await addMachine({
201
+ hostname: 'test-machine',
202
+ zone: 'internal',
203
+ ipAddress: '192.168.1.100',
204
+ sshUser: 'root',
205
+ sshKey: 'test-key',
206
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
207
+ role: 'host',
208
+ interfaces: [],
209
+ assignedModuleIds: [],
210
+ });
211
+
212
+ const managedKey = new ManagedSshKey(machine.id);
213
+ const keyPath = await managedKey.write();
214
+
215
+ expect(existsSync(keyPath)).toBe(true);
216
+
217
+ managedKey.cleanup();
218
+ expect(existsSync(keyPath)).toBe(false);
219
+ });
220
+
221
+ it('use() method handles automatic cleanup', async () => {
222
+ const machine = await addMachine({
223
+ hostname: 'test-machine',
224
+ zone: 'internal',
225
+ ipAddress: '192.168.1.100',
226
+ sshUser: 'root',
227
+ sshKey: 'test-key',
228
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
229
+ role: 'host',
230
+ interfaces: [],
231
+ assignedModuleIds: [],
232
+ });
233
+
234
+ const managedKey = new ManagedSshKey(machine.id);
235
+ let capturedPath: string | null = null;
236
+
237
+ await managedKey.use(async (keyPath) => {
238
+ capturedPath = keyPath;
239
+ expect(existsSync(keyPath)).toBe(true);
240
+ });
241
+
242
+ // Key should be cleaned up after callback
243
+ expect(capturedPath).not.toBeNull();
244
+ // biome-ignore lint/style/noNonNullAssertion: checked with not.toBeNull() above
245
+ expect(existsSync(capturedPath!)).toBe(false);
246
+ });
247
+
248
+ it('use() method cleans up even if callback throws', async () => {
249
+ const machine = await addMachine({
250
+ hostname: 'test-machine',
251
+ zone: 'internal',
252
+ ipAddress: '192.168.1.100',
253
+ sshUser: 'root',
254
+ sshKey: 'test-key',
255
+ hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
256
+ role: 'host',
257
+ interfaces: [],
258
+ assignedModuleIds: [],
259
+ });
260
+
261
+ const managedKey = new ManagedSshKey(machine.id);
262
+ let capturedPath: string | null = null;
263
+
264
+ await expect(
265
+ managedKey.use(async (keyPath) => {
266
+ capturedPath = keyPath;
267
+ throw new Error('Test error');
268
+ }),
269
+ ).rejects.toThrow('Test error');
270
+
271
+ // Key should still be cleaned up despite error
272
+ expect(capturedPath).not.toBeNull();
273
+ // biome-ignore lint/style/noNonNullAssertion: checked with not.toBeNull() above
274
+ expect(existsSync(capturedPath!)).toBe(false);
275
+ });
276
+
277
+ it('throws if getPath() called before write()', () => {
278
+ const managedKey = new ManagedSshKey('test-machine-id');
279
+
280
+ expect(() => managedKey.getPath()).toThrow('SSH key not written yet');
281
+ });
282
+ });
283
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * SSH Key Manager
3
+ * Handles temporary SSH key files for Ansible execution
4
+ * Keys are stored encrypted in database, written temporarily to filesystem for Ansible
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { getDataDir } from '../config/paths';
10
+ import { getMachineSshKey } from './machine-pool';
11
+
12
+ /**
13
+ * Get the temporary SSH keys directory
14
+ */
15
+ function getTempKeysDir(): string {
16
+ return join(getDataDir(), 'tmp', 'ansible-keys');
17
+ }
18
+
19
+ /**
20
+ * Ensure the temp keys directory exists
21
+ */
22
+ function ensureTempKeysDir(): void {
23
+ const dir = getTempKeysDir();
24
+ if (!existsSync(dir)) {
25
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get the temporary key file path for a machine
31
+ */
32
+ export function getTempKeyPath(machineId: string): string {
33
+ return join(getTempKeysDir(), `machine-${machineId}.key`);
34
+ }
35
+
36
+ /**
37
+ * Write a temporary SSH key file for Ansible
38
+ * The key is decrypted from the database and written to a temporary file
39
+ *
40
+ * @param machineId - Machine ID
41
+ * @returns Absolute path to the temporary key file
42
+ */
43
+ export async function writeTemporarySshKey(machineId: string): Promise<string> {
44
+ ensureTempKeysDir();
45
+
46
+ // Get decrypted SSH key from database
47
+ const keyContent = await getMachineSshKey(machineId);
48
+
49
+ // Write to temporary file with restrictive permissions
50
+ const keyPath = getTempKeyPath(machineId);
51
+ writeFileSync(keyPath, keyContent, { mode: 0o600 });
52
+
53
+ return keyPath;
54
+ }
55
+
56
+ /**
57
+ * Delete a temporary SSH key file
58
+ *
59
+ * @param machineId - Machine ID
60
+ */
61
+ export function deleteTemporarySshKey(machineId: string): void {
62
+ const keyPath = getTempKeyPath(machineId);
63
+ if (existsSync(keyPath)) {
64
+ rmSync(keyPath, { force: true });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Clean up all temporary SSH key files
70
+ * Should be called after Ansible execution or on process exit
71
+ */
72
+ export function cleanupTemporarySshKeys(): void {
73
+ const dir = getTempKeysDir();
74
+ if (!existsSync(dir)) {
75
+ return;
76
+ }
77
+
78
+ // Remove all .key files
79
+ const files = readdirSync(dir);
80
+ for (const file of files) {
81
+ if (file.endsWith('.key')) {
82
+ const filePath = join(dir, file);
83
+ rmSync(filePath, { force: true });
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Register cleanup handlers for process exit
90
+ * Ensures temporary SSH keys are always cleaned up
91
+ */
92
+ export function registerCleanupHandlers(): void {
93
+ // Clean up on normal exit
94
+ process.on('exit', () => {
95
+ try {
96
+ cleanupTemporarySshKeys();
97
+ } catch (error) {
98
+ console.error('Failed to cleanup SSH keys on exit:', error);
99
+ }
100
+ });
101
+
102
+ // Clean up on SIGINT (Ctrl+C)
103
+ process.on('SIGINT', () => {
104
+ try {
105
+ cleanupTemporarySshKeys();
106
+ } catch (error) {
107
+ console.error('Failed to cleanup SSH keys on SIGINT:', error);
108
+ }
109
+ process.exit(130); // Standard exit code for SIGINT
110
+ });
111
+
112
+ // Clean up on SIGTERM
113
+ process.on('SIGTERM', () => {
114
+ try {
115
+ cleanupTemporarySshKeys();
116
+ } catch (error) {
117
+ console.error('Failed to cleanup SSH keys on SIGTERM:', error);
118
+ }
119
+ process.exit(143); // Standard exit code for SIGTERM
120
+ });
121
+
122
+ // Clean up on uncaught exception
123
+ process.on('uncaughtException', (error) => {
124
+ console.error('Uncaught exception:', error);
125
+ try {
126
+ cleanupTemporarySshKeys();
127
+ } catch (cleanupError) {
128
+ console.error('Failed to cleanup SSH keys on uncaught exception:', cleanupError);
129
+ }
130
+ process.exit(1);
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Managed SSH key context for safe cleanup
136
+ * Use this for automatic cleanup in try-finally patterns
137
+ *
138
+ * @example
139
+ * const keyPath = await writeTemporarySshKey(machineId);
140
+ * try {
141
+ * await runAnsible(keyPath);
142
+ * } finally {
143
+ * deleteTemporarySshKey(machineId);
144
+ * }
145
+ */
146
+ export class ManagedSshKey {
147
+ private machineId: string;
148
+ private keyPath: string | null = null;
149
+
150
+ constructor(machineId: string) {
151
+ this.machineId = machineId;
152
+ }
153
+
154
+ /**
155
+ * Write the SSH key and return the path
156
+ */
157
+ async write(): Promise<string> {
158
+ this.keyPath = await writeTemporarySshKey(this.machineId);
159
+ return this.keyPath;
160
+ }
161
+
162
+ /**
163
+ * Get the key path (throws if not written yet)
164
+ */
165
+ getPath(): string {
166
+ if (!this.keyPath) {
167
+ throw new Error('SSH key not written yet. Call write() first.');
168
+ }
169
+ return this.keyPath;
170
+ }
171
+
172
+ /**
173
+ * Clean up the temporary key file
174
+ */
175
+ cleanup(): void {
176
+ if (this.keyPath) {
177
+ deleteTemporarySshKey(this.machineId);
178
+ this.keyPath = null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Use the key in a callback with automatic cleanup
184
+ */
185
+ async use<T>(callback: (keyPath: string) => Promise<T>): Promise<T> {
186
+ await this.write();
187
+ try {
188
+ return await callback(this.getPath());
189
+ } finally {
190
+ this.cleanup();
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Local filesystem storage provider.
3
+ * Stores backup archives in a local directory (or NAS-mounted path).
4
+ */
5
+
6
+ import {
7
+ copyFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from 'node:fs';
14
+ import { dirname, join, relative } from 'node:path';
15
+ import type { StorageProvider, StorageVerifyResult } from './types';
16
+
17
+ const BACKUP_PREFIX = 'celilo-backups';
18
+
19
+ export interface LocalStorageConfig {
20
+ path: string;
21
+ }
22
+
23
+ export function createLocalStorageProvider(config: LocalStorageConfig): StorageProvider {
24
+ const basePath = join(config.path, BACKUP_PREFIX);
25
+
26
+ return {
27
+ async upload(localPath: string, remotePath: string): Promise<void> {
28
+ const destPath = join(basePath, remotePath);
29
+ const destDir = dirname(destPath);
30
+ mkdirSync(destDir, { recursive: true });
31
+ copyFileSync(localPath, destPath);
32
+ },
33
+
34
+ async download(remotePath: string, localPath: string): Promise<void> {
35
+ const srcPath = join(basePath, remotePath);
36
+ if (!existsSync(srcPath)) {
37
+ throw new Error(`Backup file not found: ${remotePath}`);
38
+ }
39
+ const destDir = dirname(localPath);
40
+ mkdirSync(destDir, { recursive: true });
41
+ copyFileSync(srcPath, localPath);
42
+ },
43
+
44
+ async delete(remotePath: string): Promise<void> {
45
+ const fullPath = join(basePath, remotePath);
46
+ if (existsSync(fullPath)) {
47
+ unlinkSync(fullPath);
48
+ }
49
+ },
50
+
51
+ async list(prefix: string): Promise<string[]> {
52
+ const searchDir = join(basePath, prefix);
53
+ if (!existsSync(searchDir)) {
54
+ return [];
55
+ }
56
+ return collectFiles(searchDir).map((f) => relative(basePath, f));
57
+ },
58
+
59
+ async verify(): Promise<StorageVerifyResult> {
60
+ try {
61
+ // Check base directory is writable
62
+ mkdirSync(basePath, { recursive: true });
63
+
64
+ // Write test file
65
+ const testFile = join(basePath, '.celilo-verify-test');
66
+ writeFileSync(testFile, 'verify');
67
+
68
+ // Read it back
69
+ const content = Bun.file(testFile);
70
+ const text = await content.text();
71
+ if (text !== 'verify') {
72
+ return { success: false, message: 'Read-back verification failed' };
73
+ }
74
+
75
+ // Delete test file
76
+ unlinkSync(testFile);
77
+
78
+ return { success: true, message: `Write test passed at ${basePath}` };
79
+ } catch (error) {
80
+ return {
81
+ success: false,
82
+ message: `Storage verification failed: ${error instanceof Error ? error.message : String(error)}`,
83
+ };
84
+ }
85
+ },
86
+
87
+ async initialize(): Promise<void> {
88
+ mkdirSync(basePath, { recursive: true });
89
+ },
90
+ };
91
+ }
92
+
93
+ function collectFiles(dir: string): string[] {
94
+ const results: string[] = [];
95
+ const entries = readdirSync(dir, { withFileTypes: true });
96
+ for (const entry of entries) {
97
+ const fullPath = join(dir, entry.name);
98
+ if (entry.isDirectory()) {
99
+ results.push(...collectFiles(fullPath));
100
+ } else {
101
+ results.push(fullPath);
102
+ }
103
+ }
104
+ return results;
105
+ }