@celilo/cli 1.5.0 → 1.7.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 (88) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/MODULE_PRIMITIVES.md +25 -7
  4. package/drizzle/0026_module_integrity_version.sql +20 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +187 -2
  11. package/src/capabilities/validation.ts +53 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-audit.ts +5 -2
  14. package/src/cli/commands/module-remove.ts +34 -2
  15. package/src/cli/commands/module-update.test.ts +238 -3
  16. package/src/cli/commands/module-update.ts +206 -12
  17. package/src/cli/commands/module-verify.ts +77 -13
  18. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  19. package/src/cli/commands/service-set-credentials.ts +115 -0
  20. package/src/cli/commands/system-audit.ts +17 -0
  21. package/src/cli/commands/system-doctor.ts +78 -2
  22. package/src/cli/commands/system-migrate.ts +6 -4
  23. package/src/cli/commands/system-update.ts +33 -3
  24. package/src/cli/completion.ts +16 -1
  25. package/src/cli/index.ts +11 -2
  26. package/src/cli/tui/audit-state.ts +11 -3
  27. package/src/cli/tui/audit-tui.tsx +10 -4
  28. package/src/cli/tui/icons.ts +9 -2
  29. package/src/cli/tui/modals/analyzing.tsx +3 -0
  30. package/src/db/client.ts +10 -8
  31. package/src/db/migrate.test.ts +147 -0
  32. package/src/db/migrate.ts +69 -1
  33. package/src/db/schema.ts +5 -0
  34. package/src/hooks/capability-loader.test.ts +55 -0
  35. package/src/hooks/capability-loader.ts +16 -1
  36. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  37. package/src/manifest/schema.ts +23 -0
  38. package/src/module/import.ts +56 -40
  39. package/src/module/packaging/audit.ts +103 -28
  40. package/src/module/packaging/build.ts +12 -53
  41. package/src/module/packaging/classify-module-path.test.ts +104 -0
  42. package/src/module/packaging/extract.ts +31 -3
  43. package/src/module/packaging/generated-plane.test.ts +79 -0
  44. package/src/module/packaging/generated-plane.ts +134 -0
  45. package/src/module/packaging/host-plane.test.ts +132 -0
  46. package/src/module/packaging/host-plane.ts +135 -0
  47. package/src/module/packaging/package-rules.ts +62 -0
  48. package/src/policy/module-business-baseline.ts +0 -11
  49. package/src/services/alerting/monitors.ts +54 -2
  50. package/src/services/alerting/sweep-runner.ts +38 -1
  51. package/src/services/audit/cli-version.test.ts +6 -2
  52. package/src/services/audit/cli-version.ts +20 -6
  53. package/src/services/audit/detect-without-converge.test.ts +91 -0
  54. package/src/services/audit/detect-without-converge.ts +81 -0
  55. package/src/services/audit/disk-space.test.ts +5 -2
  56. package/src/services/audit/disk-space.ts +5 -3
  57. package/src/services/audit/health.test.ts +39 -0
  58. package/src/services/audit/index.test.ts +7 -1
  59. package/src/services/audit/index.ts +12 -0
  60. package/src/services/audit/module-integrity.test.ts +146 -0
  61. package/src/services/audit/module-integrity.ts +113 -0
  62. package/src/services/audit/module-versions.ts +4 -1
  63. package/src/services/audit/schema.test.ts +7 -2
  64. package/src/services/audit/schema.ts +19 -1
  65. package/src/services/audit/terraform-plan.ts +17 -2
  66. package/src/services/audit/types.test.ts +29 -0
  67. package/src/services/audit/types.ts +30 -4
  68. package/src/services/consumer-cleanup.ts +5 -3
  69. package/src/services/container-service.test.ts +34 -0
  70. package/src/services/container-service.ts +44 -0
  71. package/src/services/deployed-systems.test.ts +101 -0
  72. package/src/services/deployed-systems.ts +43 -11
  73. package/src/services/dns-provider-backfill.ts +30 -0
  74. package/src/services/fleet-checks.test.ts +26 -0
  75. package/src/services/fleet-checks.ts +11 -1
  76. package/src/services/module-deploy.ts +109 -41
  77. package/src/services/provider-arrival.test.ts +241 -0
  78. package/src/services/provider-arrival.ts +213 -0
  79. package/src/services/restore-from-file.ts +4 -0
  80. package/src/services/update/orchestrator.test.ts +2 -0
  81. package/src/templates/generator.test.ts +35 -0
  82. package/src/templates/generator.ts +29 -1
  83. package/src/variables/context.test.ts +63 -0
  84. package/src/variables/context.ts +10 -2
  85. package/src/variables/declarative-derivation.test.ts +47 -8
  86. package/src/variables/declarative-derivation.ts +6 -4
  87. package/src/services/public-web-republish.test.ts +0 -189
  88. package/src/services/public-web-republish.ts +0 -84
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Re-running a provider's consumers when that provider arrives
3
+ * (openspec/changes/capability-owned-tables, stage 1).
4
+ *
5
+ * celilo handled one side of the capability relationship generically and the
6
+ * other by hand. A consumer leaving goes through `consumer-cleanup.ts`, which
7
+ * finds every provider it used and calls each one's `on_consumer_removed` with
8
+ * no capability names in the dispatch. A provider ARRIVING had three
9
+ * hand-written pieces covering two capabilities, and `firewall` — which three
10
+ * modules provide — had none at all (celilo#1011).
11
+ *
12
+ * This is the mirror of `consumer-cleanup.ts`, and it is deliberately built the
13
+ * same way, down to sharing that file's `PRE_DEPLOY_STATES`.
14
+ *
15
+ * PULL, NOT PUSH (design D7). When a provider arrives, celilo re-runs the
16
+ * CONSUMERS' `on_install` and lets each consumer re-register through the path
17
+ * that worked the first time. It does NOT replay history into the provider by
18
+ * calling the provider's own hooks on a consumer's behalf. Two of the three
19
+ * pieces this replaces pushed, and one of them had already drifted:
20
+ * `backfillWebRouteDns` cannot go through `on_system_event`, because that hook
21
+ * concatenates `<hostname>.<zone>` and would corrupt an already-qualified name,
22
+ * so it reaches past the hook into `registerRecord` and now differs from the
23
+ * live path in ways nothing checks.
24
+ *
25
+ * Split plan/execute (Rule 10.4): which consumers, and why one is skipped, is
26
+ * pure and worth testing on its own. The rest is "run each hook and record what
27
+ * happened".
28
+ */
29
+
30
+ import { eq } from 'drizzle-orm';
31
+ import type { DbClient } from '../db/client';
32
+ import { capabilities, modules } from '../db/schema';
33
+ import { createConsoleLogger } from '../hooks/logger';
34
+ import { type RunNamedHookResult, runNamedHook } from '../hooks/run-named-hook';
35
+ import type { HookLogger } from '../hooks/types';
36
+ import { type ModuleManifest, ModuleManifestSchema } from '../manifest/schema';
37
+ import { PRE_DEPLOY_STATES } from './consumer-cleanup';
38
+
39
+ export type BackfillSkipReason = 'paused' | 'not-deployed';
40
+
41
+ export interface BackfillTarget {
42
+ /** The consumer module to re-run. */
43
+ consumerId: string;
44
+ /** Which of the arriving provider's capabilities it consumes — for the log line. */
45
+ capabilityNames: string[];
46
+ /** Set when the consumer will NOT be re-run. */
47
+ skip?: BackfillSkipReason;
48
+ }
49
+
50
+ export interface ConsumerCandidate {
51
+ moduleId: string;
52
+ manifest: ModuleManifest;
53
+ state: string;
54
+ }
55
+
56
+ /**
57
+ * Which consumers must be re-run now that `provider` is here.
58
+ *
59
+ * Pure. Sorted by consumer id so dispatch order is deterministic and a failure
60
+ * is reproducible.
61
+ *
62
+ * `requires` AND `optional`, which is the edge `remove-guard.ts` already counts
63
+ * and the one `optional` was added to express — "this hook will use monitoring
64
+ * if it is there" describes a module that has something to gain the moment the
65
+ * provider appears.
66
+ *
67
+ * The provider is never its own consumer. A module that both provides and
68
+ * requires a capability would otherwise have its `on_install` run a second time
69
+ * inside its own deploy, which just finished running it.
70
+ */
71
+ export function planProviderBackfill(
72
+ provider: string,
73
+ providedCapabilities: Iterable<string>,
74
+ candidates: ConsumerCandidate[],
75
+ ): BackfillTarget[] {
76
+ const provided = new Set(providedCapabilities);
77
+ if (provided.size === 0) return [];
78
+
79
+ const targets: BackfillTarget[] = [];
80
+ for (const candidate of candidates) {
81
+ if (candidate.moduleId === provider) continue;
82
+
83
+ const consumed = new Set([
84
+ ...(candidate.manifest.requires?.capabilities ?? []).map((c) => c.name),
85
+ ...(candidate.manifest.optional?.capabilities ?? []).map((c) => c.name),
86
+ ]);
87
+ const capabilityNames = [...consumed].filter((name) => provided.has(name)).sort();
88
+ if (capabilityNames.length === 0) continue;
89
+
90
+ if (candidate.state === 'PAUSED') {
91
+ targets.push({ consumerId: candidate.moduleId, capabilityNames, skip: 'paused' });
92
+ continue;
93
+ }
94
+ if (PRE_DEPLOY_STATES.has(candidate.state)) {
95
+ targets.push({ consumerId: candidate.moduleId, capabilityNames, skip: 'not-deployed' });
96
+ continue;
97
+ }
98
+ targets.push({ consumerId: candidate.moduleId, capabilityNames });
99
+ }
100
+
101
+ return targets.sort((a, b) => a.consumerId.localeCompare(b.consumerId));
102
+ }
103
+
104
+ /**
105
+ * Read the plan's inputs out of the DB.
106
+ *
107
+ * A module's manifest lives in `modules.manifestData`; a manifest that no
108
+ * longer parses is skipped rather than fatal, matching `module-remove.ts`'s
109
+ * dependency scan — a provider deploy must not die on some unrelated module's
110
+ * bad row.
111
+ */
112
+ export function loadProviderBackfillPlan(provider: string, db: DbClient): BackfillTarget[] {
113
+ const provided = db
114
+ .select({ capabilityName: capabilities.capabilityName })
115
+ .from(capabilities)
116
+ .where(eq(capabilities.moduleId, provider))
117
+ .all()
118
+ .map((row) => row.capabilityName);
119
+ if (provided.length === 0) return [];
120
+
121
+ const candidates: ConsumerCandidate[] = [];
122
+ for (const row of db.select().from(modules).all()) {
123
+ const parsed = ModuleManifestSchema.safeParse(row.manifestData);
124
+ if (!parsed.success) continue;
125
+ candidates.push({ moduleId: row.id, manifest: parsed.data, state: row.state });
126
+ }
127
+
128
+ return planProviderBackfill(provider, provided, candidates);
129
+ }
130
+
131
+ export interface BackfillFailure {
132
+ consumerId: string;
133
+ error: string;
134
+ }
135
+
136
+ export interface ProviderBackfillResult {
137
+ /** Consumers whose `on_install` was re-run successfully. */
138
+ rerun: string[];
139
+ skipped: Array<{ consumerId: string; reason: BackfillSkipReason }>;
140
+ failures: BackfillFailure[];
141
+ }
142
+
143
+ /**
144
+ * Run the plan. Sequential, deterministic, and it CONTINUES PAST A FAILURE:
145
+ * every consumer is attempted even if an earlier one threw, because stopping
146
+ * early would leave the remaining consumers unregistered against a provider
147
+ * that is now live, which is the gap this exists to close.
148
+ *
149
+ * A failure never fails the provider's own deploy. The provider deployed fine;
150
+ * what failed is one consumer's re-registration, and the honest report is a
151
+ * named consumer with its error and the command that retries it. The caller
152
+ * surfaces that — silence here would report a clean deploy over a fleet where
153
+ * some consumers never re-registered.
154
+ */
155
+ export async function runProviderBackfill(
156
+ provider: string,
157
+ plan: BackfillTarget[],
158
+ db: DbClient,
159
+ logger: HookLogger,
160
+ /** Injectable hook runner — tests drive the failure paths through it. */
161
+ runHook: (consumerId: string) => Promise<RunNamedHookResult> = (consumerId) =>
162
+ runNamedHook(consumerId, 'on_install', db, createConsoleLogger(consumerId, 'on_install'), {}),
163
+ ): Promise<ProviderBackfillResult> {
164
+ const result: ProviderBackfillResult = { rerun: [], skipped: [], failures: [] };
165
+
166
+ for (const target of plan) {
167
+ if (target.skip) {
168
+ result.skipped.push({ consumerId: target.consumerId, reason: target.skip });
169
+ // Reported, not silent. A paused consumer rebinds on its next deploy, so
170
+ // it is genuinely fine — but the operator should know which modules are
171
+ // NOT yet talking to the provider that just arrived.
172
+ if (target.skip === 'paused') {
173
+ logger.warn(
174
+ `${target.consumerId} is paused, so it was not re-run against the new '${provider}' — it consumes ${target.capabilityNames.join(', ')} and will rebind on its next deploy.`,
175
+ );
176
+ }
177
+ continue;
178
+ }
179
+
180
+ const hookResult = await runHook(target.consumerId);
181
+
182
+ // A module paused BETWEEN the plan and this dispatch. `runNamedHook`
183
+ // reports that as success, so taking it at face value would log a
184
+ // re-registration that never happened.
185
+ if (hookResult.skippedPaused) {
186
+ result.skipped.push({ consumerId: target.consumerId, reason: 'paused' });
187
+ logger.warn(
188
+ `${target.consumerId} was paused while '${provider}' was arriving, so it was not re-run — it will rebind on its next deploy.`,
189
+ );
190
+ continue;
191
+ }
192
+
193
+ if (hookResult.success) {
194
+ // `notDefined` means the consumer declares no `on_install` — there is
195
+ // nothing to re-run, and the dispatch succeeding is the right answer.
196
+ if (!hookResult.notDefined) {
197
+ result.rerun.push(target.consumerId);
198
+ logger.info(
199
+ `${target.consumerId} re-registered with '${provider}' (${target.capabilityNames.join(', ')})`,
200
+ );
201
+ }
202
+ continue;
203
+ }
204
+
205
+ const error = hookResult.error ?? 'unknown error';
206
+ result.failures.push({ consumerId: target.consumerId, error });
207
+ logger.warn(
208
+ `${target.consumerId} failed to re-register with '${provider}': ${error}. Run \`celilo module deploy ${target.consumerId}\` to retry.`,
209
+ );
210
+ }
211
+
212
+ return result;
213
+ }
@@ -401,8 +401,12 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
401
401
  mkdirSync(moduleStorageBase, { recursive: true });
402
402
  for (const entry of readdirSync(stagedModuleSrc, { withFileTypes: true })) {
403
403
  if (!entry.isDirectory()) continue;
404
+ // `force` explicit: this merges over an EXISTING module dir and the
405
+ // restored bytes must win. bun does not honour the documented default
406
+ // on every cp path (see generator.ts#copyAnsibleRoleFilesDirs).
404
407
  cpSync(join(stagedModuleSrc, entry.name), join(moduleStorageBase, entry.name), {
405
408
  recursive: true,
409
+ force: true,
406
410
  });
407
411
  moduleSourcesApplied += 1;
408
412
  }
@@ -76,6 +76,8 @@ const cleanAudit: AuditDeps = {
76
76
  terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
77
77
  moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) },
78
78
  moduleConfigs: { modules: [] },
79
+ moduleIntegrity: { results: [] },
80
+ detectWithoutConverge: { modules: [] },
79
81
  health: { results: [] },
80
82
  backups: { modules: [] },
81
83
  abandonedOperations: { records: [] },
@@ -13,6 +13,7 @@ import {
13
13
  getOutputFilename,
14
14
  injectProxmoxDns,
15
15
  isTemplateFile,
16
+ omitUntaggedProxmoxVlan,
16
17
  readTemplateFiles,
17
18
  storageFromTfState,
18
19
  targetNodeFromTfState,
@@ -770,6 +771,40 @@ resource "proxmox_lxc" "container" {
770
771
  );
771
772
  });
772
773
  });
774
+
775
+ describe('omitUntaggedProxmoxVlan', () => {
776
+ const LXC = [
777
+ 'resource "proxmox_lxc" "dns" {',
778
+ ' target_node = "$self:target_node"',
779
+ ' network {',
780
+ ' bridge = "$self:bridge"',
781
+ ' tag = $self:vlan',
782
+ ' ip = "$self:target_ip"',
783
+ ' }',
784
+ '}',
785
+ ].join('\n');
786
+
787
+ test('omits the optional VLAN tag for an untagged zone', () => {
788
+ const out = omitUntaggedProxmoxVlan(LXC, false);
789
+ expect(out).not.toContain('$self:vlan');
790
+ expect(out).toContain(' bridge = "$self:bridge"');
791
+ expect(out).toContain(' ip = "$self:target_ip"');
792
+ });
793
+
794
+ test('retains the VLAN tag when the zone has a VLAN ID', () => {
795
+ expect(omitUntaggedProxmoxVlan(LXC, true)).toBe(LXC);
796
+ });
797
+
798
+ test('preserves literal and non-self VLAN tag expressions', () => {
799
+ const authored = ['tag = 30', 'tag = var.vlan', 'tag = $system:network.dmz.vlan'].join('\n');
800
+ expect(omitUntaggedProxmoxVlan(authored, false)).toBe(authored);
801
+ });
802
+
803
+ test('removes an indented tag line with a trailing comment', () => {
804
+ const content = ' tag = $self:vlan # optional VLAN\r\n ip = "dhcp"\r\n';
805
+ expect(omitUntaggedProxmoxVlan(content, false)).toBe(' ip = "dhcp"\r\n');
806
+ });
807
+ });
773
808
  });
774
809
 
775
810
  describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placement record)", () => {
@@ -219,6 +219,31 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
219
219
  });
220
220
  }
221
221
 
222
+ /**
223
+ * Omit the optional Proxmox network VLAN tag when a zone is explicitly
224
+ * untagged.
225
+ *
226
+ * Celilo represents an untagged zone by leaving `network.<zone>.vlan` unset.
227
+ * Module templates historically rendered `tag = $self:vlan` unconditionally,
228
+ * which makes variable resolution fail even though the Proxmox provider treats
229
+ * an omitted `tag` attribute as the correct untagged configuration. Removing
230
+ * only that exact framework variable line preserves literal tags and other
231
+ * tag expressions authored by a module.
232
+ *
233
+ * Policy function (Rule 10.1) - pure string transformation, no I/O.
234
+ *
235
+ * @param content - Raw terraform template content (pre variable-resolution)
236
+ * @param hasVlan - Whether `$self:vlan` resolves to a configured VLAN ID
237
+ * @returns Content with the optional tag line omitted for an untagged zone
238
+ */
239
+ export function omitUntaggedProxmoxVlan(content: string, hasVlan: boolean): string {
240
+ if (hasVlan) {
241
+ return content;
242
+ }
243
+
244
+ return content.replace(/^[ \t]*tag[ \t]*=[ \t]*\$self:vlan[ \t]*(?:#.*)?(?:\r?\n|$)/gm, '');
245
+ }
246
+
222
247
  /**
223
248
  * Extract the node a Proxmox guest is deployed on from a parsed terraform state
224
249
  * object — an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu). Pure logic (Rule 10),
@@ -1266,7 +1291,10 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
1266
1291
  // every proxmox_lxc resource before resolution (terraform files only).
1267
1292
  const content =
1268
1293
  !isAnsibleTemplate && template.targetPath.endsWith('.tf')
1269
- ? injectProxmoxDns(template.content, Boolean(context.selfConfig.lxc_nameserver))
1294
+ ? injectProxmoxDns(
1295
+ omitUntaggedProxmoxVlan(template.content, context.selfConfig.vlan !== undefined),
1296
+ Boolean(context.selfConfig.lxc_nameserver),
1297
+ )
1270
1298
  : template.content;
1271
1299
  const result = isAnsibleTemplate
1272
1300
  ? await convertSecretsToJinja(content, context, db)
@@ -7,6 +7,7 @@ import {
7
7
  capabilities,
8
8
  containerServices,
9
9
  ipAllocations,
10
+ machines,
10
11
  moduleConfigs,
11
12
  moduleInfrastructure,
12
13
  moduleSystems,
@@ -193,6 +194,68 @@ describe('Variable Context', () => {
193
194
  });
194
195
 
195
196
  describe('buildResolutionContext', () => {
197
+ test('records a local machine by its interface identity instead of its transport sentinel', async () => {
198
+ db.insert(modules)
199
+ .values({
200
+ id: 'celilo-mgmt',
201
+ name: 'Celilo Management',
202
+ version: '1.0.0',
203
+ sourcePath: '/test/celilo-mgmt',
204
+ manifestData: {
205
+ id: 'celilo-mgmt',
206
+ name: 'Celilo Management',
207
+ version: '1.0.0',
208
+ requires: { system: { zone: 'internal' } },
209
+ },
210
+ })
211
+ .run();
212
+ db.insert(moduleConfigs)
213
+ .values({
214
+ moduleId: 'celilo-mgmt',
215
+ key: 'hostname',
216
+ value: 'celilo-mgr',
217
+ valueJson: '"celilo-mgr"',
218
+ })
219
+ .run();
220
+ db.insert(machines)
221
+ .values({
222
+ id: 'local-manager',
223
+ hostname: 'celilo-mgr',
224
+ ipAddress: '127.0.0.1',
225
+ sshUser: 'jem',
226
+ sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
227
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
228
+ zone: 'internal',
229
+ earmarkedModule: 'celilo-mgmt',
230
+ interfaces: [
231
+ { name: 'ens18', ipAddress: '10.77.20.32', zone: 'internal' },
232
+ { name: 'wlan0', ipAddress: '192.168.0.32', zone: 'upstream' },
233
+ ],
234
+ })
235
+ .run();
236
+ db.insert(moduleInfrastructure)
237
+ .values({
238
+ id: 'infra-celilo-mgmt',
239
+ moduleId: 'celilo-mgmt',
240
+ infrastructureType: 'machine',
241
+ machineId: 'local-manager',
242
+ })
243
+ .run();
244
+
245
+ await buildResolutionContext('celilo-mgmt', db);
246
+
247
+ const system = db
248
+ .select()
249
+ .from(moduleSystems)
250
+ .where(eq(moduleSystems.moduleId, 'celilo-mgmt'))
251
+ .get();
252
+ expect(system).toMatchObject({
253
+ hostname: 'celilo-mgr',
254
+ ipv4Address: '10.77.20.32',
255
+ zone: 'internal',
256
+ });
257
+ });
258
+
196
259
  test('should build context with module configs', async () => {
197
260
  // Insert module first (for foreign key)
198
261
  db.$client.run(
@@ -19,6 +19,7 @@ import { allocateResources, getAllocation } from '../ipam/allocator';
19
19
  import { type ModuleManifest, getDeclaredSystems, getSingularSystemSpec } from '../manifest/schema';
20
20
  import { decryptSecret } from '../secrets/encryption';
21
21
  import { getOrCreateMasterKey } from '../secrets/master-key';
22
+ import { resolveMachineIdentityAddress } from '../services/deployed-systems';
22
23
  import { upsertModuleConfig } from '../services/module-config';
23
24
  import { resolveComputedFields } from './computed/evaluate';
24
25
  import { containsComputedMarker } from './computed/marker';
@@ -419,10 +420,17 @@ async function assembleResolutionContext(
419
420
  `Machine '${infraSelection.machineId}' for module '${moduleId}' has no ipAddress`,
420
421
  );
421
422
  }
423
+ const deployedZone = machineRow.zone ?? zone;
424
+ const machineIdentity = resolveMachineIdentityAddress(machineRow, deployedZone);
425
+ if (!machineIdentity) {
426
+ throw new Error(
427
+ `Machine '${infraSelection.machineId}' for module '${moduleId}' has no non-loopback interface in zone '${deployedZone}'`,
428
+ );
429
+ }
422
430
  upsertDeployedSystem(db, moduleId, {
423
431
  name: decl.name,
424
432
  hostname,
425
- ipv4Address: machineRow.ipAddress,
433
+ ipv4Address: machineIdentity,
426
434
  // The machine's own zone, matching recordDeployedSystemForModule and
427
435
  // backfillModuleSystems. This is the THIRD place that decides a
428
436
  // deployed system's zone, and it ran last — so while the other two
@@ -430,7 +438,7 @@ async function assembleResolutionContext(
430
438
  // manifest's, and the control plane stayed recorded as `internal`.
431
439
  // `requires.system.zone` is the minimum used to SELECT a host; the
432
440
  // machine we selected is where the system actually is.
433
- zone: machineRow.zone ?? zone,
441
+ zone: deployedZone,
434
442
  infraType: 'machine',
435
443
  machineId: infraSelection.machineId,
436
444
  });
@@ -691,7 +691,7 @@ describe('applyDeclarativeDerivations', () => {
691
691
  expect(context.selfConfig.primary_domain).toBeUndefined();
692
692
  });
693
693
 
694
- test('skips non-string types (handled by config interview instead)', () => {
694
+ test('derives scalar numeric values from capability data', () => {
695
695
  const manifest: ModuleManifest = {
696
696
  celilo_contract: '1.0',
697
697
  id: 'test-module',
@@ -703,10 +703,10 @@ describe('applyDeclarativeDerivations', () => {
703
703
  owns: [
704
704
  {
705
705
  name: 'port',
706
- type: 'number',
706
+ type: 'integer',
707
707
  required: false,
708
- source: 'user',
709
- derive_from: '$system:port',
708
+ source: 'capability',
709
+ derive_from: '$capability:dns_internal.dns.knot_port',
710
710
  },
711
711
  ],
712
712
  imports: [],
@@ -716,16 +716,55 @@ describe('applyDeclarativeDerivations', () => {
716
716
  const context: ResolutionContext = {
717
717
  moduleId: 'test-module',
718
718
  selfConfig: {},
719
- systemConfig: { port: '8080' },
719
+ systemConfig: {},
720
720
  systemSecrets: {},
721
721
  secrets: {},
722
- capabilities: {},
722
+ capabilities: {
723
+ dns_internal: { dns: { knot_port: 5353 } },
724
+ },
725
+ };
726
+
727
+ applyDeclarativeDerivations(manifest, context);
728
+
729
+ expect(context.selfConfig.port).toBe('5353');
730
+ });
731
+
732
+ test('continues to skip structured derivations', () => {
733
+ const manifest: ModuleManifest = {
734
+ celilo_contract: '1.0',
735
+ id: 'test-module',
736
+ name: 'Test Module',
737
+ version: '1.0.0',
738
+ requires: { capabilities: [] },
739
+ provides: { capabilities: [] },
740
+ variables: {
741
+ owns: [
742
+ {
743
+ name: 'domains',
744
+ type: 'array',
745
+ required: false,
746
+ source: 'capability',
747
+ derive_from: '$capability:dns_internal.dns.managed_domains',
748
+ },
749
+ ],
750
+ imports: [],
751
+ },
752
+ };
753
+
754
+ const context: ResolutionContext = {
755
+ moduleId: 'test-module',
756
+ selfConfig: {},
757
+ systemConfig: {},
758
+ systemSecrets: {},
759
+ secrets: {},
760
+ capabilities: {
761
+ dns_internal: { dns: { managed_domains: ['home.arpa'] } },
762
+ },
723
763
  };
724
764
 
725
765
  applyDeclarativeDerivations(manifest, context);
726
766
 
727
- // Non-string variable is skipped — port remains unset
728
- expect(context.selfConfig.port).toBeUndefined();
767
+ expect(context.selfConfig.domains).toBeUndefined();
729
768
  });
730
769
  });
731
770
 
@@ -186,10 +186,12 @@ export function applyDeclarativeDerivations(
186
186
  continue;
187
187
  }
188
188
 
189
- // Declarative derivation only resolves string template expressions.
190
- // Non-string types (arrays, objects) with derive_from are handled by
191
- // the config interview system instead.
192
- if (variable.type !== 'string') {
189
+ // Declarative derivation resolves scalar template expressions. Numeric and
190
+ // boolean values flow through this resolution context as their string form
191
+ // and recover their declared type at the module-config / inventory boundary.
192
+ // Structured arrays and objects still belong to the typed config path: a
193
+ // template substitution would collapse them to a lossy string.
194
+ if (variable.type === 'array' || variable.type === 'object') {
193
195
  continue;
194
196
  }
195
197