@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,1265 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync } from 'node:fs';
3
+ import { rm } from 'node:fs/promises';
4
+ import { eq } from 'drizzle-orm';
5
+ import { type DbClient, createDbClient } from '../db/client';
6
+ import {
7
+ capabilities,
8
+ ipAllocations,
9
+ moduleConfigs,
10
+ modules,
11
+ secrets,
12
+ systemConfig,
13
+ } from '../db/schema';
14
+ import { buildContextFromData, buildResolutionContext } from './context';
15
+
16
+ const TEST_DB_PATH = './test-context.db';
17
+
18
+ describe('Variable Context', () => {
19
+ let db: DbClient;
20
+
21
+ beforeEach(() => {
22
+ db = createDbClient({ path: TEST_DB_PATH });
23
+
24
+ // Create tables
25
+ db.$client.run(`
26
+ CREATE TABLE IF NOT EXISTS modules (
27
+ id TEXT PRIMARY KEY,
28
+ name TEXT NOT NULL,
29
+ version TEXT NOT NULL,
30
+ description TEXT,
31
+ state TEXT NOT NULL DEFAULT 'IMPORTED',
32
+ manifest_data TEXT NOT NULL,
33
+ source_path TEXT NOT NULL,
34
+ imported_at INTEGER NOT NULL DEFAULT (unixepoch()),
35
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
36
+ error_message TEXT
37
+ )
38
+ `);
39
+
40
+ db.$client.run(`
41
+ CREATE TABLE IF NOT EXISTS module_configs (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ module_id TEXT NOT NULL,
44
+ key TEXT NOT NULL,
45
+ value TEXT NOT NULL,
46
+ value_json TEXT,
47
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
48
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
49
+ UNIQUE(module_id, key),
50
+ FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
51
+ )
52
+ `);
53
+
54
+ db.$client.run(`
55
+ CREATE TABLE IF NOT EXISTS capabilities (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ module_id TEXT NOT NULL,
58
+ capability_name TEXT NOT NULL,
59
+ version TEXT NOT NULL,
60
+ data TEXT NOT NULL,
61
+ registered_at INTEGER NOT NULL DEFAULT (unixepoch()),
62
+ FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
63
+ )
64
+ `);
65
+
66
+ db.$client.run(`
67
+ CREATE TABLE IF NOT EXISTS secrets (
68
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ module_id TEXT NOT NULL,
70
+ name TEXT NOT NULL,
71
+ encrypted_value TEXT NOT NULL,
72
+ iv TEXT NOT NULL,
73
+ auth_tag TEXT NOT NULL,
74
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
75
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
76
+ FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
77
+ )
78
+ `);
79
+
80
+ db.$client.run(`
81
+ CREATE TABLE IF NOT EXISTS system_config (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ key TEXT NOT NULL UNIQUE,
84
+ value TEXT NOT NULL,
85
+ description TEXT,
86
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
87
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
88
+ )
89
+ `);
90
+
91
+ db.$client.run(`
92
+ CREATE TABLE IF NOT EXISTS ip_allocations (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ module_id TEXT NOT NULL,
95
+ vmid INTEGER NOT NULL UNIQUE,
96
+ container_ip TEXT NOT NULL UNIQUE,
97
+ zone TEXT NOT NULL,
98
+ allocated_at INTEGER NOT NULL DEFAULT (unixepoch()),
99
+ FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
100
+ )
101
+ `);
102
+
103
+ db.$client.run(`
104
+ CREATE TABLE IF NOT EXISTS vmid_reservations (
105
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
106
+ vmid INTEGER NOT NULL UNIQUE,
107
+ reason TEXT NOT NULL,
108
+ reserved_at INTEGER NOT NULL DEFAULT (unixepoch())
109
+ )
110
+ `);
111
+
112
+ db.$client.run(`
113
+ CREATE TABLE IF NOT EXISTS ip_reservations (
114
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
115
+ ip_start TEXT NOT NULL,
116
+ ip_end TEXT,
117
+ zone TEXT NOT NULL,
118
+ reason TEXT NOT NULL,
119
+ reserved_at INTEGER NOT NULL DEFAULT (unixepoch())
120
+ )
121
+ `);
122
+
123
+ // Insert network config for DMZ zone (required for IPAM allocation)
124
+ db.insert(systemConfig)
125
+ .values([
126
+ { key: 'network.dmz.subnet', value: '10.0.10.0/24' },
127
+ { key: 'network.dmz.gateway', value: '10.0.10.1' },
128
+ { key: 'network.dmz.vlan', value: '10' },
129
+ { key: 'network.dmz.bridge', value: 'vmbr0' },
130
+ // App zone config
131
+ { key: 'network.app.subnet', value: '10.0.20.0/24' },
132
+ { key: 'network.app.gateway', value: '10.0.20.1' },
133
+ { key: 'network.app.vlan', value: '20' },
134
+ { key: 'network.app.bridge', value: 'vmbr0' },
135
+ // Secure zone config
136
+ { key: 'network.secure.subnet', value: '10.0.30.0/24' },
137
+ { key: 'network.secure.gateway', value: '10.0.30.1' },
138
+ { key: 'network.secure.vlan', value: '30' },
139
+ { key: 'network.secure.bridge', value: 'vmbr0' },
140
+ ])
141
+ .run();
142
+ });
143
+
144
+ afterEach(async () => {
145
+ db.$client.close();
146
+
147
+ if (existsSync(TEST_DB_PATH)) {
148
+ await rm(TEST_DB_PATH);
149
+ }
150
+ const walPath = `${TEST_DB_PATH}-wal`;
151
+ const shmPath = `${TEST_DB_PATH}-shm`;
152
+ if (existsSync(walPath)) {
153
+ await rm(walPath);
154
+ }
155
+ if (existsSync(shmPath)) {
156
+ await rm(shmPath);
157
+ }
158
+ });
159
+
160
+ describe('buildResolutionContext', () => {
161
+ test('should build context with module configs', async () => {
162
+ // Insert module first (for foreign key)
163
+ db.$client.run(
164
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`,
165
+ );
166
+
167
+ db.insert(moduleConfigs)
168
+ .values([
169
+ { moduleId: 'homebridge', key: 'container_ip', value: '192.168.0.50' },
170
+ { moduleId: 'homebridge', key: 'hostname', value: 'homebridge' },
171
+ ])
172
+ .run();
173
+
174
+ const context = await buildResolutionContext('homebridge', db);
175
+
176
+ expect(context.moduleId).toBe('homebridge');
177
+ expect(context.selfConfig.container_ip).toBe('192.168.0.50');
178
+ expect(context.selfConfig.hostname).toBe('homebridge');
179
+ // Auto-derived variables
180
+ expect(context.selfConfig['inventory.ansible_host']).toBe('192.168.0.50');
181
+ expect(context.selfConfig['inventory.ansible_user']).toBe('root');
182
+ expect(context.selfConfig['inventory.groups']).toBe('homebridge');
183
+ });
184
+
185
+ test('should build context with secrets', async () => {
186
+ db.$client.run(
187
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`,
188
+ );
189
+
190
+ db.insert(secrets)
191
+ .values([
192
+ {
193
+ moduleId: 'homebridge',
194
+ name: 'api_key',
195
+ encryptedValue: 'secret123',
196
+ iv: 'iv',
197
+ authTag: 'tag',
198
+ },
199
+ ])
200
+ .run();
201
+
202
+ const context = await buildResolutionContext('homebridge', db);
203
+
204
+ expect(context.secrets).toEqual({
205
+ api_key: 'secret123',
206
+ });
207
+ });
208
+
209
+ test('should build context with capabilities', async () => {
210
+ db.$client.run(
211
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns-external', 'DNS', '1.0.0', '/path', '{}')`,
212
+ );
213
+
214
+ db.insert(capabilities)
215
+ .values([
216
+ {
217
+ moduleId: 'dns-external',
218
+ capabilityName: 'dns_external',
219
+ version: '1.0.0',
220
+ data: {
221
+ nameserver: 'ns1.example.com',
222
+ zone: 'example.com',
223
+ },
224
+ },
225
+ ])
226
+ .run();
227
+
228
+ const context = await buildResolutionContext('homebridge', db);
229
+
230
+ expect(context.capabilities).toEqual({
231
+ dns_external: {
232
+ nameserver: 'ns1.example.com',
233
+ zone: 'example.com',
234
+ },
235
+ });
236
+ });
237
+
238
+ test('should load system config from database', async () => {
239
+ db.$client.run(
240
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`,
241
+ );
242
+
243
+ db.$client.run(
244
+ `INSERT INTO system_config (key, value) VALUES ('dns.primary', '192.168.0.1')`,
245
+ );
246
+ db.$client.run(
247
+ `INSERT INTO system_config (key, value) VALUES ('network.domain', 'homelab.local')`,
248
+ );
249
+
250
+ const context = await buildResolutionContext('homebridge', db);
251
+
252
+ expect(context.systemConfig['dns.primary']).toBe('192.168.0.1');
253
+ expect(context.systemConfig['network.domain']).toBe('homelab.local');
254
+ });
255
+
256
+ test('should build context with all data sources', async () => {
257
+ db.$client.run(
258
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy', 'Caddy', '1.0.0', '/path', '{}')`,
259
+ );
260
+ db.$client.run(
261
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns-external', 'DNS', '1.0.0', '/path', '{}')`,
262
+ );
263
+
264
+ db.insert(moduleConfigs)
265
+ .values({ moduleId: 'caddy', key: 'container_ip', value: '10.0.20.10' })
266
+ .run();
267
+
268
+ db.insert(secrets)
269
+ .values({
270
+ moduleId: 'caddy',
271
+ name: 'ssl_cert',
272
+ encryptedValue: 'cert_data',
273
+ iv: 'iv',
274
+ authTag: 'tag',
275
+ })
276
+ .run();
277
+
278
+ db.insert(capabilities)
279
+ .values({
280
+ moduleId: 'dns-external',
281
+ capabilityName: 'dns_external',
282
+ version: '1.0.0',
283
+ data: { nameserver: 'ns1.example.com' },
284
+ })
285
+ .run();
286
+
287
+ db.$client.run(
288
+ `INSERT INTO system_config (key, value) VALUES ('dns.primary', '192.168.0.1')`,
289
+ );
290
+
291
+ const context = await buildResolutionContext('caddy', db);
292
+
293
+ expect(context.selfConfig.container_ip).toBe('10.0.20.10');
294
+ expect(context.secrets.ssl_cert).toBe('cert_data');
295
+ expect(context.capabilities.dns_external).toBeDefined();
296
+ expect(context.systemConfig['dns.primary']).toBe('192.168.0.1');
297
+ });
298
+
299
+ test('should return empty maps for module with no data', async () => {
300
+ const context = await buildResolutionContext('empty-module', db);
301
+
302
+ expect(context.moduleId).toBe('empty-module');
303
+ // Should have auto-derived inventory variables
304
+ expect(context.selfConfig['inventory.ansible_user']).toBe('root');
305
+ expect(context.selfConfig['inventory.groups']).toBe('empty-module');
306
+ // Should not have ansible_host (no container_ip)
307
+ expect(context.selfConfig['inventory.ansible_host']).toBeUndefined();
308
+ expect(context.secrets).toEqual({});
309
+ expect(context.capabilities).toEqual({});
310
+ expect(context.systemConfig).toBeDefined();
311
+ });
312
+
313
+ test('should include VM resources from manifest', async () => {
314
+ // Create module with VM resources
315
+ db.insert(modules)
316
+ .values({
317
+ id: 'grafana',
318
+ name: 'Grafana',
319
+ version: '1.0.0',
320
+ sourcePath: '/test/grafana',
321
+ manifestData: {
322
+ id: 'grafana',
323
+ name: 'Grafana',
324
+ version: '1.0.0',
325
+ requires: {
326
+ machine: {
327
+ cpu: 2,
328
+ memory: 2048,
329
+ disk: 20,
330
+ storage: 'local-lvm',
331
+ zone: 'app',
332
+ },
333
+ },
334
+ },
335
+ })
336
+ .run();
337
+
338
+ const context = await buildResolutionContext('grafana', db);
339
+
340
+ expect(context.selfConfig['requires.machine.cpu']).toBe('2');
341
+ expect(context.selfConfig['requires.machine.memory']).toBe('2048');
342
+ expect(context.selfConfig['requires.machine.disk']).toBe('20');
343
+ expect(context.selfConfig['requires.machine.storage']).toBe('local-lvm');
344
+ expect(context.selfConfig['requires.machine.zone']).toBe('app');
345
+ });
346
+
347
+ test('should handle module without VM resources', async () => {
348
+ // Create module without VM resources
349
+ db.insert(modules)
350
+ .values({
351
+ id: 'simple',
352
+ name: 'Simple Module',
353
+ version: '1.0.0',
354
+ sourcePath: '/test/simple',
355
+ manifestData: {
356
+ id: 'simple',
357
+ name: 'Simple Module',
358
+ version: '1.0.0',
359
+ },
360
+ })
361
+ .run();
362
+
363
+ const context = await buildResolutionContext('simple', db);
364
+
365
+ // Should not have requires.machine keys
366
+ expect(context.selfConfig['requires.machine.cpu']).toBeUndefined();
367
+ expect(context.selfConfig['requires.machine.memory']).toBeUndefined();
368
+ });
369
+
370
+ test('should auto-derive inventory variables from container_ip', async () => {
371
+ db.$client.run(
372
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`,
373
+ );
374
+
375
+ db.insert(moduleConfigs)
376
+ .values({ moduleId: 'test-module', key: 'container_ip', value: '10.0.10.10/24' })
377
+ .run();
378
+
379
+ const context = await buildResolutionContext('test-module', db);
380
+
381
+ // Should strip CIDR from container_ip
382
+ expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.10.10');
383
+ expect(context.selfConfig['inventory.ansible_user']).toBe('root');
384
+ expect(context.selfConfig['inventory.groups']).toBe('test-module');
385
+ });
386
+
387
+ test('should allow overriding ansible_user', async () => {
388
+ db.$client.run(
389
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('custom', 'Custom', '1.0.0', '/path', '{}')`,
390
+ );
391
+
392
+ db.insert(moduleConfigs)
393
+ .values([
394
+ { moduleId: 'custom', key: 'container_ip', value: '10.0.20.10' },
395
+ { moduleId: 'custom', key: 'inventory.ansible_user', value: 'admin' },
396
+ ])
397
+ .run();
398
+
399
+ const context = await buildResolutionContext('custom', db);
400
+
401
+ // Explicit config should override default
402
+ expect(context.selfConfig['inventory.ansible_user']).toBe('admin');
403
+ expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.20.10');
404
+ });
405
+
406
+ test('should handle container_ip without CIDR', async () => {
407
+ db.$client.run(
408
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('no-cidr', 'No CIDR', '1.0.0', '/path', '{}')`,
409
+ );
410
+
411
+ db.insert(moduleConfigs)
412
+ .values({ moduleId: 'no-cidr', key: 'container_ip', value: '192.168.1.100' })
413
+ .run();
414
+
415
+ const context = await buildResolutionContext('no-cidr', db);
416
+
417
+ // Should work without CIDR notation
418
+ expect(context.selfConfig['inventory.ansible_host']).toBe('192.168.1.100');
419
+ });
420
+
421
+ test('should auto-allocate IPAM resources when module declares vmid and container_ip', async () => {
422
+ // Create module with manifest that declares vmid and container_ip
423
+ db.insert(modules)
424
+ .values({
425
+ id: 'auto-module',
426
+ name: 'Auto Module',
427
+ version: '1.0.0',
428
+ sourcePath: '/test/auto',
429
+ manifestData: {
430
+ id: 'auto-module',
431
+ name: 'Auto Module',
432
+ version: '1.0.0',
433
+ variables: {
434
+ owns: [
435
+ { name: 'vmid', type: 'integer', required: true, source: 'user' },
436
+ { name: 'container_ip', type: 'string', required: true, source: 'user' },
437
+ ],
438
+ },
439
+ requires: {
440
+ machine: {
441
+ zone: 'dmz',
442
+ },
443
+ },
444
+ },
445
+ })
446
+ .run();
447
+
448
+ const context = await buildResolutionContext('auto-module', db);
449
+
450
+ // Should have auto-allocated vmid and container_ip
451
+ expect(context.selfConfig.vmid).toBe('2100');
452
+ expect(context.selfConfig.container_ip).toBe('10.0.10.10/24');
453
+
454
+ // Verify allocation persisted to database
455
+ const allocations = await db.select().from(ipAllocations).all();
456
+ expect(allocations).toHaveLength(1);
457
+ expect(allocations[0].moduleId).toBe('auto-module');
458
+ expect(allocations[0].vmid).toBe(2100);
459
+ expect(allocations[0].containerIp).toBe('10.0.10.10/24');
460
+
461
+ // Verify config persisted to database
462
+ const configs = await db
463
+ .select()
464
+ .from(moduleConfigs)
465
+ .where(eq(moduleConfigs.moduleId, 'auto-module'))
466
+ .all();
467
+ const vmidConfig = configs.find((c) => c.key === 'vmid');
468
+ const ipConfig = configs.find((c) => c.key === 'container_ip');
469
+ expect(vmidConfig?.value).toBe('2100');
470
+ expect(ipConfig?.value).toBe('10.0.10.10/24');
471
+ });
472
+
473
+ test('should reuse existing allocation if already allocated', async () => {
474
+ // Create module
475
+ db.insert(modules)
476
+ .values({
477
+ id: 'existing-alloc',
478
+ name: 'Existing Allocation',
479
+ version: '1.0.0',
480
+ sourcePath: '/test/existing',
481
+ manifestData: {
482
+ id: 'existing-alloc',
483
+ name: 'Existing Allocation',
484
+ version: '1.0.0',
485
+ variables: {
486
+ owns: [
487
+ { name: 'vmid', type: 'integer', required: true, source: 'user' },
488
+ { name: 'container_ip', type: 'string', required: true, source: 'user' },
489
+ ],
490
+ },
491
+ },
492
+ })
493
+ .run();
494
+
495
+ // Pre-create allocation
496
+ db.insert(ipAllocations)
497
+ .values({
498
+ moduleId: 'existing-alloc',
499
+ vmid: 2150,
500
+ containerIp: '10.0.10.50/24',
501
+ zone: 'dmz',
502
+ })
503
+ .run();
504
+
505
+ const context = await buildResolutionContext('existing-alloc', db);
506
+
507
+ // Should reuse existing allocation
508
+ expect(context.selfConfig.vmid).toBe('2150');
509
+ expect(context.selfConfig.container_ip).toBe('10.0.10.50/24');
510
+
511
+ // Should not create duplicate allocation
512
+ const allocations = await db.select().from(ipAllocations).all();
513
+ expect(allocations).toHaveLength(1);
514
+ });
515
+
516
+ test('should skip IPAM allocation if vmid and container_ip already configured', async () => {
517
+ // Create module with existing config
518
+ db.insert(modules)
519
+ .values({
520
+ id: 'manual-config',
521
+ name: 'Manual Config',
522
+ version: '1.0.0',
523
+ sourcePath: '/test/manual',
524
+ manifestData: {
525
+ id: 'manual-config',
526
+ name: 'Manual Config',
527
+ version: '1.0.0',
528
+ variables: {
529
+ owns: [
530
+ { name: 'vmid', type: 'integer', required: true, source: 'user' },
531
+ { name: 'container_ip', type: 'string', required: true, source: 'user' },
532
+ ],
533
+ },
534
+ },
535
+ })
536
+ .run();
537
+
538
+ db.insert(moduleConfigs)
539
+ .values([
540
+ { moduleId: 'manual-config', key: 'vmid', value: '9999' },
541
+ { moduleId: 'manual-config', key: 'container_ip', value: '192.168.99.99/24' },
542
+ ])
543
+ .run();
544
+
545
+ const context = await buildResolutionContext('manual-config', db);
546
+
547
+ // Should use existing config
548
+ expect(context.selfConfig.vmid).toBe('9999');
549
+ expect(context.selfConfig.container_ip).toBe('192.168.99.99/24');
550
+
551
+ // Should not create allocation
552
+ const allocations = await db.select().from(ipAllocations).all();
553
+ expect(allocations).toHaveLength(0);
554
+ });
555
+
556
+ test('should skip IPAM allocation for VPS modules without container_ip', async () => {
557
+ // Create VPS module (no container_ip variable)
558
+ db.insert(modules)
559
+ .values({
560
+ id: 'vps-module',
561
+ name: 'VPS Module',
562
+ version: '1.0.0',
563
+ sourcePath: '/test/vps',
564
+ manifestData: {
565
+ id: 'vps-module',
566
+ name: 'VPS Module',
567
+ version: '1.0.0',
568
+ variables: {
569
+ owns: [{ name: 'vps_ip', type: 'string', required: true, source: 'user' }],
570
+ },
571
+ },
572
+ })
573
+ .run();
574
+
575
+ const context = await buildResolutionContext('vps-module', db);
576
+
577
+ // Should not allocate IPAM resources
578
+ expect(context.selfConfig.vmid).toBeUndefined();
579
+ expect(context.selfConfig.container_ip).toBeUndefined();
580
+
581
+ const allocations = await db.select().from(ipAllocations).all();
582
+ expect(allocations).toHaveLength(0);
583
+ });
584
+
585
+ test('should allocate sequential VMIDs for multiple modules', async () => {
586
+ // Create first module
587
+ db.insert(modules)
588
+ .values({
589
+ id: 'module1',
590
+ name: 'Module 1',
591
+ version: '1.0.0',
592
+ sourcePath: '/test/p1',
593
+ manifestData: {
594
+ id: 'module1',
595
+ name: 'Module 1',
596
+ version: '1.0.0',
597
+ variables: {
598
+ owns: [
599
+ { name: 'vmid', type: 'integer', required: true, source: 'user' },
600
+ { name: 'container_ip', type: 'string', required: true, source: 'user' },
601
+ ],
602
+ },
603
+ },
604
+ })
605
+ .run();
606
+
607
+ // Create second module
608
+ db.insert(modules)
609
+ .values({
610
+ id: 'module2',
611
+ name: 'Module 2',
612
+ version: '1.0.0',
613
+ sourcePath: '/test/p2',
614
+ manifestData: {
615
+ id: 'module2',
616
+ name: 'Module 2',
617
+ version: '1.0.0',
618
+ variables: {
619
+ owns: [
620
+ { name: 'vmid', type: 'integer', required: true, source: 'user' },
621
+ { name: 'container_ip', type: 'string', required: true, source: 'user' },
622
+ ],
623
+ },
624
+ },
625
+ })
626
+ .run();
627
+
628
+ // Allocate for both
629
+ const context1 = await buildResolutionContext('module1', db);
630
+ const context2 = await buildResolutionContext('module2', db);
631
+
632
+ // Should have sequential VMIDs
633
+ expect(context1.selfConfig.vmid).toBe('2100');
634
+ expect(context2.selfConfig.vmid).toBe('2101');
635
+
636
+ // Should have sequential IPs
637
+ expect(context1.selfConfig.container_ip).toBe('10.0.10.10/24');
638
+ expect(context2.selfConfig.container_ip).toBe('10.0.10.11/24');
639
+
640
+ const allocations = await db.select().from(ipAllocations).all();
641
+ expect(allocations).toHaveLength(2);
642
+ });
643
+
644
+ test('should auto-assign hostname from well-known capability', async () => {
645
+ // Create module providing public_web capability
646
+ db.insert(modules)
647
+ .values({
648
+ id: 'caddy',
649
+ name: 'Caddy',
650
+ version: '1.0.0',
651
+ sourcePath: '/test/caddy',
652
+ manifestData: {
653
+ id: 'caddy',
654
+ name: 'Caddy',
655
+ version: '1.0.0',
656
+ provides: {
657
+ capabilities: [
658
+ {
659
+ name: 'public_web',
660
+ version: '1.0.0',
661
+ data: {},
662
+ },
663
+ ],
664
+ },
665
+ },
666
+ })
667
+ .run();
668
+
669
+ const context = await buildResolutionContext('caddy', db);
670
+
671
+ // Should auto-assign canonical hostname
672
+ expect(context.selfConfig.hostname).toBe('www');
673
+
674
+ // Verify persisted to database
675
+ const configs = await db
676
+ .select()
677
+ .from(moduleConfigs)
678
+ .where(eq(moduleConfigs.moduleId, 'caddy'))
679
+ .all();
680
+ const hostnameConfig = configs.find((c) => c.key === 'hostname');
681
+ expect(hostnameConfig?.value).toBe('www');
682
+ });
683
+
684
+ test('should auto-assign zone from well-known capability', async () => {
685
+ // Create module providing auth capability (requires secure zone)
686
+ db.insert(modules)
687
+ .values({
688
+ id: 'authentik',
689
+ name: 'Authentik',
690
+ version: '1.0.0',
691
+ sourcePath: '/test/authentik',
692
+ manifestData: {
693
+ id: 'authentik',
694
+ name: 'Authentik',
695
+ version: '1.0.0',
696
+ provides: {
697
+ capabilities: [
698
+ {
699
+ name: 'auth',
700
+ version: '1.0.0',
701
+ data: {},
702
+ },
703
+ ],
704
+ },
705
+ },
706
+ })
707
+ .run();
708
+
709
+ const context = await buildResolutionContext('authentik', db);
710
+
711
+ // Should auto-assign required zone
712
+ expect(context.selfConfig.zone).toBe('secure');
713
+ expect(context.selfConfig.hostname).toBe('auth');
714
+
715
+ // Verify persisted to database
716
+ const configs = await db
717
+ .select()
718
+ .from(moduleConfigs)
719
+ .where(eq(moduleConfigs.moduleId, 'authentik'))
720
+ .all();
721
+ const zoneConfig = configs.find((c) => c.key === 'zone');
722
+ expect(zoneConfig?.value).toBe('secure');
723
+ });
724
+
725
+ test('should respect explicit hostname override', async () => {
726
+ // Create module with manual hostname config
727
+ db.insert(modules)
728
+ .values({
729
+ id: 'custom-web',
730
+ name: 'Custom Web',
731
+ version: '1.0.0',
732
+ sourcePath: '/test/custom',
733
+ manifestData: {
734
+ id: 'custom-web',
735
+ name: 'Custom Web',
736
+ version: '1.0.0',
737
+ provides: {
738
+ capabilities: [
739
+ {
740
+ name: 'public_web',
741
+ version: '1.0.0',
742
+ data: {},
743
+ },
744
+ ],
745
+ },
746
+ },
747
+ })
748
+ .run();
749
+
750
+ db.insert(moduleConfigs)
751
+ .values({ moduleId: 'custom-web', key: 'hostname', value: 'custom' })
752
+ .run();
753
+
754
+ const context = await buildResolutionContext('custom-web', db);
755
+
756
+ // Should use explicit config
757
+ expect(context.selfConfig.hostname).toBe('custom');
758
+ });
759
+
760
+ // Note: Hostname conflict and zone enforcement tests moved to import-time validation
761
+ // See test-integration/module/zero-config.test.ts and src/module/import.test.ts
762
+ // These validations now happen during module import via validateWellKnownCapabilities()
763
+
764
+ test('should skip well-known assignment for non-well-known capabilities', async () => {
765
+ // Create module with custom capability
766
+ db.insert(modules)
767
+ .values({
768
+ id: 'custom-module',
769
+ name: 'Custom Module',
770
+ version: '1.0.0',
771
+ sourcePath: '/test/custom',
772
+ manifestData: {
773
+ id: 'custom-module',
774
+ name: 'Custom Module',
775
+ version: '1.0.0',
776
+ provides: {
777
+ capabilities: [{ name: 'custom_capability', version: '1.0.0', data: {} }],
778
+ },
779
+ },
780
+ })
781
+ .run();
782
+
783
+ const context = await buildResolutionContext('custom-module', db);
784
+
785
+ // Should not auto-assign anything
786
+ expect(context.selfConfig.hostname).toBeUndefined();
787
+ expect(context.selfConfig.zone).toBeUndefined();
788
+ });
789
+
790
+ test('should prioritize first well-known capability when multiple provided', async () => {
791
+ // Create module providing multiple well-known capabilities
792
+ db.insert(modules)
793
+ .values({
794
+ id: 'multi-cap',
795
+ name: 'Multi Cap',
796
+ version: '1.0.0',
797
+ sourcePath: '/test/multi',
798
+ manifestData: {
799
+ id: 'multi-cap',
800
+ name: 'Multi Cap',
801
+ version: '1.0.0',
802
+ provides: {
803
+ capabilities: [
804
+ { name: 'public_web', version: '1.0.0', data: {} }, // First
805
+ { name: 'auth', version: '1.0.0', data: {} }, // Second
806
+ ],
807
+ },
808
+ },
809
+ })
810
+ .run();
811
+
812
+ const context = await buildResolutionContext('multi-cap', db);
813
+
814
+ // Should use first capability (public_web)
815
+ expect(context.selfConfig.hostname).toBe('www');
816
+ expect(context.selfConfig.zone).toBe('dmz');
817
+ });
818
+
819
+ test('should auto-apply VM resource defaults from manifest', async () => {
820
+ // Create module with VM resource defaults
821
+ db.insert(modules)
822
+ .values({
823
+ id: 'app-with-resources',
824
+ name: 'App With Resources',
825
+ version: '1.0.0',
826
+ sourcePath: '/test/app',
827
+ manifestData: {
828
+ id: 'app-with-resources',
829
+ name: 'App With Resources',
830
+ version: '1.0.0',
831
+ requires: {
832
+ machine: {
833
+ cpu: 2,
834
+ memory: 2048,
835
+ disk: 20,
836
+ storage: 'local-lvm',
837
+ },
838
+ },
839
+ },
840
+ })
841
+ .run();
842
+
843
+ const context = await buildResolutionContext('app-with-resources', db);
844
+
845
+ // Should auto-apply all VM resource defaults
846
+ expect(context.selfConfig.cores).toBe('2');
847
+ expect(context.selfConfig.memory).toBe('2048');
848
+ expect(context.selfConfig.disk).toBe('20');
849
+ expect(context.selfConfig.storage).toBe('local-lvm');
850
+
851
+ // Verify persisted to database
852
+ const configs = await db
853
+ .select()
854
+ .from(moduleConfigs)
855
+ .where(eq(moduleConfigs.moduleId, 'app-with-resources'))
856
+ .all();
857
+
858
+ expect(configs.find((c) => c.key === 'cores')?.value).toBe('2');
859
+ expect(configs.find((c) => c.key === 'memory')?.value).toBe('2048');
860
+ expect(configs.find((c) => c.key === 'disk')?.value).toBe('20');
861
+ expect(configs.find((c) => c.key === 'storage')?.value).toBe('local-lvm');
862
+ });
863
+
864
+ test('should respect user overrides for VM resources', async () => {
865
+ // Create module with VM resource defaults
866
+ db.insert(modules)
867
+ .values({
868
+ id: 'custom-resources',
869
+ name: 'Custom Resources',
870
+ version: '1.0.0',
871
+ sourcePath: '/test/custom',
872
+ manifestData: {
873
+ id: 'custom-resources',
874
+ name: 'Custom Resources',
875
+ version: '1.0.0',
876
+ requires: {
877
+ machine: {
878
+ cpu: 2,
879
+ memory: 2048,
880
+ },
881
+ },
882
+ },
883
+ })
884
+ .run();
885
+
886
+ // User explicitly sets higher memory
887
+ db.insert(moduleConfigs)
888
+ .values([
889
+ { moduleId: 'custom-resources', key: 'cores', value: '4' },
890
+ { moduleId: 'custom-resources', key: 'memory', value: '4096' },
891
+ ])
892
+ .run();
893
+
894
+ const context = await buildResolutionContext('custom-resources', db);
895
+
896
+ // Should use user overrides, not manifest defaults
897
+ expect(context.selfConfig.cores).toBe('4');
898
+ expect(context.selfConfig.memory).toBe('4096');
899
+ });
900
+
901
+ test('should handle modules without VM resources', async () => {
902
+ // Create module with no VM resources section
903
+ db.insert(modules)
904
+ .values({
905
+ id: 'no-resources',
906
+ name: 'No Resources',
907
+ version: '1.0.0',
908
+ sourcePath: '/test/no-res',
909
+ manifestData: {
910
+ id: 'no-resources',
911
+ name: 'No Resources',
912
+ version: '1.0.0',
913
+ },
914
+ })
915
+ .run();
916
+
917
+ const context = await buildResolutionContext('no-resources', db);
918
+
919
+ // Should not have any VM resource values
920
+ expect(context.selfConfig.cores).toBeUndefined();
921
+ expect(context.selfConfig.memory).toBeUndefined();
922
+ expect(context.selfConfig.disk).toBeUndefined();
923
+ expect(context.selfConfig.storage).toBeUndefined();
924
+ });
925
+
926
+ test('should only apply defined VM resource fields', async () => {
927
+ // Create module with partial VM resources
928
+ db.insert(modules)
929
+ .values({
930
+ id: 'partial-resources',
931
+ name: 'Partial Resources',
932
+ version: '1.0.0',
933
+ sourcePath: '/test/partial',
934
+ manifestData: {
935
+ id: 'partial-resources',
936
+ name: 'Partial Resources',
937
+ version: '1.0.0',
938
+ requires: {
939
+ machine: {
940
+ cpu: 1,
941
+ memory: 512,
942
+ // No disk or storage specified
943
+ },
944
+ },
945
+ },
946
+ })
947
+ .run();
948
+
949
+ const context = await buildResolutionContext('partial-resources', db);
950
+
951
+ // Should apply specified fields
952
+ expect(context.selfConfig.cores).toBe('1');
953
+ expect(context.selfConfig.memory).toBe('512');
954
+
955
+ // Should not have unspecified fields
956
+ expect(context.selfConfig.disk).toBeUndefined();
957
+ expect(context.selfConfig.storage).toBeUndefined();
958
+ });
959
+
960
+ test('should auto-derive network config from dmz zone', async () => {
961
+ // Create module in DMZ zone
962
+ db.insert(modules)
963
+ .values({
964
+ id: 'dmz-module',
965
+ name: 'DMZ Module',
966
+ version: '1.0.0',
967
+ sourcePath: '/test/dmz',
968
+ manifestData: {
969
+ id: 'dmz-module',
970
+ name: 'DMZ Module',
971
+ version: '1.0.0',
972
+ requires: {
973
+ machine: {
974
+ zone: 'dmz',
975
+ },
976
+ },
977
+ },
978
+ })
979
+ .run();
980
+
981
+ const context = await buildResolutionContext('dmz-module', db);
982
+
983
+ // Should auto-derive all network config from DMZ zone
984
+ expect(context.selfConfig.gateway).toBe('10.0.10.1');
985
+ expect(context.selfConfig.vlan).toBe('10');
986
+ expect(context.selfConfig.subnet).toBe('10.0.10.0/24');
987
+ expect(context.selfConfig.bridge).toBe('vmbr0');
988
+
989
+ // Verify persisted to database
990
+ const configs = await db
991
+ .select()
992
+ .from(moduleConfigs)
993
+ .where(eq(moduleConfigs.moduleId, 'dmz-module'))
994
+ .all();
995
+
996
+ expect(configs.find((c) => c.key === 'gateway')?.value).toBe('10.0.10.1');
997
+ expect(configs.find((c) => c.key === 'vlan')?.value).toBe('10');
998
+ expect(configs.find((c) => c.key === 'subnet')?.value).toBe('10.0.10.0/24');
999
+ expect(configs.find((c) => c.key === 'bridge')?.value).toBe('vmbr0');
1000
+ });
1001
+
1002
+ test('should auto-derive network config from app zone', async () => {
1003
+ // Create module in app zone
1004
+ db.insert(modules)
1005
+ .values({
1006
+ id: 'app-module',
1007
+ name: 'App Module',
1008
+ version: '1.0.0',
1009
+ sourcePath: '/test/app',
1010
+ manifestData: {
1011
+ id: 'app-module',
1012
+ name: 'App Module',
1013
+ version: '1.0.0',
1014
+ requires: {
1015
+ machine: {
1016
+ zone: 'app',
1017
+ },
1018
+ },
1019
+ },
1020
+ })
1021
+ .run();
1022
+
1023
+ const context = await buildResolutionContext('app-module', db);
1024
+
1025
+ // Should auto-derive network config from app zone
1026
+ expect(context.selfConfig.gateway).toBe('10.0.20.1');
1027
+ expect(context.selfConfig.vlan).toBe('20');
1028
+ expect(context.selfConfig.subnet).toBe('10.0.20.0/24');
1029
+ expect(context.selfConfig.bridge).toBe('vmbr0');
1030
+ });
1031
+
1032
+ test('should auto-derive network config from secure zone', async () => {
1033
+ // Create module in secure zone
1034
+ db.insert(modules)
1035
+ .values({
1036
+ id: 'secure-module',
1037
+ name: 'Secure Module',
1038
+ version: '1.0.0',
1039
+ sourcePath: '/test/secure',
1040
+ manifestData: {
1041
+ id: 'secure-module',
1042
+ name: 'Secure Module',
1043
+ version: '1.0.0',
1044
+ requires: {
1045
+ machine: {
1046
+ zone: 'secure',
1047
+ },
1048
+ },
1049
+ },
1050
+ })
1051
+ .run();
1052
+
1053
+ const context = await buildResolutionContext('secure-module', db);
1054
+
1055
+ // Should auto-derive network config from secure zone
1056
+ expect(context.selfConfig.gateway).toBe('10.0.30.1');
1057
+ expect(context.selfConfig.vlan).toBe('30');
1058
+ expect(context.selfConfig.subnet).toBe('10.0.30.0/24');
1059
+ expect(context.selfConfig.bridge).toBe('vmbr0');
1060
+ });
1061
+
1062
+ test('should use zone from config if specified', async () => {
1063
+ // Create module with zone in config (not manifest)
1064
+ db.insert(modules)
1065
+ .values({
1066
+ id: 'config-zone',
1067
+ name: 'Config Zone',
1068
+ version: '1.0.0',
1069
+ sourcePath: '/test/config-zone',
1070
+ manifestData: {
1071
+ id: 'config-zone',
1072
+ name: 'Config Zone',
1073
+ version: '1.0.0',
1074
+ },
1075
+ })
1076
+ .run();
1077
+
1078
+ db.insert(moduleConfigs).values({ moduleId: 'config-zone', key: 'zone', value: 'app' }).run();
1079
+
1080
+ const context = await buildResolutionContext('config-zone', db);
1081
+
1082
+ // Should use zone from config and derive network settings
1083
+ expect(context.selfConfig.gateway).toBe('10.0.20.1');
1084
+ expect(context.selfConfig.vlan).toBe('20');
1085
+ });
1086
+
1087
+ test('should respect user overrides for network config', async () => {
1088
+ // Create module with zone
1089
+ db.insert(modules)
1090
+ .values({
1091
+ id: 'custom-network',
1092
+ name: 'Custom Network',
1093
+ version: '1.0.0',
1094
+ sourcePath: '/test/custom-net',
1095
+ manifestData: {
1096
+ id: 'custom-network',
1097
+ name: 'Custom Network',
1098
+ version: '1.0.0',
1099
+ requires: {
1100
+ machine: {
1101
+ zone: 'dmz',
1102
+ },
1103
+ },
1104
+ },
1105
+ })
1106
+ .run();
1107
+
1108
+ // User explicitly sets different gateway
1109
+ db.insert(moduleConfigs)
1110
+ .values([
1111
+ { moduleId: 'custom-network', key: 'gateway', value: '10.0.10.254' },
1112
+ { moduleId: 'custom-network', key: 'vlan', value: '100' },
1113
+ ])
1114
+ .run();
1115
+
1116
+ const context = await buildResolutionContext('custom-network', db);
1117
+
1118
+ // Should use user overrides, not zone defaults
1119
+ expect(context.selfConfig.gateway).toBe('10.0.10.254');
1120
+ expect(context.selfConfig.vlan).toBe('100');
1121
+
1122
+ // Should still auto-derive unset fields
1123
+ expect(context.selfConfig.subnet).toBe('10.0.10.0/24');
1124
+ expect(context.selfConfig.bridge).toBe('vmbr0');
1125
+ });
1126
+
1127
+ test('should skip zone-based networking for external zone', async () => {
1128
+ // Create VPS module (external zone)
1129
+ db.insert(modules)
1130
+ .values({
1131
+ id: 'vps-external',
1132
+ name: 'VPS External',
1133
+ version: '1.0.0',
1134
+ sourcePath: '/test/vps',
1135
+ manifestData: {
1136
+ id: 'vps-external',
1137
+ name: 'VPS External',
1138
+ version: '1.0.0',
1139
+ requires: {
1140
+ machine: {
1141
+ zone: 'external',
1142
+ },
1143
+ },
1144
+ },
1145
+ })
1146
+ .run();
1147
+
1148
+ const context = await buildResolutionContext('vps-external', db);
1149
+
1150
+ // Should not auto-derive network config for external zone
1151
+ expect(context.selfConfig.gateway).toBeUndefined();
1152
+ expect(context.selfConfig.vlan).toBeUndefined();
1153
+ expect(context.selfConfig.subnet).toBeUndefined();
1154
+ expect(context.selfConfig.bridge).toBeUndefined();
1155
+ });
1156
+
1157
+ test('should handle missing system network config gracefully', async () => {
1158
+ // Create module in zone without system config
1159
+ db.insert(modules)
1160
+ .values({
1161
+ id: 'no-net-config',
1162
+ name: 'No Net Config',
1163
+ version: '1.0.0',
1164
+ sourcePath: '/test/no-net',
1165
+ manifestData: {
1166
+ id: 'no-net-config',
1167
+ name: 'No Net Config',
1168
+ version: '1.0.0',
1169
+ requires: {
1170
+ machine: {
1171
+ zone: 'dmz',
1172
+ },
1173
+ },
1174
+ },
1175
+ })
1176
+ .run();
1177
+
1178
+ // Remove all network config from system
1179
+ await db.delete(systemConfig).run();
1180
+
1181
+ const context = await buildResolutionContext('no-net-config', db);
1182
+
1183
+ // Should not fail, just not have network config
1184
+ expect(context.selfConfig.gateway).toBeUndefined();
1185
+ expect(context.selfConfig.vlan).toBeUndefined();
1186
+ });
1187
+
1188
+ test('should handle module without zone', async () => {
1189
+ // Create module without zone specified
1190
+ db.insert(modules)
1191
+ .values({
1192
+ id: 'no-zone',
1193
+ name: 'No Zone',
1194
+ version: '1.0.0',
1195
+ sourcePath: '/test/no-zone',
1196
+ manifestData: {
1197
+ id: 'no-zone',
1198
+ name: 'No Zone',
1199
+ version: '1.0.0',
1200
+ },
1201
+ })
1202
+ .run();
1203
+
1204
+ const context = await buildResolutionContext('no-zone', db);
1205
+
1206
+ // Should not auto-derive network config (no zone)
1207
+ expect(context.selfConfig.gateway).toBeUndefined();
1208
+ expect(context.selfConfig.vlan).toBeUndefined();
1209
+ expect(context.selfConfig.subnet).toBeUndefined();
1210
+ });
1211
+ });
1212
+
1213
+ describe('buildContextFromData', () => {
1214
+ test('should build context from explicit data', () => {
1215
+ const context = buildContextFromData('test-module', {
1216
+ selfConfig: { ip: '192.168.0.50' },
1217
+ secrets: { key: 'secret' },
1218
+ capabilities: { dns: { server: 'ns1' } },
1219
+ });
1220
+
1221
+ expect(context.moduleId).toBe('test-module');
1222
+ expect(context.selfConfig.ip).toBe('192.168.0.50');
1223
+ // Auto-derived variables
1224
+ expect(context.selfConfig['inventory.ansible_user']).toBe('root');
1225
+ expect(context.selfConfig['inventory.groups']).toBe('test-module');
1226
+ expect(context.secrets).toEqual({ key: 'secret' });
1227
+ expect(context.capabilities).toEqual({ dns: { server: 'ns1' } });
1228
+ });
1229
+
1230
+ test('should use provided system config', () => {
1231
+ const context = buildContextFromData('test-module', {
1232
+ systemConfig: { 'dns.primary': '192.168.0.1', 'network.domain': 'homelab.local' },
1233
+ });
1234
+
1235
+ expect(context.systemConfig['dns.primary']).toBe('192.168.0.1');
1236
+ expect(context.systemConfig['network.domain']).toBe('homelab.local');
1237
+ });
1238
+
1239
+ test('should have empty system config when none provided', () => {
1240
+ const context = buildContextFromData('test-module');
1241
+
1242
+ expect(context.systemConfig).toEqual({});
1243
+ });
1244
+
1245
+ test('should return empty maps when no data provided', () => {
1246
+ const context = buildContextFromData('test-module');
1247
+
1248
+ // Should still have auto-derived inventory variables
1249
+ expect(context.selfConfig['inventory.ansible_user']).toBe('root');
1250
+ expect(context.selfConfig['inventory.groups']).toBe('test-module');
1251
+ expect(context.secrets).toEqual({});
1252
+ expect(context.capabilities).toEqual({});
1253
+ });
1254
+
1255
+ test('should auto-derive inventory variables in buildContextFromData', () => {
1256
+ const context = buildContextFromData('my-module', {
1257
+ selfConfig: { container_ip: '10.0.30.15/24', hostname: 'my-host' },
1258
+ });
1259
+
1260
+ expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.30.15');
1261
+ expect(context.selfConfig['inventory.ansible_user']).toBe('root');
1262
+ expect(context.selfConfig['inventory.groups']).toBe('my-module');
1263
+ });
1264
+ });
1265
+ });