@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,253 @@
1
+ /**
2
+ * System Initialization Service
3
+ *
4
+ * Handles first-time system configuration with sensible defaults.
5
+ * Supports both interactive and non-interactive (--accept-defaults) modes.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import { eq } from 'drizzle-orm';
12
+ import type { DbClient } from '../db/client';
13
+ import { systemConfig } from '../db/schema';
14
+
15
+ /**
16
+ * System configuration schema interface
17
+ */
18
+ interface SystemConfigSchema {
19
+ properties: Record<
20
+ string,
21
+ {
22
+ type: string;
23
+ default?: string | number;
24
+ description?: string;
25
+ pattern?: string;
26
+ minimum?: number;
27
+ maximum?: number;
28
+ format?: string;
29
+ }
30
+ >;
31
+ }
32
+
33
+ /**
34
+ * Load system config schema from JSON file
35
+ */
36
+ function loadSchema(): SystemConfigSchema {
37
+ // Try common locations (relative to this file's directory and cwd)
38
+ const thisDir = dirname(new URL(import.meta.url).pathname);
39
+ const candidates = [
40
+ join(thisDir, '..', '..', 'schemas', 'system_config.json'), // Relative to src/services/ → apps/celilo/schemas/
41
+ './schemas/system_config.json', // From cwd (apps/celilo)
42
+ join(process.cwd(), 'schemas', 'system_config.json'),
43
+ ];
44
+
45
+ for (const candidate of candidates) {
46
+ if (existsSync(candidate)) {
47
+ try {
48
+ return JSON.parse(readFileSync(candidate, 'utf-8'));
49
+ } catch (error) {
50
+ throw new Error(
51
+ `Failed to parse system_config.json schema: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
52
+ );
53
+ }
54
+ }
55
+ }
56
+
57
+ throw new Error('Could not find system_config.json schema file');
58
+ }
59
+
60
+ /**
61
+ * Get default configuration values from schema
62
+ */
63
+ export function getDefaultConfiguration(): Record<string, string | number> {
64
+ const schema = loadSchema();
65
+ const defaults: Record<string, string | number> = {};
66
+
67
+ for (const [key, property] of Object.entries(schema.properties)) {
68
+ if (property.default !== undefined) {
69
+ defaults[key] = property.default;
70
+ }
71
+ }
72
+
73
+ return defaults;
74
+ }
75
+
76
+ /**
77
+ * Compute gateway IP from subnet CIDR
78
+ * Returns the first usable IP address in the subnet
79
+ *
80
+ * @param subnet - CIDR notation (e.g., "10.0.10.0/24")
81
+ * @returns Gateway IP (e.g., "10.0.10.1")
82
+ */
83
+ function computeGateway(subnet: string): string {
84
+ const [network, _bits] = subnet.split('/');
85
+ const octets = network.split('.').map(Number);
86
+
87
+ // First usable IP (network address + 1)
88
+ octets[3] += 1;
89
+
90
+ return octets.join('.');
91
+ }
92
+
93
+ /**
94
+ * Auto-detect SSH public key from common locations
95
+ *
96
+ * Checks ~/.ssh/ for common key files:
97
+ * - id_ed25519.pub (preferred)
98
+ * - id_rsa.pub
99
+ * - id_ecdsa.pub
100
+ *
101
+ * @returns SSH public key content or null if none found
102
+ */
103
+ export interface DetectedSSHKey {
104
+ filename: string;
105
+ keyType: string;
106
+ content: string;
107
+ }
108
+
109
+ export function autoDetectSSHKeys(): DetectedSSHKey[] {
110
+ const sshDir = join(homedir(), '.ssh');
111
+ const keyFiles = ['id_ed25519.pub', 'id_rsa.pub', 'id_ecdsa.pub'];
112
+ const keys: DetectedSSHKey[] = [];
113
+
114
+ for (const keyFile of keyFiles) {
115
+ const keyPath = join(sshDir, keyFile);
116
+ if (existsSync(keyPath)) {
117
+ try {
118
+ const keyContent = readFileSync(keyPath, 'utf-8').trim();
119
+ if (keyContent) {
120
+ const keyType = keyContent.split(' ')[0] || keyFile;
121
+ keys.push({ filename: keyFile, keyType, content: keyContent });
122
+ }
123
+ } catch {}
124
+ }
125
+ }
126
+
127
+ return keys;
128
+ }
129
+
130
+ /** @deprecated Use autoDetectSSHKeys() instead */
131
+ export function autoDetectSSHKey(): string | null {
132
+ const keys = autoDetectSSHKeys();
133
+ return keys.length > 0 ? keys[0].content : null;
134
+ }
135
+
136
+ /**
137
+ * Initialize system configuration with defaults
138
+ *
139
+ * This function does all the "smart" work:
140
+ * - Loads defaults from schema
141
+ * - Computes gateway IPs from subnets
142
+ * - Derives admin email from primary domain
143
+ * - Auto-detects SSH keys
144
+ * - Applies user overrides
145
+ * - Stores everything in the database
146
+ *
147
+ * @param db - Database instance
148
+ * @param overrides - Optional overrides for specific keys (used for interactive mode)
149
+ * @returns Configuration that was applied (for summary display)
150
+ */
151
+ export function initializeSystem(
152
+ db: DbClient,
153
+ overrides: Partial<Record<string, string | number>> = {},
154
+ ): Record<string, string | number> {
155
+ const defaults = getDefaultConfiguration();
156
+
157
+ // Start with defaults
158
+ let config = { ...defaults };
159
+
160
+ // Apply user overrides (filter out undefined values)
161
+ const definedOverrides = Object.fromEntries(
162
+ Object.entries(overrides).filter(([_, v]) => v !== undefined),
163
+ ) as Record<string, string | number>;
164
+ config = { ...config, ...definedOverrides };
165
+
166
+ // Auto-detect SSH key if not provided
167
+ if (!config['ssh.public_key']) {
168
+ const detectedKey = autoDetectSSHKey();
169
+ if (detectedKey) {
170
+ config['ssh.public_key'] = detectedKey;
171
+ }
172
+ }
173
+
174
+ // Compute gateway IPs from subnets (if subnet was provided but not gateway)
175
+ const zones = ['dmz', 'app', 'secure', 'internal'];
176
+ for (const zone of zones) {
177
+ const subnetKey = `network.${zone}.subnet`;
178
+ const gatewayKey = `network.${zone}.gateway`;
179
+
180
+ // If user provided a subnet, compute gateway from it
181
+ if (config[subnetKey] && !overrides[gatewayKey]) {
182
+ config[gatewayKey] = computeGateway(String(config[subnetKey]));
183
+ }
184
+ }
185
+
186
+ // primary_domain and admin.email are no longer system config — they live
187
+ // in the dns_registrar capability and in the authentik/caddy modules
188
+ // respectively as of MANIFEST_V2 Phase 2 (D9).
189
+
190
+ // Store all configuration in database
191
+ for (const [key, value] of Object.entries(config)) {
192
+ if (value === undefined || value === null) {
193
+ continue; // Skip undefined/null values
194
+ }
195
+
196
+ const valueStr = String(value);
197
+
198
+ db.insert(systemConfig)
199
+ .values({ key, value: valueStr })
200
+ .onConflictDoUpdate({
201
+ target: systemConfig.key,
202
+ set: { value: valueStr },
203
+ })
204
+ .run();
205
+ }
206
+
207
+ return config;
208
+ }
209
+
210
+ /**
211
+ * Load existing system configuration from database
212
+ *
213
+ * @param db - Database instance
214
+ * @returns Map of existing configuration values
215
+ */
216
+ export function loadExistingConfiguration(db: DbClient): Record<string, string> {
217
+ const existing: Record<string, string> = {};
218
+
219
+ const rows = db.select().from(systemConfig).all();
220
+
221
+ for (const row of rows) {
222
+ existing[row.key] = row.value;
223
+ }
224
+
225
+ return existing;
226
+ }
227
+
228
+ /**
229
+ * Check if system is already initialized
230
+ *
231
+ * System is considered initialized if critical configuration exists:
232
+ * - At least one network zone subnet
233
+ * - ssh.public_key
234
+ *
235
+ * @param db - Database instance
236
+ * @returns true if system appears to be initialized
237
+ */
238
+ export function isSystemInitialized(db: DbClient): boolean {
239
+ const criticalKeys = ['network.dmz.subnet', 'ssh.public_key'];
240
+
241
+ let foundKeys = 0;
242
+
243
+ for (const key of criticalKeys) {
244
+ const result = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
245
+
246
+ if (result) {
247
+ foundKeys++;
248
+ }
249
+ }
250
+
251
+ // Consider initialized if at least 2 of 3 critical keys exist
252
+ return foundKeys >= 2;
253
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Terraform Plan Safety Validation
3
+ *
4
+ * Parses Terraform plan output and validates that only safe operations are planned.
5
+ *
6
+ * Safety Rules:
7
+ * - Only CREATE operations allowed
8
+ * - Only whitelisted provider resources allowed:
9
+ * - Proxmox: proxmox_lxc, proxmox_vm
10
+ * - Digital Ocean: digitalocean_droplet, digitalocean_*
11
+ * - No UPDATE operations (coming in future phase)
12
+ * - No DELETE operations
13
+ * - No REPLACE operations
14
+ */
15
+
16
+ /**
17
+ * Allowed Terraform resource type prefixes
18
+ * Only resources from these providers can be created
19
+ */
20
+ const ALLOWED_RESOURCE_PREFIXES = ['proxmox_lxc', 'proxmox_vm', 'digitalocean_'];
21
+
22
+ export interface PlanSafetyResult {
23
+ safe: boolean;
24
+ error?: string;
25
+ actions: TerraformAction[];
26
+ }
27
+
28
+ export interface TerraformAction {
29
+ resourceType: string;
30
+ resourceName: string;
31
+ action: 'create' | 'update' | 'delete' | 'replace';
32
+ }
33
+
34
+ /**
35
+ * Parse and validate Terraform plan for safety
36
+ * Policy function - validates plan adheres to safety rules
37
+ *
38
+ * @param planOutput - Terraform plan output (from terraform plan -no-color)
39
+ * @returns Safety validation result
40
+ */
41
+ export function validateTerraformPlanSafety(planOutput: string): PlanSafetyResult {
42
+ const actions = parseTerraformPlan(planOutput);
43
+
44
+ // Check each action against safety rules
45
+ for (const action of actions) {
46
+ // Rule 1: Only whitelisted provider resources allowed
47
+ const isAllowed = ALLOWED_RESOURCE_PREFIXES.some((prefix) =>
48
+ action.resourceType.startsWith(prefix),
49
+ );
50
+
51
+ if (!isAllowed) {
52
+ const allowedList = ALLOWED_RESOURCE_PREFIXES.join(', ');
53
+ return {
54
+ safe: false,
55
+ error: `Unexpected resource type: ${action.resourceType}\nOnly resources from approved providers are allowed: ${allowedList}`,
56
+ actions,
57
+ };
58
+ }
59
+
60
+ // Rule 2: Only CREATE operations allowed
61
+ if (action.action !== 'create') {
62
+ return {
63
+ safe: false,
64
+ error: formatUnsafeOperationError(action),
65
+ actions,
66
+ };
67
+ }
68
+ }
69
+
70
+ return { safe: true, actions };
71
+ }
72
+
73
+ /**
74
+ * Parse Terraform plan output to extract resource actions
75
+ * Policy function - pure parsing logic
76
+ *
77
+ * Terraform plan format examples:
78
+ * # proxmox_lxc.caddy will be created
79
+ * # proxmox_lxc.caddy will be updated in-place
80
+ * # proxmox_lxc.caddy will be updated in-place (some changes)
81
+ * # proxmox_lxc.caddy must be replaced
82
+ * # proxmox_lxc.caddy will be destroyed
83
+ * # proxmox_lxc.caddy must be replaced (forces new resource)
84
+ *
85
+ * @param planOutput - Terraform plan output text
86
+ * @returns Array of parsed actions
87
+ */
88
+ export function parseTerraformPlan(planOutput: string): TerraformAction[] {
89
+ const actions: TerraformAction[] = [];
90
+ const lines = planOutput.split('\n');
91
+
92
+ // Regex patterns for Terraform plan actions
93
+ const createPattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be created$/;
94
+ const updatePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be updated/;
95
+ const replacePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+must be replaced/;
96
+ const deletePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be destroyed$/;
97
+
98
+ for (const line of lines) {
99
+ const trimmed = line.trim();
100
+
101
+ // Try each pattern in order (replace must come before update since replace contains "must be")
102
+ let match = trimmed.match(replacePattern);
103
+ if (match) {
104
+ actions.push({
105
+ resourceType: match[1],
106
+ resourceName: match[2],
107
+ action: 'replace',
108
+ });
109
+ continue;
110
+ }
111
+
112
+ match = trimmed.match(createPattern);
113
+ if (match) {
114
+ actions.push({
115
+ resourceType: match[1],
116
+ resourceName: match[2],
117
+ action: 'create',
118
+ });
119
+ continue;
120
+ }
121
+
122
+ match = trimmed.match(updatePattern);
123
+ if (match) {
124
+ actions.push({
125
+ resourceType: match[1],
126
+ resourceName: match[2],
127
+ action: 'update',
128
+ });
129
+ continue;
130
+ }
131
+
132
+ match = trimmed.match(deletePattern);
133
+ if (match) {
134
+ actions.push({
135
+ resourceType: match[1],
136
+ resourceName: match[2],
137
+ action: 'delete',
138
+ });
139
+ }
140
+ }
141
+
142
+ return actions;
143
+ }
144
+
145
+ /**
146
+ * Format error message for unsafe operations
147
+ * Presentation function - formats output for user
148
+ *
149
+ * @param action - Terraform action that failed validation
150
+ * @returns Formatted error message
151
+ */
152
+ function formatUnsafeOperationError(action: TerraformAction): string {
153
+ const lines = [
154
+ `Unsafe operation: ${action.action} on ${action.resourceType}.${action.resourceName}`,
155
+ '',
156
+ 'Only CREATE operations are currently allowed.',
157
+ ];
158
+
159
+ if (action.action === 'update') {
160
+ lines.push('UPDATE operations require explicit approval.');
161
+ lines.push('');
162
+ lines.push('To fix: Review the changes and manually apply if safe.');
163
+ } else if (action.action === 'delete') {
164
+ lines.push('DELETE operations are never auto-approved.');
165
+ lines.push('');
166
+ lines.push('To fix: Remove the resource manually or use terraform destroy.');
167
+ } else if (action.action === 'replace') {
168
+ lines.push('REPLACE operations require explicit approval.');
169
+ lines.push('');
170
+ lines.push('To fix: Review what triggered the replacement and decide if it should proceed.');
171
+ }
172
+
173
+ return lines.join('\n');
174
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Tests for zone detector
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { closeDb, createDbClient } from '../db/client';
10
+ import { runMigrations } from '../db/migrate';
11
+ import { systemConfig } from '../db/schema';
12
+ import { detectZoneFromIp, isValidIp } from './zone-detector';
13
+
14
+ describe('zone-detector', () => {
15
+ let testDbPath: string;
16
+ let testDir: string;
17
+
18
+ beforeEach(async () => {
19
+ // Create temp directory for test database
20
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-test-'));
21
+ testDbPath = join(testDir, 'test.db');
22
+
23
+ // Set environment variable for database path
24
+ process.env.CELILO_DB_PATH = testDbPath;
25
+
26
+ // Initialize database and run migrations
27
+ await runMigrations(testDbPath);
28
+
29
+ // Insert test network configuration
30
+ const db = createDbClient({ path: testDbPath });
31
+ await db.insert(systemConfig).values([
32
+ { key: 'network.internal.subnet', value: '192.168.0.0/24' },
33
+ { key: 'network.dmz.subnet', value: '10.0.10.0/24' },
34
+ { key: 'network.app.subnet', value: '10.0.20.0/24' },
35
+ { key: 'network.secure.subnet', value: '10.0.30.0/24' },
36
+ ]);
37
+ });
38
+
39
+ afterEach(() => {
40
+ // Close database connection
41
+ closeDb();
42
+
43
+ // Clean up test directory
44
+ if (testDir) {
45
+ rmSync(testDir, { recursive: true, force: true });
46
+ }
47
+
48
+ // Clear environment variables
49
+ process.env.CELILO_DB_PATH = undefined;
50
+ });
51
+
52
+ describe('detectZoneFromIp', () => {
53
+ it('detects internal zone', async () => {
54
+ const zone = await detectZoneFromIp('192.168.0.100');
55
+ expect(zone).toBe('internal');
56
+ });
57
+
58
+ it('detects dmz zone', async () => {
59
+ const zone = await detectZoneFromIp('10.0.10.50');
60
+ expect(zone).toBe('dmz');
61
+ });
62
+
63
+ it('detects app zone', async () => {
64
+ const zone = await detectZoneFromIp('10.0.20.100');
65
+ expect(zone).toBe('app');
66
+ });
67
+
68
+ it('detects secure zone', async () => {
69
+ const zone = await detectZoneFromIp('10.0.30.25');
70
+ expect(zone).toBe('secure');
71
+ });
72
+
73
+ it('returns external for non-matching IP', async () => {
74
+ const zone = await detectZoneFromIp('167.99.123.45');
75
+ expect(zone).toBe('external');
76
+ });
77
+
78
+ it('matches first IP in subnet', async () => {
79
+ const zone = await detectZoneFromIp('192.168.0.1');
80
+ expect(zone).toBe('internal');
81
+ });
82
+
83
+ it('matches last IP in subnet', async () => {
84
+ const zone = await detectZoneFromIp('192.168.0.254');
85
+ expect(zone).toBe('internal');
86
+ });
87
+
88
+ it('does not match IP outside subnet', async () => {
89
+ const zone = await detectZoneFromIp('192.168.1.100');
90
+ expect(zone).toBe('external');
91
+ });
92
+ });
93
+
94
+ describe('isValidIp', () => {
95
+ it('validates correct IPv4 addresses', () => {
96
+ expect(isValidIp('192.168.1.1')).toBe(true);
97
+ expect(isValidIp('10.0.0.1')).toBe(true);
98
+ expect(isValidIp('255.255.255.255')).toBe(true);
99
+ expect(isValidIp('0.0.0.0')).toBe(true);
100
+ });
101
+
102
+ it('rejects invalid IPv4 addresses', () => {
103
+ expect(isValidIp('192.168.1.256')).toBe(false); // Number > 255
104
+ expect(isValidIp('192.168.1')).toBe(false); // Too few octets
105
+ expect(isValidIp('192.168.1.1.1')).toBe(false); // Too many octets
106
+ expect(isValidIp('not.an.ip.address')).toBe(false); // Non-numeric
107
+ expect(isValidIp('')).toBe(false); // Empty string
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Zone Detector
3
+ * Auto-detects network zone from IP address by matching against system subnets
4
+ */
5
+
6
+ import { eq } from 'drizzle-orm';
7
+ import { getDb } from '../db/client';
8
+ import { type NetworkZone, systemConfig } from '../db/schema';
9
+
10
+ /**
11
+ * Parse CIDR notation to get network address and prefix length
12
+ */
13
+ function parseCIDR(cidr: string): { network: string; prefixLength: number } {
14
+ const [ip, prefix] = cidr.split('/');
15
+ return {
16
+ network: ip,
17
+ prefixLength: Number.parseInt(prefix, 10),
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Convert IP address string to 32-bit integer
23
+ */
24
+ function ipToInt(ip: string): number {
25
+ const parts = ip.split('.').map((part) => Number.parseInt(part, 10));
26
+ return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
27
+ }
28
+
29
+ /**
30
+ * Check if an IP address is in a CIDR subnet
31
+ */
32
+ function ipInSubnet(ip: string, cidr: string): boolean {
33
+ try {
34
+ const { network, prefixLength } = parseCIDR(cidr);
35
+ const ipInt = ipToInt(ip);
36
+ const networkInt = ipToInt(network);
37
+
38
+ // Create subnet mask
39
+ const mask = ~((1 << (32 - prefixLength)) - 1);
40
+
41
+ // Check if IP is in subnet
42
+ return (ipInt & mask) === (networkInt & mask);
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Get system network configuration for a zone
50
+ */
51
+ async function getZoneSubnet(zone: NetworkZone): Promise<string | null> {
52
+ const db = getDb();
53
+
54
+ // Try to get subnet from system config
55
+ // Format: network.{zone}.subnet (e.g., network.dmz.subnet)
56
+ const key = `network.${zone}.subnet`;
57
+
58
+ const result = await db.select().from(systemConfig).where(eq(systemConfig.key, key)).limit(1);
59
+
60
+ if (result.length === 0) {
61
+ return null;
62
+ }
63
+
64
+ return result[0].value;
65
+ }
66
+
67
+ /**
68
+ * Detect network zone from IP address
69
+ * Matches IP against system network configuration
70
+ *
71
+ * @param ip - IP address to check
72
+ * @returns Detected network zone, or 'external' if no match
73
+ */
74
+ export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
75
+ // Check each zone's subnet
76
+ const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure'];
77
+
78
+ for (const zone of zones) {
79
+ const subnet = await getZoneSubnet(zone);
80
+ if (subnet && ipInSubnet(ip, subnet)) {
81
+ return zone;
82
+ }
83
+ }
84
+
85
+ // No match found - must be external
86
+ return 'external';
87
+ }
88
+
89
+ /**
90
+ * Validate IP address format
91
+ */
92
+ export function isValidIp(ip: string): boolean {
93
+ const parts = ip.split('.');
94
+ if (parts.length !== 4) {
95
+ return false;
96
+ }
97
+
98
+ return parts.every((part) => {
99
+ const num = Number.parseInt(part, 10);
100
+ return !Number.isNaN(num) && num >= 0 && num <= 255;
101
+ });
102
+ }