@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.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/MODULE_PRIMITIVES.md +25 -7
- package/drizzle/0026_module_integrity_version.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +238 -3
- package/src/cli/commands/module-update.ts +206 -12
- package/src/cli/commands/module-verify.ts +77 -13
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +11 -2
- package/src/cli/tui/audit-state.ts +11 -3
- package/src/cli/tui/audit-tui.tsx +10 -4
- package/src/cli/tui/icons.ts +9 -2
- package/src/cli/tui/modals/analyzing.tsx +3 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +5 -0
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +56 -40
- package/src/module/packaging/audit.ts +103 -28
- package/src/module/packaging/build.ts +12 -53
- package/src/module/packaging/classify-module-path.test.ts +104 -0
- package/src/module/packaging/extract.ts +31 -3
- package/src/module/packaging/generated-plane.test.ts +79 -0
- package/src/module/packaging/generated-plane.ts +134 -0
- package/src/module/packaging/host-plane.test.ts +132 -0
- package/src/module/packaging/host-plane.ts +135 -0
- package/src/module/packaging/package-rules.ts +62 -0
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/audit/cli-version.test.ts +6 -2
- package/src/services/audit/cli-version.ts +20 -6
- package/src/services/audit/detect-without-converge.test.ts +91 -0
- package/src/services/audit/detect-without-converge.ts +81 -0
- package/src/services/audit/disk-space.test.ts +5 -2
- package/src/services/audit/disk-space.ts +5 -3
- package/src/services/audit/health.test.ts +39 -0
- package/src/services/audit/index.test.ts +7 -1
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/module-integrity.test.ts +146 -0
- package/src/services/audit/module-integrity.ts +113 -0
- package/src/services/audit/module-versions.ts +4 -1
- package/src/services/audit/schema.test.ts +7 -2
- package/src/services/audit/schema.ts +19 -1
- package/src/services/audit/terraform-plan.ts +17 -2
- package/src/services/audit/types.test.ts +29 -0
- package/src/services/audit/types.ts +30 -4
- package/src/services/consumer-cleanup.ts +5 -3
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +109 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/services/public-web-republish.test.ts +0 -189
- 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(
|
|
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(
|
package/src/variables/context.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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('
|
|
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: '
|
|
706
|
+
type: 'integer',
|
|
707
707
|
required: false,
|
|
708
|
-
source: '
|
|
709
|
-
derive_from: '$
|
|
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: {
|
|
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
|
-
|
|
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
|
|
190
|
-
//
|
|
191
|
-
// the config
|
|
192
|
-
|
|
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
|
|