@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,624 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { getWellKnownCapability, isWellKnown } from '../capabilities/well-known';
3
+ import { getDb } from '../db/client';
4
+ import type { DbClient } from '../db/client';
5
+ import {
6
+ capabilities,
7
+ moduleConfigs,
8
+ modules,
9
+ secrets,
10
+ systemConfig,
11
+ systemSecrets,
12
+ } from '../db/schema';
13
+ import { allocateResources, getAllocation } from '../ipam/allocator';
14
+ import type { ModuleManifest } from '../manifest/schema';
15
+ import { decryptSecret } from '../secrets/encryption';
16
+ import { getOrCreateMasterKey } from '../secrets/master-key';
17
+ import { applyDeclarativeDerivations } from './declarative-derivation';
18
+ import type { ResolutionContext } from './types';
19
+
20
+ /**
21
+ * Strip CIDR notation from IP address
22
+ * Policy function - pure string manipulation
23
+ *
24
+ * @param ipWithCidr - IP address with optional CIDR (e.g., "10.0.10.10/24")
25
+ * @returns IP address without CIDR (e.g., "10.0.10.10")
26
+ */
27
+ function stripCidr(ipWithCidr: string): string {
28
+ const slashIndex = ipWithCidr.indexOf('/');
29
+ if (slashIndex === -1) {
30
+ return ipWithCidr;
31
+ }
32
+ return ipWithCidr.slice(0, slashIndex);
33
+ }
34
+
35
+ /**
36
+ * Auto-assign hostname and zone from well-known capabilities
37
+ * Policy function - derives values from capability registry
38
+ *
39
+ * Zero-config systems
40
+ * If module provides a well-known capability, auto-assign:
41
+ * - hostname from canonical_hostname
42
+ * - zone from required_zone
43
+ *
44
+ * Only assigns if not already set (explicit config wins)
45
+ *
46
+ * @param manifest - Module manifest
47
+ * @param selfConfig - Current module configuration
48
+ * @param db - Database client for hostname conflict detection
49
+ * @returns Object with assigned hostname and zone (if any)
50
+ * @throws Error if hostname conflict or zone enforcement fails
51
+ */
52
+ async function autoAssignFromWellKnown(
53
+ manifest: ModuleManifest,
54
+ selfConfig: Record<string, string>,
55
+ _moduleId: string,
56
+ _db: DbClient,
57
+ ): Promise<{ hostname?: string; zone?: string }> {
58
+ const providedCapabilities = manifest.provides?.capabilities ?? [];
59
+ const result: { hostname?: string; zone?: string } = {};
60
+
61
+ // Find first well-known capability (priority order)
62
+ // Note: Capability uniqueness and zone enforcement are validated at import time
63
+ for (const capability of providedCapabilities) {
64
+ if (!isWellKnown(capability.name)) {
65
+ continue;
66
+ }
67
+
68
+ const wellKnown = getWellKnownCapability(capability.name);
69
+
70
+ // Determine current zone (from config or manifest)
71
+ const currentZone = selfConfig.zone || manifest.requires?.machine?.zone;
72
+
73
+ // Auto-assign hostname if not already set
74
+ if (!selfConfig.hostname) {
75
+ result.hostname = wellKnown.canonical_hostname;
76
+ }
77
+
78
+ // Auto-assign zone if not already set (in config or manifest)
79
+ if (!currentZone) {
80
+ result.zone = wellKnown.required_zone;
81
+ }
82
+
83
+ // Only process first well-known capability (deterministic)
84
+ break;
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Determine if module needs IPAM auto-allocation
92
+ * Policy function - checks manifest and config
93
+ *
94
+ * A module needs IPAM if:
95
+ * 1. It declares vmid and container_ip variables (container-based), AND
96
+ * 2. These values are not already set in module config
97
+ *
98
+ * @param manifest - Module manifest
99
+ * @param selfConfig - Current module configuration
100
+ * @returns true if IPAM allocation needed
101
+ */
102
+ function needsIpamAllocation(
103
+ manifest: ModuleManifest,
104
+ selfConfig: Record<string, string>,
105
+ ): boolean {
106
+ const variables = manifest.variables?.owns ?? [];
107
+
108
+ const hasVmid = variables.some((v) => v.name === 'vmid');
109
+ const hasContainerIp = variables.some((v) => v.name === 'container_ip');
110
+
111
+ // Module must declare both vmid and container_ip to be container-based
112
+ if (!hasVmid || !hasContainerIp) {
113
+ return false;
114
+ }
115
+
116
+ // Check if already allocated (both must be present)
117
+ const vmidSet = selfConfig.vmid !== undefined && selfConfig.vmid !== '';
118
+ const ipSet = selfConfig.container_ip !== undefined && selfConfig.container_ip !== '';
119
+
120
+ // Need allocation if either is missing
121
+ return !vmidSet || !ipSet;
122
+ }
123
+
124
+ /**
125
+ * Determine network zone for module
126
+ * Policy function - extracts zone from manifest or config
127
+ *
128
+ * Zone determination priority:
129
+ * 1. Explicit zone in module config (user override)
130
+ * 2. VM resources zone in manifest
131
+ * 3. Default to 'dmz'
132
+ *
133
+ * @param manifest - Module manifest
134
+ * @param selfConfig - Current module configuration
135
+ * @returns Network zone ('dmz', 'app', 'secure', or 'internal')
136
+ */
137
+ function determineModuleZone(
138
+ manifest: ModuleManifest,
139
+ selfConfig: Record<string, string>,
140
+ ): 'dmz' | 'app' | 'secure' | 'internal' {
141
+ // Priority 1: Explicit zone in config
142
+ if (selfConfig.zone) {
143
+ const zone = selfConfig.zone;
144
+ if (zone === 'dmz' || zone === 'app' || zone === 'secure' || zone === 'internal') {
145
+ return zone;
146
+ }
147
+ }
148
+
149
+ // Priority 2: VM resources zone in manifest
150
+ const vmZone = manifest.requires?.machine?.zone;
151
+ if (vmZone === 'dmz' || vmZone === 'app' || vmZone === 'secure' || vmZone === 'internal') {
152
+ return vmZone;
153
+ }
154
+
155
+ // Default: dmz (public-facing services)
156
+ return 'dmz';
157
+ }
158
+
159
+ /**
160
+ * Auto-derive inventory variables from module configuration
161
+ * Policy function - derives values from existing config
162
+ *
163
+ * These variables are automatically available in Ansible templates:
164
+ * - inventory.hostname: Derived from hostname variable
165
+ * - inventory.ansible_host: Derived from container_ip (strips CIDR) or vps_ip
166
+ * - inventory.ansible_user: Defaults to "root"
167
+ * - inventory.groups: Derived from module ID
168
+ *
169
+ * @param moduleId - Module ID
170
+ * @param selfConfig - Module configuration
171
+ * @returns Additional derived variables to merge into selfConfig
172
+ */
173
+ function autoDeriveInventoryVariables(
174
+ moduleId: string,
175
+ selfConfig: Record<string, string>,
176
+ ): Record<string, string> {
177
+ const derived: Record<string, string> = {};
178
+
179
+ // Auto-derive hostname from hostname variable
180
+ if (selfConfig.hostname) {
181
+ derived['inventory.hostname'] = selfConfig.hostname;
182
+ }
183
+
184
+ // Auto-derive ansible_host from container_ip (strips CIDR) or vps_ip
185
+ if (selfConfig.container_ip) {
186
+ derived['inventory.ansible_host'] = stripCidr(selfConfig.container_ip);
187
+ } else if (selfConfig.vps_ip) {
188
+ // VPS-based modules use vps_ip directly (no CIDR to strip)
189
+ derived['inventory.ansible_host'] = selfConfig.vps_ip;
190
+ }
191
+
192
+ // Auto-derive ansible_user (default: root)
193
+ // Can be overridden by module config
194
+ if (!selfConfig['inventory.ansible_user']) {
195
+ derived['inventory.ansible_user'] = 'root';
196
+ }
197
+
198
+ // Auto-derive groups from module ID
199
+ // Format: module ID becomes the primary group
200
+ derived['inventory.groups'] = moduleId;
201
+
202
+ return derived;
203
+ }
204
+
205
+ /**
206
+ * Build resolution context for a module
207
+ *
208
+ * Execution function (Rule 10.1) - performs database queries
209
+ *
210
+ * @param moduleId - Module to build context for
211
+ * @param db - Database client (optional, for testing)
212
+ * @returns Resolution context with all data sources
213
+ */
214
+ export async function buildResolutionContext(
215
+ moduleId: string,
216
+ db = getDb(),
217
+ ): Promise<ResolutionContext> {
218
+ // Fetch module manifest for VM resources
219
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
220
+
221
+ // Fetch module configuration (self)
222
+ const configRows = db
223
+ .select()
224
+ .from(moduleConfigs)
225
+ .where(eq(moduleConfigs.moduleId, moduleId))
226
+ .all();
227
+
228
+ const selfConfig: Record<string, string> = {};
229
+ for (const row of configRows) {
230
+ // Handle complex types (stored in valueJson)
231
+ if (row.valueJson) {
232
+ selfConfig[row.key] = row.valueJson; // Store JSON string
233
+ } else {
234
+ selfConfig[row.key] = row.value;
235
+ }
236
+ }
237
+
238
+ // Well-known capability auto-assignment
239
+ // Auto-assign hostname and zone if module provides well-known capability
240
+ if (module?.manifestData) {
241
+ const manifest = module.manifestData as ModuleManifest;
242
+ const assigned = await autoAssignFromWellKnown(manifest, selfConfig, moduleId, db);
243
+
244
+ // Store assigned values in module config
245
+ if (assigned.hostname) {
246
+ await db
247
+ .insert(moduleConfigs)
248
+ .values({ moduleId, key: 'hostname', value: assigned.hostname })
249
+ .onConflictDoUpdate({
250
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
251
+ set: { value: assigned.hostname },
252
+ });
253
+ selfConfig.hostname = assigned.hostname;
254
+ }
255
+
256
+ if (assigned.zone) {
257
+ await db
258
+ .insert(moduleConfigs)
259
+ .values({ moduleId, key: 'zone', value: assigned.zone })
260
+ .onConflictDoUpdate({
261
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
262
+ set: { value: assigned.zone },
263
+ });
264
+ selfConfig.zone = assigned.zone;
265
+ }
266
+ }
267
+
268
+ // Variable defaults
269
+ // Auto-apply default values from variable declarations if not already configured
270
+ if (module?.manifestData) {
271
+ const manifest = module.manifestData as ModuleManifest;
272
+ const variables = manifest.variables?.owns ?? [];
273
+
274
+ for (const variable of variables) {
275
+ // Only apply if variable has a default and config doesn't already have it
276
+ if (variable.default !== undefined && !selfConfig[variable.name]) {
277
+ const valueStr = String(variable.default);
278
+
279
+ await db
280
+ .insert(moduleConfigs)
281
+ .values({ moduleId, key: variable.name, value: valueStr })
282
+ .onConflictDoUpdate({
283
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
284
+ set: { value: valueStr },
285
+ });
286
+
287
+ selfConfig[variable.name] = valueStr;
288
+ }
289
+ }
290
+ }
291
+
292
+ // VM resource defaults
293
+ // Auto-apply VM resource defaults from manifest if not already configured
294
+ if (module?.manifestData) {
295
+ const manifest = module.manifestData as ModuleManifest;
296
+ const machineResources = manifest.requires?.machine;
297
+
298
+ if (machineResources) {
299
+ // Map manifest fields to module variable names and apply defaults
300
+ const resourceMappings: Array<{
301
+ manifestKey: keyof typeof machineResources;
302
+ configKey: string;
303
+ }> = [
304
+ { manifestKey: 'cpu', configKey: 'cores' }, // manifest.requires.machine.cpu → cores variable
305
+ { manifestKey: 'memory', configKey: 'memory' },
306
+ { manifestKey: 'disk', configKey: 'disk' },
307
+ { manifestKey: 'storage', configKey: 'storage' },
308
+ ];
309
+
310
+ for (const { manifestKey, configKey } of resourceMappings) {
311
+ const value = machineResources[manifestKey];
312
+
313
+ // Only apply if manifest specifies a value and config doesn't already have it
314
+ if (value !== undefined && !selfConfig[configKey]) {
315
+ const valueStr = String(value);
316
+
317
+ await db
318
+ .insert(moduleConfigs)
319
+ .values({ moduleId, key: configKey, value: valueStr })
320
+ .onConflictDoUpdate({
321
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
322
+ set: { value: valueStr },
323
+ });
324
+
325
+ selfConfig[configKey] = valueStr;
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ // IPAM auto-allocation
332
+ // If module declares vmid/container_ip but they're not configured, allocate automatically
333
+ if (module?.manifestData) {
334
+ const manifest = module.manifestData as ModuleManifest;
335
+
336
+ if (needsIpamAllocation(manifest, selfConfig)) {
337
+ const zone = determineModuleZone(manifest, selfConfig);
338
+
339
+ // Use transaction to ensure atomicity
340
+ await db.transaction(async (tx) => {
341
+ // Check if allocation already exists in ipAllocations table
342
+ const existing = await getAllocation(moduleId, tx);
343
+
344
+ let vmid: number;
345
+ let containerIp: string;
346
+
347
+ if (existing) {
348
+ // Use existing allocation
349
+ vmid = existing.vmid;
350
+ containerIp = existing.containerIp;
351
+ } else {
352
+ // Allocate new resources (persists to ipAllocations)
353
+ const allocation = await allocateResources(moduleId, zone, tx);
354
+ vmid = allocation.vmid;
355
+ containerIp = allocation.containerIp;
356
+ }
357
+
358
+ // Ensure values are in module config (upsert to handle existing keys)
359
+ await tx
360
+ .insert(moduleConfigs)
361
+ .values({ moduleId, key: 'vmid', value: String(vmid) })
362
+ .onConflictDoUpdate({
363
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
364
+ set: { value: String(vmid) },
365
+ });
366
+
367
+ await tx
368
+ .insert(moduleConfigs)
369
+ .values({ moduleId, key: 'container_ip', value: containerIp })
370
+ .onConflictDoUpdate({
371
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
372
+ set: { value: containerIp },
373
+ });
374
+
375
+ // Update selfConfig with allocated values
376
+ selfConfig.vmid = String(vmid);
377
+ selfConfig.container_ip = containerIp;
378
+ });
379
+ }
380
+ }
381
+
382
+ // Add machine requirements from manifest to selfConfig so templates can
383
+ // reference them via $self:requires.machine.<field>
384
+ if (module?.manifestData) {
385
+ const manifest = module.manifestData as Record<string, unknown>;
386
+ const requires = manifest.requires as Record<string, unknown> | undefined;
387
+ if (requires?.machine) {
388
+ const machineRequires = requires.machine as Record<string, unknown>;
389
+ for (const [key, value] of Object.entries(machineRequires)) {
390
+ selfConfig[`requires.machine.${key}`] = String(value);
391
+ }
392
+ }
393
+ }
394
+
395
+ // Fetch secrets (encrypted values should be decrypted by caller)
396
+ const secretRows = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all();
397
+
398
+ const secretsMap: Record<string, string> = {};
399
+ for (const row of secretRows) {
400
+ secretsMap[row.name] = row.encryptedValue;
401
+ }
402
+
403
+ // Fetch all capabilities (for capability variables)
404
+ const capabilityRows = db.select().from(capabilities).all();
405
+
406
+ const capabilitiesMap: Record<string, Record<string, unknown>> = {};
407
+ for (const row of capabilityRows) {
408
+ // Parse JSON data if it's a string
409
+ let data: unknown;
410
+ if (typeof row.data === 'string') {
411
+ try {
412
+ data = JSON.parse(row.data);
413
+ } catch (error) {
414
+ throw new Error(
415
+ `Failed to parse capability data for ${row.capabilityName}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
416
+ );
417
+ }
418
+ } else {
419
+ data = row.data;
420
+ }
421
+ capabilitiesMap[row.capabilityName] = data as Record<string, unknown>;
422
+ }
423
+
424
+ // Fetch system configuration (for $system: variables)
425
+ const systemConfigRows = db.select().from(systemConfig).all();
426
+
427
+ const systemConfigMap: Record<string, string> = {};
428
+ for (const row of systemConfigRows) {
429
+ systemConfigMap[row.key] = row.value;
430
+ }
431
+
432
+ // Fetch system secrets (for $system_secret: variables)
433
+ const systemSecretsMap: Record<string, string> = {};
434
+ try {
435
+ const systemSecretRows = db.select().from(systemSecrets).all();
436
+
437
+ if (systemSecretRows.length > 0) {
438
+ // Get master key for decryption
439
+ const masterKey = await getOrCreateMasterKey();
440
+
441
+ for (const row of systemSecretRows) {
442
+ try {
443
+ const decrypted = decryptSecret(
444
+ {
445
+ encryptedValue: row.encryptedValue,
446
+ iv: row.iv,
447
+ authTag: row.authTag,
448
+ },
449
+ masterKey,
450
+ );
451
+ systemSecretsMap[row.key] = decrypted;
452
+ } catch (err) {
453
+ // Log error but continue with other secrets
454
+ console.error(`Failed to decrypt system secret ${row.key}:`, err);
455
+ }
456
+ }
457
+ }
458
+ } catch (_err) {
459
+ // Table might not exist in test/old databases - that's okay
460
+ // System secrets are optional
461
+ }
462
+
463
+ // Zone-based networking
464
+ // Auto-derive network config from zone (gateway, vlan, subnet, bridge)
465
+ if (module?.manifestData) {
466
+ const manifest = module.manifestData as ModuleManifest;
467
+ const zone = selfConfig.zone || manifest.requires?.machine?.zone;
468
+
469
+ // If zone from manifest but not in selfConfig, store it as first-class config
470
+ if (zone && !selfConfig.zone) {
471
+ await db
472
+ .insert(moduleConfigs)
473
+ .values({ moduleId, key: 'zone', value: zone })
474
+ .onConflictDoUpdate({
475
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
476
+ set: { value: zone },
477
+ });
478
+ selfConfig.zone = zone;
479
+ }
480
+
481
+ // Only apply for container-based zones (not external/VPS)
482
+ if (zone && zone !== 'external') {
483
+ const networkFields = ['gateway', 'vlan', 'subnet', 'bridge'];
484
+
485
+ for (const field of networkFields) {
486
+ // Only apply if not already configured by user
487
+ if (!selfConfig[field]) {
488
+ const systemConfigKey = `network.${zone}.${field}`;
489
+ const value = systemConfigMap[systemConfigKey];
490
+
491
+ if (value) {
492
+ await db
493
+ .insert(moduleConfigs)
494
+ .values({ moduleId, key: field, value })
495
+ .onConflictDoUpdate({
496
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
497
+ set: { value },
498
+ });
499
+
500
+ selfConfig[field] = value;
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
506
+
507
+ // Declarative variable derivation
508
+ // Apply template-based derivations from manifest
509
+ if (module?.manifestData) {
510
+ const manifest = module.manifestData as ModuleManifest;
511
+ const context: ResolutionContext = {
512
+ moduleId,
513
+ selfConfig,
514
+ systemConfig: systemConfigMap,
515
+ systemSecrets: systemSecretsMap,
516
+ secrets: secretsMap,
517
+ capabilities: capabilitiesMap,
518
+ };
519
+
520
+ // Snapshot values before derivation so we can detect changes
521
+ const preDeriveValues: Record<string, string> = { ...selfConfig };
522
+
523
+ // Apply declarative derivations from manifest
524
+ applyDeclarativeDerivations(manifest, context);
525
+
526
+ // Persist derived values to database
527
+ // For capability/infrastructure-sourced variables, always update since
528
+ // the upstream value may have changed.
529
+ const rederivableSources = new Set(['capability', 'infrastructure']);
530
+ const declaredVars = manifest.variables?.owns ?? [];
531
+
532
+ for (const [key, value] of Object.entries(context.selfConfig)) {
533
+ const decl = declaredVars.find((v) => v.name === key);
534
+ const isNew = preDeriveValues[key] === undefined;
535
+ const isChanged =
536
+ decl?.source && rederivableSources.has(decl.source) && preDeriveValues[key] !== value;
537
+
538
+ // Don't persist values that still contain unresolved template
539
+ // variables ($self:, $system:, $capability:). This happens when
540
+ // capability data contains template references that can't be
541
+ // fully resolved in the consuming module's context — e.g.,
542
+ // $capability:dns_registrar.primary_domain resolves to
543
+ // namecheap's capability data "$self:primary_domain", but
544
+ // $self: in that context refers to namecheap, not the consumer.
545
+ // Persisting the raw template would poison the config with an
546
+ // unresolvable string. Keep the user-set value (if any) instead.
547
+ if (
548
+ typeof value === 'string' &&
549
+ (value.includes('$self:') ||
550
+ value.includes('$system:') ||
551
+ value.includes('$capability:') ||
552
+ value.includes('$secret:'))
553
+ ) {
554
+ continue;
555
+ }
556
+
557
+ if (isNew || isChanged) {
558
+ await db
559
+ .insert(moduleConfigs)
560
+ .values({ moduleId, key, value })
561
+ .onConflictDoUpdate({
562
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
563
+ set: { value },
564
+ });
565
+
566
+ selfConfig[key] = value;
567
+ }
568
+ }
569
+ }
570
+
571
+ // Auto-derive inventory variables
572
+ const derivedVars = autoDeriveInventoryVariables(moduleId, selfConfig);
573
+
574
+ // Merge derived variables into selfConfig
575
+ // Explicit config takes precedence over derived values
576
+ const finalSelfConfig = { ...derivedVars, ...selfConfig };
577
+
578
+ return {
579
+ moduleId,
580
+ selfConfig: finalSelfConfig,
581
+ systemConfig: systemConfigMap,
582
+ systemSecrets: systemSecretsMap,
583
+ secrets: secretsMap,
584
+ capabilities: capabilitiesMap,
585
+ };
586
+ }
587
+
588
+ /**
589
+ * Build resolution context from explicit data (for testing)
590
+ *
591
+ * Policy function - no database access
592
+ *
593
+ * @param moduleId - Module ID
594
+ * @param data - Explicit data sources
595
+ * @returns Resolution context
596
+ */
597
+ export function buildContextFromData(
598
+ moduleId: string,
599
+ data: {
600
+ selfConfig?: Record<string, string>;
601
+ systemConfig?: Record<string, string>;
602
+ systemSecrets?: Record<string, string>;
603
+ secrets?: Record<string, string>;
604
+ capabilities?: Record<string, Record<string, unknown>>;
605
+ } = {},
606
+ ): ResolutionContext {
607
+ const selfConfig = data.selfConfig ?? {};
608
+
609
+ // Auto-derive inventory variables (same as buildResolutionContext)
610
+ const derivedVars = autoDeriveInventoryVariables(moduleId, selfConfig);
611
+
612
+ // Merge derived variables into selfConfig
613
+ // Explicit config takes precedence over derived values
614
+ const finalSelfConfig = { ...derivedVars, ...selfConfig };
615
+
616
+ return {
617
+ moduleId,
618
+ selfConfig: finalSelfConfig,
619
+ systemConfig: data.systemConfig ?? {},
620
+ systemSecrets: data.systemSecrets ?? {},
621
+ secrets: data.secrets ?? {},
622
+ capabilities: data.capabilities ?? {},
623
+ };
624
+ }