@celilo/cli 1.6.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 (46) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/package.json +3 -3
  5. package/src/capabilities/lookup.ts +39 -29
  6. package/src/capabilities/secret-ref.test.ts +24 -0
  7. package/src/capabilities/secret-validation.ts +50 -0
  8. package/src/capabilities/validation.test.ts +187 -2
  9. package/src/capabilities/validation.ts +53 -1
  10. package/src/cli/commands/alerts-sweep.ts +18 -0
  11. package/src/cli/commands/module-remove.ts +34 -2
  12. package/src/cli/commands/module-update.test.ts +149 -2
  13. package/src/cli/commands/module-update.ts +113 -25
  14. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  15. package/src/cli/commands/service-set-credentials.ts +115 -0
  16. package/src/cli/commands/system-migrate.ts +6 -4
  17. package/src/cli/completion.ts +16 -1
  18. package/src/cli/index.ts +9 -0
  19. package/src/db/client.ts +10 -8
  20. package/src/db/migrate.test.ts +147 -0
  21. package/src/db/migrate.ts +69 -1
  22. package/src/hooks/capability-loader.test.ts +55 -0
  23. package/src/hooks/capability-loader.ts +16 -1
  24. package/src/module/import.ts +20 -5
  25. package/src/policy/module-business-baseline.ts +0 -11
  26. package/src/services/alerting/monitors.ts +54 -2
  27. package/src/services/alerting/sweep-runner.ts +38 -1
  28. package/src/services/consumer-cleanup.ts +5 -3
  29. package/src/services/container-service.test.ts +34 -0
  30. package/src/services/container-service.ts +44 -0
  31. package/src/services/deployed-systems.test.ts +101 -0
  32. package/src/services/deployed-systems.ts +43 -11
  33. package/src/services/dns-provider-backfill.ts +30 -0
  34. package/src/services/fleet-checks.test.ts +26 -0
  35. package/src/services/fleet-checks.ts +11 -1
  36. package/src/services/module-deploy.ts +88 -41
  37. package/src/services/provider-arrival.test.ts +241 -0
  38. package/src/services/provider-arrival.ts +213 -0
  39. package/src/templates/generator.test.ts +35 -0
  40. package/src/templates/generator.ts +29 -1
  41. package/src/variables/context.test.ts +63 -0
  42. package/src/variables/context.ts +10 -2
  43. package/src/variables/declarative-derivation.test.ts +47 -8
  44. package/src/variables/declarative-derivation.ts +6 -4
  45. package/src/services/public-web-republish.test.ts +0 -189
  46. package/src/services/public-web-republish.ts +0 -84
@@ -0,0 +1,241 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { RunNamedHookResult } from '../hooks/run-named-hook';
3
+ import type { HookLogger } from '../hooks/types';
4
+ import type { ModuleManifest } from '../manifest/schema';
5
+ import {
6
+ type ConsumerCandidate,
7
+ planProviderBackfill,
8
+ runProviderBackfill,
9
+ } from './provider-arrival';
10
+
11
+ /**
12
+ * The pure half of provider arrival: which consumers get re-run, and which are
13
+ * skipped and why. All of it is decided without a database, a hook runner, or a
14
+ * deployed anything, which is the point of the split.
15
+ */
16
+
17
+ function manifest(input: { requires?: string[]; optional?: string[] }): ModuleManifest {
18
+ return {
19
+ requires: { capabilities: (input.requires ?? []).map((name) => ({ name, version: '1.0.0' })) },
20
+ optional: { capabilities: (input.optional ?? []).map((name) => ({ name, version: '1.0.0' })) },
21
+ } as unknown as ModuleManifest;
22
+ }
23
+
24
+ const consumer = (
25
+ moduleId: string,
26
+ caps: { requires?: string[]; optional?: string[] },
27
+ state = 'VERIFIED',
28
+ ): ConsumerCandidate => ({ moduleId, manifest: manifest(caps), state });
29
+
30
+ describe('planProviderBackfill', () => {
31
+ it('re-runs consumers of the capabilities the arriving provider actually provides', () => {
32
+ const plan = planProviderBackfill(
33
+ 'iptables',
34
+ ['firewall'],
35
+ [
36
+ consumer('caddy', { requires: ['firewall'] }),
37
+ consumer('forgejo', { requires: ['public_web'] }),
38
+ ],
39
+ );
40
+
41
+ expect(plan.map((t) => t.consumerId)).toEqual(['caddy']);
42
+ });
43
+
44
+ /**
45
+ * `optional` is the edge `remove-guard.ts` already counts, and it is the one
46
+ * the field was added to express — "this hook will use monitoring if it is
47
+ * there" describes exactly a module with something to gain the moment the
48
+ * provider appears. Counting only `requires` would leave it unregistered.
49
+ */
50
+ it('includes an OPTIONAL dependent, not only a required one', () => {
51
+ const plan = planProviderBackfill(
52
+ 'technitium',
53
+ ['dns_internal'],
54
+ [consumer('caddy-internal', { optional: ['dns_internal'] })],
55
+ );
56
+
57
+ expect(plan.map((t) => t.consumerId)).toEqual(['caddy-internal']);
58
+ expect(plan[0]?.skip).toBeUndefined();
59
+ });
60
+
61
+ it('never treats the provider as its own consumer', () => {
62
+ // A module that both provides and requires a capability would otherwise
63
+ // have its on_install run a second time inside the deploy that just ran it.
64
+ const plan = planProviderBackfill(
65
+ 'caddy',
66
+ ['public_web'],
67
+ [
68
+ consumer('caddy', { requires: ['public_web', 'firewall'] }),
69
+ consumer('forgejo', { requires: ['public_web'] }),
70
+ ],
71
+ );
72
+
73
+ expect(plan.map((t) => t.consumerId)).toEqual(['forgejo']);
74
+ });
75
+
76
+ it('skips a consumer in a pre-deploy state, and says so rather than dropping it', () => {
77
+ // Capabilities are registered at IMPORT, not deploy, so an imported module
78
+ // has never resolved anything and has nothing to re-register.
79
+ const plan = planProviderBackfill(
80
+ 'iptables',
81
+ ['firewall'],
82
+ [
83
+ consumer('imported', { requires: ['firewall'] }, 'IMPORTED'),
84
+ consumer('validated', { requires: ['firewall'] }, 'VALIDATED'),
85
+ consumer('configured', { requires: ['firewall'] }, 'CONFIGURED'),
86
+ ],
87
+ );
88
+
89
+ expect(plan.map((t) => t.skip)).toEqual(['not-deployed', 'not-deployed', 'not-deployed']);
90
+ });
91
+
92
+ it('skips a PAUSED consumer separately from a never-deployed one', () => {
93
+ const plan = planProviderBackfill(
94
+ 'iptables',
95
+ ['firewall'],
96
+ [consumer('paused', { requires: ['firewall'] }, 'PAUSED')],
97
+ );
98
+
99
+ expect(plan[0]?.skip).toBe('paused');
100
+ });
101
+
102
+ it('names every capability the consumer takes from this provider, once', () => {
103
+ const plan = planProviderBackfill(
104
+ 'greenwave',
105
+ ['firewall', 'dhcp_server'],
106
+ [consumer('caddy', { requires: ['firewall'], optional: ['dhcp_server'] })],
107
+ );
108
+
109
+ expect(plan).toHaveLength(1);
110
+ expect(plan[0]?.capabilityNames).toEqual(['dhcp_server', 'firewall']);
111
+ });
112
+
113
+ it('is empty for a module that provides nothing', () => {
114
+ expect(
115
+ planProviderBackfill('forgejo', [], [consumer('caddy', { requires: ['firewall'] })]),
116
+ ).toEqual([]);
117
+ });
118
+
119
+ it('is ordered by consumer id, so a failure is reproducible', () => {
120
+ const plan = planProviderBackfill(
121
+ 'iptables',
122
+ ['firewall'],
123
+ [consumer('zulu', { requires: ['firewall'] }), consumer('alpha', { requires: ['firewall'] })],
124
+ );
125
+
126
+ expect(plan.map((t) => t.consumerId)).toEqual(['alpha', 'zulu']);
127
+ });
128
+ });
129
+
130
+ const silentLogger = (): HookLogger & { warnings: string[] } => {
131
+ const warnings: string[] = [];
132
+ return {
133
+ warnings,
134
+ info: () => {},
135
+ warn: (m: string) => warnings.push(m),
136
+ error: () => {},
137
+ success: () => {},
138
+ debug: () => {},
139
+ } as unknown as HookLogger & { warnings: string[] };
140
+ };
141
+
142
+ const ok = (): RunNamedHookResult => ({ success: true }) as RunNamedHookResult;
143
+ const fails = (error: string): RunNamedHookResult =>
144
+ ({ success: false, error }) as RunNamedHookResult;
145
+
146
+ describe('runProviderBackfill', () => {
147
+ /**
148
+ * The property the whole thing exists for. Stopping at the first failure
149
+ * would leave the remaining consumers unregistered against a provider that is
150
+ * now live — the same gap, just narrower.
151
+ */
152
+ it('attempts every consumer even after one fails, and names each failure', async () => {
153
+ const attempted: string[] = [];
154
+ const result = await runProviderBackfill(
155
+ 'iptables',
156
+ [
157
+ { consumerId: 'alpha', capabilityNames: ['firewall'] },
158
+ { consumerId: 'bravo', capabilityNames: ['firewall'] },
159
+ { consumerId: 'charlie', capabilityNames: ['firewall'] },
160
+ ],
161
+ {} as never,
162
+ silentLogger(),
163
+ async (id) => {
164
+ attempted.push(id);
165
+ return id === 'bravo' ? fails('ssh timed out') : ok();
166
+ },
167
+ );
168
+
169
+ expect(attempted).toEqual(['alpha', 'bravo', 'charlie']);
170
+ expect(result.rerun).toEqual(['alpha', 'charlie']);
171
+ expect(result.failures).toEqual([{ consumerId: 'bravo', error: 'ssh timed out' }]);
172
+ });
173
+
174
+ it('names the consumer and the retry command in the warning an operator reads', async () => {
175
+ const logger = silentLogger();
176
+ await runProviderBackfill(
177
+ 'iptables',
178
+ [{ consumerId: 'bravo', capabilityNames: ['firewall'] }],
179
+ {} as never,
180
+ logger,
181
+ async () => fails('ssh timed out'),
182
+ );
183
+
184
+ expect(logger.warnings.join('\n')).toContain('bravo');
185
+ expect(logger.warnings.join('\n')).toContain('celilo module deploy bravo');
186
+ });
187
+
188
+ it('does not run a skipped consumer at all', async () => {
189
+ const attempted: string[] = [];
190
+ const result = await runProviderBackfill(
191
+ 'iptables',
192
+ [
193
+ { consumerId: 'paused', capabilityNames: ['firewall'], skip: 'paused' },
194
+ { consumerId: 'fresh', capabilityNames: ['firewall'], skip: 'not-deployed' },
195
+ ],
196
+ {} as never,
197
+ silentLogger(),
198
+ async (id) => {
199
+ attempted.push(id);
200
+ return ok();
201
+ },
202
+ );
203
+
204
+ expect(attempted).toEqual([]);
205
+ expect(result.skipped).toEqual([
206
+ { consumerId: 'paused', reason: 'paused' },
207
+ { consumerId: 'fresh', reason: 'not-deployed' },
208
+ ]);
209
+ });
210
+
211
+ /**
212
+ * `runNamedHook` reports a paused module as a plain success, so trusting the
213
+ * flag alone would log a re-registration that never happened — the silence
214
+ * this change exists to end, reintroduced one layer down.
215
+ */
216
+ it('reads skippedPaused rather than trusting success, for a module paused mid-dispatch', async () => {
217
+ const result = await runProviderBackfill(
218
+ 'iptables',
219
+ [{ consumerId: 'racer', capabilityNames: ['firewall'] }],
220
+ {} as never,
221
+ silentLogger(),
222
+ async () => ({ success: true, skippedPaused: true }) as RunNamedHookResult,
223
+ );
224
+
225
+ expect(result.rerun).toEqual([]);
226
+ expect(result.skipped).toEqual([{ consumerId: 'racer', reason: 'paused' }]);
227
+ });
228
+
229
+ it('does not count a consumer with no on_install as re-registered', async () => {
230
+ const result = await runProviderBackfill(
231
+ 'iptables',
232
+ [{ consumerId: 'hookless', capabilityNames: ['firewall'] }],
233
+ {} as never,
234
+ silentLogger(),
235
+ async () => ({ success: true, notDefined: true }) as RunNamedHookResult,
236
+ );
237
+
238
+ expect(result.rerun).toEqual([]);
239
+ expect(result.failures).toEqual([]);
240
+ });
241
+ });
@@ -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
+ }
@@ -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
  });