@celilo/cli 0.3.30-alpha.0 → 0.4.0-alpha.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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +3 -3
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -4,16 +4,23 @@ import { getDb } from '../db/client';
4
4
  import type { DbClient } from '../db/client';
5
5
  import {
6
6
  capabilities,
7
+ containerServices,
8
+ machines,
7
9
  moduleConfigs,
10
+ moduleInfrastructure,
8
11
  modules,
9
12
  secrets,
10
13
  systemConfig,
11
14
  systemSecrets,
12
15
  } from '../db/schema';
13
16
  import { allocateResources, getAllocation } from '../ipam/allocator';
14
- import type { ModuleManifest } from '../manifest/schema';
17
+ import { type ModuleManifest, getDeclaredSystems } from '../manifest/schema';
15
18
  import { decryptSecret } from '../secrets/encryption';
16
19
  import { getOrCreateMasterKey } from '../secrets/master-key';
20
+ import { upsertModuleConfig } from '../services/module-config';
21
+ import { resolveComputedFields } from './computed/evaluate';
22
+ import { containsComputedMarker } from './computed/marker';
23
+ import { buildProviderLookup } from './computed/provider-lookup';
17
24
  import { applyDeclarativeDerivations } from './declarative-derivation';
18
25
  import { applyIndex, parsePath } from './parser';
19
26
  import type { ResolutionContext } from './types';
@@ -88,75 +95,6 @@ async function autoAssignFromWellKnown(
88
95
  return result;
89
96
  }
90
97
 
91
- /**
92
- * Determine if module needs IPAM auto-allocation
93
- * Policy function - checks manifest and config
94
- *
95
- * A module needs IPAM if:
96
- * 1. It declares vmid and target_ip variables (container-based), AND
97
- * 2. These values are not already set in module config
98
- *
99
- * @param manifest - Module manifest
100
- * @param selfConfig - Current module configuration
101
- * @returns true if IPAM allocation needed
102
- */
103
- function needsIpamAllocation(
104
- manifest: ModuleManifest,
105
- selfConfig: Record<string, string>,
106
- ): boolean {
107
- const variables = manifest.variables?.owns ?? [];
108
-
109
- const hasVmid = variables.some((v) => v.name === 'vmid');
110
- const hasTargetIp = variables.some((v) => v.name === 'target_ip');
111
-
112
- // Module must declare both vmid and target_ip to be container-based
113
- if (!hasVmid || !hasTargetIp) {
114
- return false;
115
- }
116
-
117
- // Check if already allocated (both must be present)
118
- const vmidSet = selfConfig.vmid !== undefined && selfConfig.vmid !== '';
119
- const ipSet = selfConfig.target_ip !== undefined && selfConfig.target_ip !== '';
120
-
121
- // Need allocation if either is missing
122
- return !vmidSet || !ipSet;
123
- }
124
-
125
- /**
126
- * Determine network zone for module
127
- * Policy function - extracts zone from manifest or config
128
- *
129
- * Zone determination priority:
130
- * 1. Explicit zone in module config (user override)
131
- * 2. VM resources zone in manifest
132
- * 3. Default to 'dmz'
133
- *
134
- * @param manifest - Module manifest
135
- * @param selfConfig - Current module configuration
136
- * @returns Network zone ('dmz', 'app', 'secure', or 'internal')
137
- */
138
- function determineModuleZone(
139
- manifest: ModuleManifest,
140
- selfConfig: Record<string, string>,
141
- ): 'dmz' | 'app' | 'secure' | 'internal' {
142
- // Priority 1: Explicit zone in config
143
- if (selfConfig.zone) {
144
- const zone = selfConfig.zone;
145
- if (zone === 'dmz' || zone === 'app' || zone === 'secure' || zone === 'internal') {
146
- return zone;
147
- }
148
- }
149
-
150
- // Priority 2: VM resources zone in manifest
151
- const vmZone = manifest.requires?.machine?.zone;
152
- if (vmZone === 'dmz' || vmZone === 'app' || vmZone === 'secure' || vmZone === 'internal') {
153
- return vmZone;
154
- }
155
-
156
- // Default: dmz (public-facing services)
157
- return 'dmz';
158
- }
159
-
160
98
  /**
161
99
  * Auto-derive inventory variables from module configuration
162
100
  * Policy function - derives values from existing config
@@ -204,17 +142,18 @@ function autoDeriveInventoryVariables(
204
142
  }
205
143
 
206
144
  /**
207
- * Recursively resolve "$self:key" strings in a capability data object
208
- * using the provider module's actual config values. Supports the
209
- * optional `[N]` array-index suffix (e.g. `$self:domains[0]`) so a
210
- * provider can declare computed aliases `primary_domain:
211
- * $self:domains[0]` without the framework storing the alias as a
212
- * separate value. Non-string values and strings that don't start
213
- * with "$self:" are returned unchanged.
145
+ * Recursively resolve "$self:key" and "$infra:<name>.<field>" strings in a
146
+ * capability data object using the provider module's actual config values and
147
+ * its deployed systems. `$self:` supports the optional `[N]` array-index suffix
148
+ * (e.g. `$self:domains[0]`). `$infra:` references a provider's deployed system
149
+ * by name (the replacement for the old `$self:target_ip` — see
150
+ * v2/MODULE_SYSTEMS_ADDRESSING.md). Non-string values and strings that don't
151
+ * start with one of those prefixes are returned unchanged.
214
152
  */
215
153
  function resolveSelfRefsInObject(
216
154
  obj: Record<string, unknown>,
217
155
  providerConfig: Record<string, unknown>,
156
+ providerSystems: Record<string, import('./types').InfraSystemFields> = {},
218
157
  ): Record<string, unknown> {
219
158
  const result: Record<string, unknown> = {};
220
159
  for (const [key, value] of Object.entries(obj)) {
@@ -222,8 +161,20 @@ function resolveSelfRefsInObject(
222
161
  const { name, index } = parsePath(value.slice(6));
223
162
  const resolved = applyIndex(providerConfig[name], index);
224
163
  result[key] = resolved !== undefined ? resolved : value;
164
+ } else if (typeof value === 'string' && value.startsWith('$infra:')) {
165
+ const path = value.slice('$infra:'.length);
166
+ const dot = path.indexOf('.');
167
+ const sysName = dot === -1 ? path : path.slice(0, dot);
168
+ const field = dot === -1 ? '' : path.slice(dot + 1);
169
+ const sys = providerSystems[sysName] as unknown as Record<string, unknown> | undefined;
170
+ const resolved = sys ? sys[field] : undefined;
171
+ result[key] = resolved !== undefined && resolved !== '' ? resolved : value;
225
172
  } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
226
- result[key] = resolveSelfRefsInObject(value as Record<string, unknown>, providerConfig);
173
+ result[key] = resolveSelfRefsInObject(
174
+ value as Record<string, unknown>,
175
+ providerConfig,
176
+ providerSystems,
177
+ );
227
178
  } else {
228
179
  result[key] = value;
229
180
  }
@@ -256,12 +207,13 @@ export async function buildResolutionContext(
256
207
 
257
208
  const selfConfig: Record<string, string> = {};
258
209
  for (const row of configRows) {
259
- // Handle complex types (stored in valueJson)
260
- if (row.valueJson) {
261
- selfConfig[row.key] = row.valueJson; // Store JSON string
262
- } else {
263
- selfConfig[row.key] = row.value;
264
- }
210
+ // Template variables substitute as strings (the YAML/HCL files
211
+ // this map drives expect string replacements). `value` is already
212
+ // the human-readable string form populated alongside valueJson;
213
+ // safe to use directly here. The typed path (numbers, booleans
214
+ // as their JS types) is parseStoredConfigValue, used by hook
215
+ // contexts.
216
+ selfConfig[row.key] = row.value;
265
217
  }
266
218
 
267
219
  // Well-known capability auto-assignment
@@ -272,24 +224,12 @@ export async function buildResolutionContext(
272
224
 
273
225
  // Store assigned values in module config
274
226
  if (assigned.hostname) {
275
- await db
276
- .insert(moduleConfigs)
277
- .values({ moduleId, key: 'hostname', value: assigned.hostname })
278
- .onConflictDoUpdate({
279
- target: [moduleConfigs.moduleId, moduleConfigs.key],
280
- set: { value: assigned.hostname },
281
- });
227
+ upsertModuleConfig(db, moduleId, 'hostname', assigned.hostname);
282
228
  selfConfig.hostname = assigned.hostname;
283
229
  }
284
230
 
285
231
  if (assigned.zone) {
286
- await db
287
- .insert(moduleConfigs)
288
- .values({ moduleId, key: 'zone', value: assigned.zone })
289
- .onConflictDoUpdate({
290
- target: [moduleConfigs.moduleId, moduleConfigs.key],
291
- set: { value: assigned.zone },
292
- });
232
+ upsertModuleConfig(db, moduleId, 'zone', assigned.zone);
293
233
  selfConfig.zone = assigned.zone;
294
234
  }
295
235
  }
@@ -301,19 +241,18 @@ export async function buildResolutionContext(
301
241
  const variables = manifest.variables?.owns ?? [];
302
242
 
303
243
  for (const variable of variables) {
304
- // Only apply if variable has a default and config doesn't already have it
244
+ // Only apply if variable has a default and config doesn't already have it.
245
+ // Pass the typed manifest default directly so valueJson preserves the
246
+ // declared shape — e.g. `default: 2222` (YAML int) round-trips as
247
+ // `number` not the string "2222". This is the root of Defect 1.
305
248
  if (variable.default !== undefined && !selfConfig[variable.name]) {
306
- const valueStr = String(variable.default);
307
-
308
- await db
309
- .insert(moduleConfigs)
310
- .values({ moduleId, key: variable.name, value: valueStr })
311
- .onConflictDoUpdate({
312
- target: [moduleConfigs.moduleId, moduleConfigs.key],
313
- set: { value: valueStr },
314
- });
315
-
316
- selfConfig[variable.name] = valueStr;
249
+ upsertModuleConfig(
250
+ db,
251
+ moduleId,
252
+ variable.name,
253
+ variable.default as string | number | boolean | unknown[] | Record<string, unknown>,
254
+ );
255
+ selfConfig[variable.name] = String(variable.default);
317
256
  }
318
257
  }
319
258
  }
@@ -339,72 +278,99 @@ export async function buildResolutionContext(
339
278
  for (const { manifestKey, configKey } of resourceMappings) {
340
279
  const value = machineResources[manifestKey];
341
280
 
342
- // Only apply if manifest specifies a value and config doesn't already have it
281
+ // Manifest fields are typed (cpu: number, storage: string, etc.).
282
+ // Pass them through unstringified so valueJson preserves the
283
+ // shape — see comment in the variable-defaults block above.
343
284
  if (value !== undefined && !selfConfig[configKey]) {
344
- const valueStr = String(value);
345
-
346
- await db
347
- .insert(moduleConfigs)
348
- .values({ moduleId, key: configKey, value: valueStr })
349
- .onConflictDoUpdate({
350
- target: [moduleConfigs.moduleId, moduleConfigs.key],
351
- set: { value: valueStr },
352
- });
353
-
354
- selfConfig[configKey] = valueStr;
285
+ upsertModuleConfig(
286
+ db,
287
+ moduleId,
288
+ configKey,
289
+ value as string | number | boolean | unknown[] | Record<string, unknown>,
290
+ );
291
+ selfConfig[configKey] = String(value);
355
292
  }
356
293
  }
357
294
  }
358
295
  }
359
296
 
360
- // IPAM auto-allocation
361
- // If module declares vmid/target_ip but they're not configured, allocate automatically
297
+ // Deployed-system recording (v2/MODULE_SYSTEMS_ADDRESSING.md).
298
+ // Record this module's system(s) into module_systems at generate time, so
299
+ // `$infra:<name>.…` resolves in templates. The address comes from:
300
+ // - machine pool → the machine's own IP; no vmid (no container).
301
+ // - container_service → IPAM-allocated zone IP + vmid (proxmox only).
302
+ // - DigitalOcean → assigned by terraform; recorded at deploy from
303
+ // outputs (resolveInfrastructureVariables), not here.
304
+ // This is the single place generate-time addresses are recorded — `target_ip`
305
+ // no longer lives in module_configs.
362
306
  if (module?.manifestData) {
363
307
  const manifest = module.manifestData as ModuleManifest;
364
-
365
- if (needsIpamAllocation(manifest, selfConfig)) {
366
- const zone = determineModuleZone(manifest, selfConfig);
367
-
368
- // Use transaction to ensure atomicity
369
- await db.transaction(async (tx) => {
370
- // Check if allocation already exists in ipAllocations table
371
- const existing = await getAllocation(moduleId, tx);
372
-
373
- let vmid: number;
374
- let allocatedIp: string;
375
-
376
- if (existing) {
377
- // Use existing allocation
378
- vmid = existing.vmid;
379
- allocatedIp = existing.containerIp;
380
- } else {
381
- // Allocate new resources (persists to ipAllocations)
382
- const allocation = await allocateResources(moduleId, zone, tx);
383
- vmid = allocation.vmid;
384
- allocatedIp = allocation.containerIp;
308
+ const declared = getDeclaredSystems(manifest);
309
+ const hostname = selfConfig.hostname;
310
+
311
+ if (declared.length > 0 && hostname) {
312
+ // Single-system transition: every current module declares one system.
313
+ const decl = declared[0];
314
+ const zone = decl.resources.zone;
315
+ const infraSelection = db
316
+ .select()
317
+ .from(moduleInfrastructure)
318
+ .where(eq(moduleInfrastructure.moduleId, moduleId))
319
+ .get();
320
+ const { upsertDeployedSystem } = await import('../services/deployed-systems');
321
+
322
+ if (infraSelection?.infrastructureType === 'machine') {
323
+ if (!infraSelection.machineId) {
324
+ throw new Error(
325
+ `Module '${moduleId}' is assigned to a machine but module_infrastructure.machineId is null`,
326
+ );
385
327
  }
386
-
387
- // Ensure values are in module config (upsert to handle existing keys)
388
- await tx
389
- .insert(moduleConfigs)
390
- .values({ moduleId, key: 'vmid', value: String(vmid) })
391
- .onConflictDoUpdate({
392
- target: [moduleConfigs.moduleId, moduleConfigs.key],
393
- set: { value: String(vmid) },
394
- });
395
-
396
- await tx
397
- .insert(moduleConfigs)
398
- .values({ moduleId, key: 'target_ip', value: allocatedIp })
399
- .onConflictDoUpdate({
400
- target: [moduleConfigs.moduleId, moduleConfigs.key],
401
- set: { value: allocatedIp },
328
+ const machineRow = db
329
+ .select()
330
+ .from(machines)
331
+ .where(eq(machines.id, infraSelection.machineId))
332
+ .get();
333
+ if (!machineRow?.ipAddress) {
334
+ throw new Error(
335
+ `Machine '${infraSelection.machineId}' for module '${moduleId}' has no ipAddress`,
336
+ );
337
+ }
338
+ upsertDeployedSystem(db, moduleId, {
339
+ name: decl.name,
340
+ hostname,
341
+ ipv4Address: machineRow.ipAddress,
342
+ zone,
343
+ infraType: 'machine',
344
+ machineId: infraSelection.machineId,
345
+ });
346
+ } else if (infraSelection?.infrastructureType === 'container_service') {
347
+ // Only proxmox needs IPAM; DigitalOcean's IP comes from terraform
348
+ // outputs at deploy. Determine the provider from the selected service.
349
+ let isProxmox = false;
350
+ if (infraSelection.serviceId) {
351
+ const svc = db
352
+ .select()
353
+ .from(containerServices)
354
+ .where(eq(containerServices.id, infraSelection.serviceId))
355
+ .get();
356
+ isProxmox = svc?.providerName === 'proxmox';
357
+ }
358
+ if (isProxmox && zone !== 'external') {
359
+ await db.transaction(async (tx) => {
360
+ const existing = await getAllocation(moduleId, tx);
361
+ const allocation = existing ?? (await allocateResources(moduleId, zone, tx));
362
+ upsertDeployedSystem(tx as unknown as DbClient, moduleId, {
363
+ name: decl.name,
364
+ hostname,
365
+ ipv4Address: allocation.containerIp,
366
+ zone,
367
+ infraType: 'container_service',
368
+ serviceId: infraSelection.serviceId,
369
+ vmid: allocation.vmid,
370
+ });
402
371
  });
403
-
404
- // Update selfConfig with allocated values
405
- selfConfig.vmid = String(vmid);
406
- selfConfig.target_ip = allocatedIp;
407
- });
372
+ }
373
+ }
408
374
  }
409
375
  }
410
376
 
@@ -463,10 +429,38 @@ export async function buildResolutionContext(
463
429
  for (const c of providerConfigs) {
464
430
  providerConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
465
431
  }
466
- capabilitiesMap[row.capabilityName] = resolveSelfRefsInObject(
432
+ // The provider's deployed systems, so capability data can reference
433
+ // `$infra:<name>.ipv4_address` (replacing the old `$self:target_ip`).
434
+ // CIDR uses the default prefix here (capability data reads bare ipv4/host,
435
+ // not cidr); the full systemConfig isn't built yet at this point.
436
+ const { buildInfraSystemsMap } = await import('../services/deployed-systems');
437
+ const providerSystems = buildInfraSystemsMap(row.moduleId, db, {});
438
+ let resolved = resolveSelfRefsInObject(
467
439
  data as Record<string, unknown>,
468
440
  providerConfigMap,
441
+ providerSystems,
469
442
  );
443
+
444
+ // Evaluate computed-field markers (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md
445
+ // D1) in the PROVIDER's context, so consumers — both `$capability:` template
446
+ // refs AND `variables.imports` — see the real value (e.g. domain_list as an
447
+ // array) rather than the raw marker. Only build the provider lookup when a
448
+ // marker is actually present (the common case has none). Best-effort: a
449
+ // single provider's eval failure must not break an unrelated module's
450
+ // context build, so we log and leave the original data on error.
451
+ if (containsComputedMarker(resolved)) {
452
+ try {
453
+ const lookup = await buildProviderLookup(row.moduleId, db);
454
+ resolved = resolveComputedFields(resolved, lookup) as Record<string, unknown>;
455
+ } catch (err) {
456
+ console.error(
457
+ `Failed to evaluate computed fields for capability '${row.capabilityName}' (provider '${row.moduleId}'):`,
458
+ err,
459
+ );
460
+ }
461
+ }
462
+
463
+ capabilitiesMap[row.capabilityName] = resolved;
470
464
  }
471
465
 
472
466
  // Fetch system configuration (for $system: variables)
@@ -508,6 +502,16 @@ export async function buildResolutionContext(
508
502
  // System secrets are optional
509
503
  }
510
504
 
505
+ // Build the `$infra:<name>.<field>` lookup from the systems recorded above
506
+ // (proxmox/machine at generate; DigitalOcean gets refreshed at deploy from
507
+ // terraform outputs). This is what lets `$infra:` resolve in templates.
508
+ // v2/MODULE_SYSTEMS_ADDRESSING.md.
509
+ let infraSystemsMap: Record<string, import('./types').InfraSystemFields> = {};
510
+ if (module?.manifestData) {
511
+ const { buildInfraSystemsMap } = await import('../services/deployed-systems');
512
+ infraSystemsMap = buildInfraSystemsMap(moduleId, db, systemConfigMap);
513
+ }
514
+
511
515
  // Zone-based networking
512
516
  // Auto-derive network config from zone (gateway, vlan, subnet, bridge)
513
517
  if (module?.manifestData) {
@@ -516,13 +520,7 @@ export async function buildResolutionContext(
516
520
 
517
521
  // If zone from manifest but not in selfConfig, store it as first-class config
518
522
  if (zone && !selfConfig.zone) {
519
- await db
520
- .insert(moduleConfigs)
521
- .values({ moduleId, key: 'zone', value: zone })
522
- .onConflictDoUpdate({
523
- target: [moduleConfigs.moduleId, moduleConfigs.key],
524
- set: { value: zone },
525
- });
523
+ upsertModuleConfig(db, moduleId, 'zone', zone);
526
524
  selfConfig.zone = zone;
527
525
  }
528
526
 
@@ -537,15 +535,25 @@ export async function buildResolutionContext(
537
535
  const value = systemConfigMap[systemConfigKey];
538
536
 
539
537
  if (value) {
540
- await db
541
- .insert(moduleConfigs)
542
- .values({ moduleId, key: field, value })
543
- .onConflictDoUpdate({
544
- target: [moduleConfigs.moduleId, moduleConfigs.key],
545
- set: { value },
546
- });
547
-
548
- selfConfig[field] = value;
538
+ // system_config stores everything as strings (no
539
+ // valueJson companion column there — that's a sister
540
+ // type-fidelity gap, tracked separately). Use a
541
+ // try-JSON-parse heuristic at the boundary to recover
542
+ // primitive types: "20" → 20 (vlan tags are numbers),
543
+ // "true" true, IPs/hostnames fall through to string.
544
+ // Once system_config gets the same treatment as
545
+ // module_configs this coercion becomes redundant.
546
+ const coerced = ((): string | number | boolean => {
547
+ try {
548
+ const parsed = JSON.parse(value);
549
+ if (typeof parsed === 'number' || typeof parsed === 'boolean') return parsed;
550
+ } catch {
551
+ // not JSON — fall through
552
+ }
553
+ return value;
554
+ })();
555
+ upsertModuleConfig(db, moduleId, field, coerced);
556
+ selfConfig[field] = String(coerced);
549
557
  }
550
558
  }
551
559
  }
@@ -563,6 +571,7 @@ export async function buildResolutionContext(
563
571
  systemSecrets: systemSecretsMap,
564
572
  secrets: secretsMap,
565
573
  capabilities: capabilitiesMap,
574
+ systems: infraSystemsMap,
566
575
  };
567
576
 
568
577
  // Snapshot values before derivation so we can detect changes
@@ -603,14 +612,7 @@ export async function buildResolutionContext(
603
612
  }
604
613
 
605
614
  if (isNew || isChanged) {
606
- await db
607
- .insert(moduleConfigs)
608
- .values({ moduleId, key, value })
609
- .onConflictDoUpdate({
610
- target: [moduleConfigs.moduleId, moduleConfigs.key],
611
- set: { value },
612
- });
613
-
615
+ upsertModuleConfig(db, moduleId, key, value);
614
616
  selfConfig[key] = value;
615
617
  }
616
618
  }
@@ -630,6 +632,7 @@ export async function buildResolutionContext(
630
632
  systemSecrets: systemSecretsMap,
631
633
  secrets: secretsMap,
632
634
  capabilities: capabilitiesMap,
635
+ systems: infraSystemsMap,
633
636
  };
634
637
  }
635
638
 
@@ -650,6 +653,7 @@ export function buildContextFromData(
650
653
  systemSecrets?: Record<string, string>;
651
654
  secrets?: Record<string, string>;
652
655
  capabilities?: Record<string, Record<string, unknown>>;
656
+ systems?: Record<string, import('./types').InfraSystemFields>;
653
657
  } = {},
654
658
  ): ResolutionContext {
655
659
  const selfConfig = data.selfConfig ?? {};
@@ -668,5 +672,6 @@ export function buildContextFromData(
668
672
  systemSecrets: data.systemSecrets ?? {},
669
673
  secrets: data.secrets ?? {},
670
674
  capabilities: data.capabilities ?? {},
675
+ systems: data.systems ?? {},
671
676
  };
672
677
  }
@@ -21,7 +21,7 @@ import type { VariableReference } from './types';
21
21
  * computed alias for the canonical default).
22
22
  */
23
23
  const VARIABLE_PATTERN =
24
- /\$\{(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)\}|\$(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/g;
24
+ /\$\{(self|system|system_secret|secret|capability|infra):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)\}|\$(self|system|system_secret|secret|capability|infra):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/g;
25
25
 
26
26
  /**
27
27
  * Parse template content to extract variable references
@@ -64,7 +64,7 @@ export function hasVariables(content: string): boolean {
64
64
  // Create new regex without state to avoid issues with global flag
65
65
  // Matches both ${type:path} and $type:path (with optional [N] index)
66
66
  const pattern =
67
- /\$\{?(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/;
67
+ /\$\{?(self|system|system_secret|secret|capability|infra):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/;
68
68
  return pattern.test(content);
69
69
  }
70
70
 
@@ -78,7 +78,7 @@ export function isValidVariableFormat(variable: string): boolean {
78
78
  // Path must start with letter or underscore, not digit
79
79
  // Accepts both ${type:path} and $type:path with optional [N] indexing
80
80
  const pattern =
81
- /^(?:\$\{(self|system|system_secret|secret|capability):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?\}|\$(self|system|system_secret|secret|capability):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)$/;
81
+ /^(?:\$\{(self|system|system_secret|secret|capability|infra):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?\}|\$(self|system|system_secret|secret|capability|infra):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)$/;
82
82
  return pattern.test(variable);
83
83
  }
84
84
 
@@ -82,6 +82,67 @@ describe('resolveVariable', () => {
82
82
  }
83
83
  });
84
84
 
85
+ describe('$infra: deployed-system selector', () => {
86
+ const infraContext = () =>
87
+ createContext({
88
+ systems: {
89
+ main: {
90
+ name: 'main',
91
+ hostname: 'homebridge',
92
+ ipv4_address: '10.0.20.20',
93
+ zone: 'app',
94
+ cidr: '10.0.20.20/24',
95
+ vmid: '2100',
96
+ },
97
+ },
98
+ });
99
+
100
+ test('resolves $infra:<name>.ipv4_address', async () => {
101
+ const result = await resolveVariable(
102
+ { type: 'infra', path: 'main.ipv4_address', raw: '$infra:main.ipv4_address' },
103
+ infraContext(),
104
+ mockDb,
105
+ );
106
+ expect(result.success).toBe(true);
107
+ if (result.success) expect(result.value).toBe('10.0.20.20');
108
+ });
109
+
110
+ test('resolves $infra:<name>.cidr and .vmid', async () => {
111
+ const ctx = infraContext();
112
+ const cidr = await resolveVariable(
113
+ { type: 'infra', path: 'main.cidr', raw: '$infra:main.cidr' },
114
+ ctx,
115
+ mockDb,
116
+ );
117
+ const vmid = await resolveVariable(
118
+ { type: 'infra', path: 'main.vmid', raw: '$infra:main.vmid' },
119
+ ctx,
120
+ mockDb,
121
+ );
122
+ expect(cidr.success && cidr.value).toBe('10.0.20.20/24');
123
+ expect(vmid.success && vmid.value).toBe('2100');
124
+ });
125
+
126
+ test('fails clearly for an unknown system name', async () => {
127
+ const result = await resolveVariable(
128
+ { type: 'infra', path: 'db.ipv4_address', raw: '$infra:db.ipv4_address' },
129
+ infraContext(),
130
+ mockDb,
131
+ );
132
+ expect(result.success).toBe(false);
133
+ if (!result.success) expect(result.error).toContain("No deployed system named 'db'");
134
+ });
135
+
136
+ test('fails when no systems recorded (API-only / pre-deploy)', async () => {
137
+ const result = await resolveVariable(
138
+ { type: 'infra', path: 'main.ipv4_address', raw: '$infra:main.ipv4_address' },
139
+ createContext(),
140
+ mockDb,
141
+ );
142
+ expect(result.success).toBe(false);
143
+ });
144
+ });
145
+
85
146
  test('should resolve system variable', async () => {
86
147
  const variable: VariableReference = {
87
148
  type: 'system',