@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,445 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { eq } from 'drizzle-orm';
4
+ import { stringify as stringifyYaml } from 'yaml';
5
+ import type { DbClient } from '../db/client';
6
+ import { machines, moduleConfigs, systemConfig } from '../db/schema';
7
+ import { getTempKeyPath } from '../services/ssh-key-manager';
8
+
9
+ /**
10
+ * Inventory generation result
11
+ */
12
+ export interface InventoryResult {
13
+ success: boolean;
14
+ error?: string;
15
+ details?: unknown;
16
+ files?: string[];
17
+ }
18
+
19
+ /**
20
+ * Inventory host definition
21
+ */
22
+ export interface InventoryHost {
23
+ hostname: string;
24
+ ansibleHost: string;
25
+ ansibleUser: string;
26
+ groups: string[];
27
+ ansibleSshPrivateKeyFile?: string;
28
+ }
29
+
30
+ /**
31
+ * Generate hosts.ini file in INI format
32
+ *
33
+ * Presentation function (Rule 10.1) - formats inventory structure
34
+ *
35
+ * @param hosts - Array of inventory host definitions
36
+ * @returns INI format string
37
+ */
38
+ export function generateHostsIni(hosts: InventoryHost[]): string {
39
+ const lines: string[] = [];
40
+
41
+ // Group hosts by their groups
42
+ const groupMap = new Map<string, InventoryHost[]>();
43
+
44
+ for (const host of hosts) {
45
+ for (const group of host.groups) {
46
+ if (!groupMap.has(group)) {
47
+ groupMap.set(group, []);
48
+ }
49
+ groupMap.get(group)?.push(host);
50
+ }
51
+ }
52
+
53
+ // Generate INI sections for each group
54
+ for (const [group, groupHosts] of groupMap) {
55
+ lines.push(`[${group}]`);
56
+ for (const host of groupHosts) {
57
+ let hostLine = `${host.hostname} ansible_host=${host.ansibleHost} ansible_user=${host.ansibleUser}`;
58
+ if (host.ansibleSshPrivateKeyFile) {
59
+ hostLine += ` ansible_ssh_private_key_file=${host.ansibleSshPrivateKeyFile}`;
60
+ }
61
+ lines.push(hostLine);
62
+ }
63
+ lines.push(''); // Blank line between groups
64
+ }
65
+
66
+ // Add [all:vars] section with common variables
67
+ lines.push('[all:vars]');
68
+ lines.push('ansible_python_interpreter=/usr/bin/python3');
69
+ lines.push('ansible_ssh_common_args=-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null');
70
+ lines.push('');
71
+
72
+ return lines.join('\n');
73
+ }
74
+
75
+ /**
76
+ * Generate host_vars YAML file
77
+ *
78
+ * Presentation function - formats host variables as YAML
79
+ *
80
+ * @param vars - Host variable object (supports primitives, arrays, nested objects)
81
+ * @param hostname - Optional hostname for comment
82
+ * @returns YAML format string
83
+ */
84
+ export function generateHostVarsYaml(vars: Record<string, unknown>, hostname?: string): string {
85
+ const yamlContent = stringifyYaml(vars, {
86
+ indent: 2,
87
+ lineWidth: 0, // Don't wrap long lines
88
+ sortMapEntries: true, // Deterministic output for testing
89
+ });
90
+ const comment = hostname ? `# Host-specific variables for ${hostname}` : '';
91
+ return `---\n${comment ? `${comment}\n` : ''}${yamlContent}`;
92
+ }
93
+
94
+ /**
95
+ * Generate group_vars/all.yml with system configuration
96
+ *
97
+ * Presentation function - formats system config as YAML
98
+ *
99
+ * @param systemVars - System configuration object
100
+ * @returns YAML format string
101
+ */
102
+ export function generateGroupVarsYaml(systemVars: Record<string, unknown>): string {
103
+ const yamlContent = stringifyYaml(systemVars, {
104
+ indent: 2,
105
+ lineWidth: 0,
106
+ sortMapEntries: true,
107
+ });
108
+ return `---\n# System-wide variables available to all hosts\n${yamlContent}`;
109
+ }
110
+
111
+ /**
112
+ * Parse module config value into appropriate type
113
+ *
114
+ * Policy function - normalizes config values
115
+ *
116
+ * @param value - Raw config value string
117
+ * @returns Parsed value (string, number, boolean, or JSON-parsed object/array)
118
+ */
119
+ export function parseConfigValue(value: string): unknown {
120
+ // Try to parse as JSON (handles arrays, objects, numbers, booleans)
121
+ try {
122
+ return JSON.parse(value);
123
+ } catch {
124
+ // Not JSON, return as string
125
+ return value;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Sort object keys alphabetically (recursively for nested objects)
131
+ *
132
+ * Policy function - ensures deterministic output
133
+ *
134
+ * @param obj - Object to sort
135
+ * @returns New object with sorted keys
136
+ */
137
+ function sortObjectKeys(obj: Record<string, unknown>): Record<string, unknown> {
138
+ const sorted: Record<string, unknown> = {};
139
+ const keys = Object.keys(obj).sort();
140
+
141
+ for (const key of keys) {
142
+ const value = obj[key];
143
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
144
+ sorted[key] = sortObjectKeys(value as Record<string, unknown>);
145
+ } else {
146
+ sorted[key] = value;
147
+ }
148
+ }
149
+
150
+ return sorted;
151
+ }
152
+
153
+ /**
154
+ * Build host variables from module configuration
155
+ *
156
+ * Planning function (Rule 10.1) - transforms DB config into host vars structure
157
+ *
158
+ * @param moduleId - Module identifier
159
+ * @param db - Database connection
160
+ * @returns Host variables object with sorted keys
161
+ */
162
+ export function buildHostVars(moduleId: string, db: DbClient): Record<string, unknown> {
163
+ // Get all module config, ordered by key for deterministic output
164
+ const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
165
+
166
+ const vars: Record<string, unknown> = {};
167
+
168
+ for (const config of configs) {
169
+ // Skip inventory-specific keys (handled separately)
170
+ if (config.key.startsWith('inventory.')) {
171
+ continue;
172
+ }
173
+
174
+ // Parse value from correct column
175
+ let parsedValue: unknown;
176
+ if (config.valueJson) {
177
+ // Complex type stored in valueJson
178
+ try {
179
+ parsedValue = JSON.parse(config.valueJson);
180
+ } catch (error) {
181
+ throw new Error(
182
+ `Failed to parse config value for ${config.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
183
+ );
184
+ }
185
+ } else {
186
+ // Primitive type stored in value
187
+ parsedValue = parseConfigValue(config.value);
188
+ }
189
+
190
+ // Store with underscore naming
191
+ const key = config.key.replace(/\./g, '_');
192
+ vars[key] = parsedValue;
193
+ }
194
+
195
+ // Sort keys alphabetically for deterministic YAML output
196
+ return sortObjectKeys(vars);
197
+ }
198
+
199
+ /**
200
+ * Build system variables from system configuration
201
+ *
202
+ * Planning function - transforms DB system config into group vars
203
+ *
204
+ * @param db - Database connection
205
+ * @returns System variables object with sorted keys
206
+ */
207
+ export function buildSystemVars(db: DbClient): Record<string, unknown> {
208
+ const configs = db.select().from(systemConfig).all();
209
+
210
+ const vars: Record<string, unknown> = {};
211
+
212
+ for (const config of configs) {
213
+ // Convert dot notation to underscore for Ansible variables
214
+ const key = config.key.replace(/\./g, '_');
215
+ vars[key] = parseConfigValue(config.value);
216
+ }
217
+
218
+ // Sort keys alphabetically for deterministic YAML output
219
+ return sortObjectKeys(vars);
220
+ }
221
+
222
+ /**
223
+ * Extract inventory host definition from module config
224
+ *
225
+ * Planning function - extracts inventory metadata from config (auto-derived)
226
+ *
227
+ * Uses auto-derived inventory variables from context resolution:
228
+ * - inventory.hostname (from hostname)
229
+ * - inventory.ansible_host (from container_ip or vps_ip)
230
+ * - inventory.ansible_user (defaults to "root")
231
+ * - inventory.groups (defaults to module ID)
232
+ *
233
+ * @param moduleId - Module identifier
234
+ * @param db - Database connection
235
+ * @returns Inventory host definition or null if not configured
236
+ */
237
+ export function extractInventoryHost(moduleId: string, db: DbClient): InventoryHost | null {
238
+ // Get all module config
239
+ const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
240
+
241
+ const moduleConfig: Record<string, string> = {};
242
+ for (const config of configs) {
243
+ // Parse value from correct column (same logic as buildHostVars)
244
+ if (config.valueJson) {
245
+ // Complex type stored in valueJson
246
+ moduleConfig[config.key] = config.valueJson;
247
+ } else {
248
+ // Primitive type stored in value
249
+ moduleConfig[config.key] = config.value;
250
+ }
251
+ }
252
+
253
+ // Auto-derive inventory variables (same logic as context.ts)
254
+ const derived: Record<string, string> = {};
255
+
256
+ // Auto-derive hostname from hostname variable
257
+ if (moduleConfig.hostname) {
258
+ derived['inventory.hostname'] = moduleConfig.hostname;
259
+ }
260
+
261
+ // Auto-derive ansible_host from infrastructure variables
262
+ // Priority: container_ip > ip.primary > vps_ip (for backward compatibility)
263
+ if (moduleConfig.container_ip) {
264
+ const slashIndex = moduleConfig.container_ip.indexOf('/');
265
+ derived['inventory.ansible_host'] =
266
+ slashIndex === -1
267
+ ? moduleConfig.container_ip
268
+ : moduleConfig.container_ip.slice(0, slashIndex);
269
+ } else if (moduleConfig['ip.primary']) {
270
+ derived['inventory.ansible_host'] = moduleConfig['ip.primary'];
271
+ } else if (moduleConfig.vps_ip) {
272
+ derived['inventory.ansible_host'] = moduleConfig.vps_ip;
273
+ }
274
+
275
+ // Auto-derive ansible_user (default: root)
276
+ derived['inventory.ansible_user'] = moduleConfig['inventory.ansible_user'] || 'root';
277
+
278
+ // Auto-derive groups from module ID
279
+ derived['inventory.groups'] = moduleConfig['inventory.groups'] || moduleId;
280
+
281
+ // Validate required fields
282
+ const hostname = derived['inventory.hostname'];
283
+ const ansibleHost = derived['inventory.ansible_host'];
284
+ const ansibleUser = derived['inventory.ansible_user'];
285
+
286
+ if (!hostname || !ansibleHost || !ansibleUser) {
287
+ return null;
288
+ }
289
+
290
+ // Parse groups (could be array already or comma-separated string)
291
+ let groups: string[] = [];
292
+ const groupsValue = derived['inventory.groups'];
293
+ if (groupsValue) {
294
+ try {
295
+ // Try parsing as JSON
296
+ groups = JSON.parse(groupsValue);
297
+ } catch {
298
+ // Not JSON, treat as single group
299
+ groups = [groupsValue];
300
+ }
301
+ }
302
+
303
+ return {
304
+ hostname,
305
+ ansibleHost,
306
+ ansibleUser,
307
+ groups,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Infrastructure selection info (passed from generator)
313
+ */
314
+ export interface InfrastructureInfo {
315
+ type: 'machine' | 'container_service';
316
+ machineId?: string;
317
+ serviceId?: string;
318
+ }
319
+
320
+ /**
321
+ * Generate Ansible inventory structure for a module
322
+ *
323
+ * Execution function (Rule 10.1) - performs file I/O
324
+ *
325
+ * Enhanced to support both container services and machine pool
326
+ * - Container services: use placeholder IP (will be updated after Terraform)
327
+ * - Machines: use machine IP, SSH user, and SSH key path from database
328
+ *
329
+ * @param moduleId - Module identifier
330
+ * @param outputPath - Base output path (e.g., /tmp/celilo/modules/homebridge/generated)
331
+ * @param db - Database connection
332
+ * @param infrastructure - Optional infrastructure selection info
333
+ * @returns Generation result with list of created files
334
+ */
335
+ export async function generateInventory(
336
+ moduleId: string,
337
+ outputPath: string,
338
+ db: DbClient,
339
+ infrastructure?: InfrastructureInfo,
340
+ ): Promise<InventoryResult> {
341
+ try {
342
+ const inventoryPath = join(outputPath, 'ansible/inventory');
343
+ await mkdir(inventoryPath, { recursive: true });
344
+
345
+ const createdFiles: string[] = [];
346
+
347
+ let host: InventoryHost | null = null;
348
+
349
+ if (infrastructure?.type === 'machine' && infrastructure.machineId) {
350
+ // Machine infrastructure: load machine from database
351
+ const machine = db
352
+ .select()
353
+ .from(machines)
354
+ .where(eq(machines.id, infrastructure.machineId))
355
+ .get();
356
+
357
+ if (!machine) {
358
+ return {
359
+ success: false,
360
+ error: `Machine not found: ${infrastructure.machineId}`,
361
+ };
362
+ }
363
+
364
+ // Get hostname from module config (if available) or use machine hostname
365
+ const configs = db
366
+ .select()
367
+ .from(moduleConfigs)
368
+ .where(eq(moduleConfigs.moduleId, moduleId))
369
+ .all();
370
+ const moduleHostname = configs.find(
371
+ (c: typeof moduleConfigs.$inferSelect) => c.key === 'hostname',
372
+ )?.value;
373
+
374
+ // Build host definition from machine
375
+ host = {
376
+ hostname: moduleHostname || machine.hostname,
377
+ ansibleHost: machine.ipAddress,
378
+ ansibleUser: machine.sshUser,
379
+ groups: [moduleId], // Use module ID as group
380
+ ansibleSshPrivateKeyFile: getTempKeyPath(machine.id),
381
+ };
382
+ } else {
383
+ // Container service or no infrastructure: use module config
384
+ host = extractInventoryHost(moduleId, db);
385
+ if (!host) {
386
+ // No inventory configured - this is not an error, just skip
387
+ return {
388
+ success: true,
389
+ files: [],
390
+ };
391
+ }
392
+ }
393
+
394
+ // Generate hosts.ini
395
+ const hostsIni = generateHostsIni([host]);
396
+ await writeFile(join(inventoryPath, 'hosts.ini'), hostsIni, 'utf-8');
397
+ createdFiles.push('ansible/inventory/hosts.ini');
398
+
399
+ // Generate host_vars/<hostname>.yml
400
+ const hostVars = buildHostVars(moduleId, db);
401
+
402
+ // Inject machine architecture if deploying to a machine
403
+ if (infrastructure?.type === 'machine' && infrastructure.machineId) {
404
+ const machine = db
405
+ .select()
406
+ .from(machines)
407
+ .where(eq(machines.id, infrastructure.machineId))
408
+ .get();
409
+ if (machine?.hardware?.arch) {
410
+ hostVars.target_arch = machine.hardware.arch;
411
+ }
412
+ }
413
+
414
+ if (Object.keys(hostVars).length > 0) {
415
+ const hostVarsPath = join(inventoryPath, 'host_vars');
416
+ await mkdir(hostVarsPath, { recursive: true });
417
+
418
+ const hostVarsYaml = generateHostVarsYaml(hostVars, host.hostname);
419
+ await writeFile(join(hostVarsPath, `${host.hostname}.yml`), hostVarsYaml, 'utf-8');
420
+ createdFiles.push(`ansible/inventory/host_vars/${host.hostname}.yml`);
421
+ }
422
+
423
+ // Generate group_vars/all.yml with system config
424
+ const systemVars = buildSystemVars(db);
425
+ if (Object.keys(systemVars).length > 0) {
426
+ const groupVarsPath = join(inventoryPath, 'group_vars');
427
+ await mkdir(groupVarsPath, { recursive: true });
428
+
429
+ const groupVarsYaml = generateGroupVarsYaml(systemVars);
430
+ await writeFile(join(groupVarsPath, 'all.yml'), groupVarsYaml, 'utf-8');
431
+ createdFiles.push('ansible/inventory/group_vars/all.yml');
432
+ }
433
+
434
+ return {
435
+ success: true,
436
+ files: createdFiles,
437
+ };
438
+ } catch (error) {
439
+ return {
440
+ success: false,
441
+ error: 'Failed to generate inventory',
442
+ details: error,
443
+ };
444
+ }
445
+ }
@@ -0,0 +1,222 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { eq } from 'drizzle-orm';
5
+ import { secrets } from '../db/schema';
6
+ import { decryptSecret } from '../secrets/encryption';
7
+ import { getOrCreateMasterKey } from '../secrets/master-key';
8
+ import { getVaultPassword } from '../secrets/vault';
9
+
10
+ /**
11
+ * Ansible secrets generation result
12
+ */
13
+ export interface AnsibleSecretsResult {
14
+ success: boolean;
15
+ secretsYaml?: string;
16
+ encryptedPath?: string;
17
+ error?: string;
18
+ details?: unknown;
19
+ }
20
+
21
+ /**
22
+ * Generate Ansible secrets YAML content
23
+ *
24
+ * Policy function (Rule 10.1) - pure formatting, no I/O
25
+ *
26
+ * @param secrets - Map of secret names to decrypted values
27
+ * @returns YAML content as string
28
+ */
29
+ export function formatSecretsYaml(secrets: Record<string, string>): string {
30
+ const lines = ['---', '# Ansible Vault encrypted secrets', ''];
31
+
32
+ // Sort keys for deterministic output
33
+ const sortedKeys = Object.keys(secrets).sort();
34
+
35
+ for (const key of sortedKeys) {
36
+ const value = secrets[key];
37
+ // Escape YAML special characters
38
+ const escaped = value.includes("'") ? `"${value.replace(/"/g, '\\"')}"` : `'${value}'`;
39
+ lines.push(`${key}: ${escaped}`);
40
+ }
41
+
42
+ return `${lines.join('\n')}\n`;
43
+ }
44
+
45
+ /**
46
+ * Decrypt all secrets for a module
47
+ *
48
+ * Execution function - performs database queries and decryption
49
+ *
50
+ * @param moduleId - Module ID
51
+ * @param db - Database client
52
+ * @returns Map of secret names to decrypted values
53
+ */
54
+ export async function decryptModuleSecrets(
55
+ moduleId: string,
56
+ db: ReturnType<typeof import('../db/client').getDb>,
57
+ ): Promise<Record<string, string>> {
58
+ // Get master key for decryption
59
+ const masterKey = await getOrCreateMasterKey();
60
+
61
+ // Fetch encrypted secrets from database
62
+ const secretRows = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all();
63
+
64
+ const decryptedSecrets: Record<string, string> = {};
65
+
66
+ for (const row of secretRows) {
67
+ try {
68
+ const decrypted = decryptSecret(
69
+ {
70
+ encryptedValue: row.encryptedValue,
71
+ iv: row.iv,
72
+ authTag: row.authTag,
73
+ },
74
+ masterKey,
75
+ );
76
+ decryptedSecrets[row.name] = decrypted;
77
+ } catch (error) {
78
+ throw new Error(`Failed to decrypt secret '${row.name}' for module '${moduleId}': ${error}`);
79
+ }
80
+ }
81
+
82
+ return decryptedSecrets;
83
+ }
84
+
85
+ /**
86
+ * Encrypt YAML content using ansible-vault
87
+ *
88
+ * Execution function - spawns external process
89
+ *
90
+ * Uses temporary files to avoid stdin/stdout complexity
91
+ *
92
+ * @param yamlContent - Plaintext YAML content
93
+ * @param vaultPassword - Vault password
94
+ * @returns Encrypted content or error
95
+ */
96
+ export async function encryptWithAnsibleVault(
97
+ yamlContent: string,
98
+ vaultPassword: string,
99
+ ): Promise<{ success: true; encrypted: string } | { success: false; error: string }> {
100
+ // Check if ansible-vault is available
101
+ const checkResult = Bun.spawnSync(['which', 'ansible-vault'], {
102
+ stdout: 'pipe',
103
+ stderr: 'pipe',
104
+ });
105
+
106
+ if (checkResult.exitCode !== 0) {
107
+ return {
108
+ success: false,
109
+ error:
110
+ 'ansible-vault command not found. Please install Ansible: https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html',
111
+ };
112
+ }
113
+
114
+ // Create temporary directory for vault operations
115
+ const tempDir = await mkdtemp(join(tmpdir(), 'celilo-vault-'));
116
+
117
+ try {
118
+ // Write plaintext YAML to temp file
119
+ const plaintextPath = join(tempDir, 'plaintext.yml');
120
+ await writeFile(plaintextPath, yamlContent, 'utf-8');
121
+
122
+ // Write vault password to temp file
123
+ const passwordPath = join(tempDir, 'vault-pass');
124
+ await writeFile(passwordPath, vaultPassword, 'utf-8');
125
+
126
+ // Encrypt using ansible-vault
127
+ const encryptResult = Bun.spawnSync(
128
+ ['ansible-vault', 'encrypt', '--vault-password-file', passwordPath, plaintextPath],
129
+ {
130
+ stdout: 'pipe',
131
+ stderr: 'pipe',
132
+ cwd: tempDir,
133
+ },
134
+ );
135
+
136
+ if (encryptResult.exitCode !== 0) {
137
+ const stderr = encryptResult.stderr
138
+ ? new TextDecoder().decode(encryptResult.stderr)
139
+ : 'Unknown error';
140
+ return {
141
+ success: false,
142
+ error: `ansible-vault encryption failed: ${stderr}`,
143
+ };
144
+ }
145
+
146
+ // Read encrypted content
147
+ const encrypted = await readFile(plaintextPath, 'utf-8');
148
+
149
+ return {
150
+ success: true,
151
+ encrypted,
152
+ };
153
+ } finally {
154
+ // Clean up temp directory
155
+ await rm(tempDir, { recursive: true, force: true });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Generate and encrypt Ansible secrets file
161
+ *
162
+ * Orchestration function - coordinates all steps
163
+ *
164
+ * @param moduleId - Module ID
165
+ * @param outputPath - Path to write encrypted secrets.yml
166
+ * @param db - Database client
167
+ * @returns Generation result
168
+ */
169
+ export async function generateAnsibleSecrets(
170
+ moduleId: string,
171
+ outputPath: string,
172
+ db: ReturnType<typeof import('../db/client').getDb>,
173
+ ): Promise<AnsibleSecretsResult> {
174
+ try {
175
+ // Step 1: Decrypt secrets from database
176
+ const decryptedSecrets = await decryptModuleSecrets(moduleId, db);
177
+
178
+ if (Object.keys(decryptedSecrets).length === 0) {
179
+ // No secrets to encrypt - skip file generation
180
+ return {
181
+ success: true,
182
+ secretsYaml: '',
183
+ };
184
+ }
185
+
186
+ // Step 2: Format as YAML
187
+ const yamlContent = formatSecretsYaml(decryptedSecrets);
188
+
189
+ // Step 3: Get vault password
190
+ const vaultPassword = await getVaultPassword();
191
+
192
+ // Step 4: Encrypt with ansible-vault
193
+ const encryptResult = await encryptWithAnsibleVault(yamlContent, vaultPassword);
194
+
195
+ if (!encryptResult.success) {
196
+ return {
197
+ success: false,
198
+ error: encryptResult.error,
199
+ };
200
+ }
201
+
202
+ // Step 5: Write encrypted file
203
+ // Ensure directory exists
204
+ const outputDir = dirname(outputPath);
205
+ await mkdir(outputDir, { recursive: true });
206
+ await writeFile(outputPath, encryptResult.encrypted, 'utf-8');
207
+
208
+ return {
209
+ success: true,
210
+ secretsYaml: yamlContent,
211
+ encryptedPath: outputPath,
212
+ };
213
+ } catch (error) {
214
+ const errorMessage = error instanceof Error ? error.message : String(error);
215
+ const errorStack = error instanceof Error ? error.stack : undefined;
216
+ return {
217
+ success: false,
218
+ error: `Failed to generate Ansible secrets: ${errorMessage}`,
219
+ details: errorStack,
220
+ };
221
+ }
222
+ }