@celilo/cli 0.5.0-alpha.0 → 0.5.0-alpha.10

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 (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +240 -0
  9. package/src/api-clients/proxmox.ts +456 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +166 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +122 -15
  95. package/src/templates/generator.ts +206 -57
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -5,7 +5,9 @@ import { dirname, join, relative } from 'node:path';
5
5
  import { and, eq } from 'drizzle-orm';
6
6
  import { generateInventory } from '../ansible/inventory';
7
7
  import { generateAnsibleSecrets } from '../ansible/secrets';
8
+ import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
8
9
  import { log } from '../cli/prompts';
10
+ import { getModuleStoragePath } from '../config/paths';
9
11
  import { type DbClient, getDb } from '../db/client';
10
12
  import {
11
13
  capabilities,
@@ -18,8 +20,14 @@ import {
18
20
  import { getSingularSystemSpec } from '../manifest/schema';
19
21
  import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
20
22
  import { validateZoneRequirements } from '../manifest/validate';
23
+ import { getServiceCredentials } from '../services/container-service';
24
+ import { getModuleSystems } from '../services/deployed-systems';
25
+ import {
26
+ describeCapabilityProblem,
27
+ findBrokenCapabilityDerivations,
28
+ } from '../services/fleet-checks';
21
29
  import { selectInfrastructure } from '../services/infrastructure-selector';
22
- import { upsertModuleConfig } from '../services/module-config';
30
+ import { deleteModuleConfig, upsertModuleConfig } from '../services/module-config';
23
31
  import type { InfrastructureSelection } from '../types/infrastructure';
24
32
  import { convertSecretsToJinja } from '../variables/ansible-resolver';
25
33
  import { buildResolutionContext } from '../variables/context';
@@ -128,79 +136,177 @@ export function getOutputFilename(templateFilename: string): string {
128
136
  }
129
137
 
130
138
  /**
131
- * Inject framework-owned DNS-at-birth into every `proxmox_lxc` resource.
139
+ * Inject framework-owned DNS-at-birth into every Proxmox compute resource
140
+ * `proxmox_lxc` and `proxmox_vm_qemu`.
132
141
  *
133
- * An LXC's nameserver is infrastructure celilo owns — like vmid, target_ip, and
142
+ * A node's nameserver is infrastructure celilo owns — like vmid, target_ip, and
134
143
  * inventory — not something a module author hand-writes. Authors declare zero
135
- * DNS terraform; this stamps it onto each `proxmox_lxc` block at generate time.
136
- * For every block it emits:
144
+ * DNS terraform; this stamps it onto each Proxmox resource block at generate
145
+ * time. For every block it emits:
137
146
  *
138
147
  * - `nameserver = "$self:lxc_nameserver"` — only when a nameserver is
139
- * computable (`hasNameserver`). The first LXC, deployed before any
140
- * `dns_internal` provider exists, has no value: it inherits the Proxmox
141
- * node default and the `dns-client-config` aspect repairs resolv.conf
142
- * post-deploy.
143
- * - `lifecycle { ignore_changes = [nameserver] }` — always. Existing LXCs
144
- * were born without a nameserver, so setting one is an in-place UPDATE the
145
- * terraform-safety guard (create-only, see terraform-safety.ts) rejects.
146
- * `ignore_changes` makes nameserver birth-only: terraform sets it at create
147
- * and never diffs it again, so the guard never trips on a redeploy. The
148
- * aspect owns the live resolv.conf from then on (terraform = birth DNS,
149
- * aspect = ongoing DNS). See v2/LXC_INTERNAL_DNS.md.
148
+ * computable (`hasNameserver`). The attribute is `nameserver` for both an
149
+ * LXC and a cloud-init VM. The first system, deployed before any
150
+ * `dns_internal` provider exists, has no value: it inherits the node default
151
+ * and the `dns-client-config` aspect repairs resolv.conf post-deploy.
152
+ * - `lifecycle { ignore_changes = [...] }` — always. The list is the
153
+ * resource's create-time / ForceNew attributes, so an unchanged redeploy is
154
+ * a no-op and a real change an in-place UPDATE — never the destructive
155
+ * REPLACE the terraform-safety guard (create-only, see terraform-safety.ts)
156
+ * rejects. terraform = birth DNS, the `dns-client-config` aspect = ongoing
157
+ * DNS. The per-type lists are in the callback. See v2/LXC_INTERNAL_DNS.md.
150
158
  *
151
159
  * Anchored on the resource's opening line — it never brace-matches the nested
152
- * rootfs/network/features blocks, so it's robust to attribute order/formatting.
160
+ * disk/network/features blocks, so it's robust to attribute order/formatting.
153
161
  * Idempotent at file granularity: a `.tf` that already declares
154
- * `ignore_changes = [nameserver]` (re-run, or an author who opted in) is
155
- * returned untouched.
162
+ * `ignore_changes = [nameserver` (re-run, or an author who opted in) is returned
163
+ * untouched.
156
164
  *
157
165
  * Policy function (Rule 10.1) - pure string transformation, no I/O.
158
166
  *
159
167
  * @param content - Raw terraform template content (pre variable-resolution)
160
168
  * @param hasNameserver - Whether `$self:lxc_nameserver` resolves to a value
161
- * @returns Content with DNS injected into each proxmox_lxc resource
169
+ * @returns Content with DNS injected into each Proxmox compute resource
162
170
  */
163
- export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): string {
171
+ export function injectProxmoxDns(content: string, hasNameserver: boolean): string {
164
172
  // Already injected (idempotent) or author opted into the lifecycle — done.
165
- // Match the `[nameserver` prefix (no closing bracket) so this stays true
166
- // whether the list is the original `[nameserver]` or the ISS-0055-extended
167
- // `[nameserver, network[0].hwaddr, …]` — otherwise re-generate double-injects.
173
+ // Match the `[nameserver` prefix (no closing bracket) so this stays true for
174
+ // both the LXC and the VM variant otherwise re-generate double-injects.
168
175
  if (content.includes('ignore_changes = [nameserver')) {
169
176
  return content;
170
177
  }
171
178
 
172
179
  // A template that still carries a `nameserver = …` attribute — a stale copied
173
- // module from before the per-template lines were reverted, or an author who
174
- // set it by hand already supplies the value. Injecting a second `nameserver`
175
- // is a terraform "Attribute redefined" error. So skip the value line when one
176
- // exists and just add the lifecycle guard (the load-bearing part). Both cases
177
- // converge on exactly one nameserver + ignore_changes.
180
+ // module, or an author who set it by hand already supplies the value.
181
+ // Injecting a second `nameserver` is a terraform "Attribute redefined" error.
182
+ // So skip the value line when one exists and just add the lifecycle guard.
178
183
  const alreadyHasNameserver = /^[ \t]*nameserver[ \t]*=/m.test(content);
179
184
 
180
- const openLineRe = /^([ \t]*)resource\s+"proxmox_lxc"\s+"[^"]+"\s*\{[ \t]*$/gm;
181
- return content.replace(openLineRe, (openLine, indent: string) => {
185
+ const openLineRe = /^([ \t]*)resource\s+"(proxmox_lxc|proxmox_vm_qemu)"\s+"[^"]+"\s*\{[ \t]*$/gm;
186
+ return content.replace(openLineRe, (openLine, indent: string, resourceType: string) => {
182
187
  const inner = `${indent} `;
183
188
  const injected = [openLine];
184
189
  if (hasNameserver && !alreadyHasNameserver) {
185
190
  injected.push(`${inner}nameserver = "$self:lxc_nameserver"`);
186
191
  }
187
192
  injected.push(`${inner}lifecycle {`);
188
- // ISS-0055: ignore the ForceNew attributes Proxmox assigns at create time —
189
- // the MAC (network hwaddr) and the rootfs volume path. telmate marks these
190
- // ForceNew, so leaving them out of the config makes every re-deploy plan a
191
- // destructive REPLACE. Ignoring just these two makes an unchanged re-deploy a
192
- // no-op (the computed network id/type stay stable once the block is no longer
193
- // being replaced) and a real change (e.g. a memory bump) an in-place UPDATE.
194
- // We deliberately do NOT list network[0].id / network[0].type — they aren't
195
- // schema attributes in telmate ~>2.9 and `terraform validate` rejects them.
196
- // `nameserver` stays for the original DNS reason; `rootfs.size` is NOT ignored
197
- // so a disk grow still applies in place.
198
- injected.push(`${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]`);
193
+ // The create-time / ForceNew attributes Proxmox assigns, per resource type.
194
+ // Listing them makes an unchanged redeploy a no-op and a real change an
195
+ // in-place UPDATE, never a destructive REPLACE. A non-existent attribute
196
+ // here fails `terraform validate`, so each list is exactly the schema
197
+ // attributes the resource actually has.
198
+ // LXC (ISS-0055/0089): the create-time MAC + rootfs volume, plus
199
+ // `ssh_public_keys` (ForceNew a fleet-key rotation must not REPLACE the
200
+ // container). `rootfs.size` is NOT ignored, so a disk grow still applies.
201
+ // We do NOT ignore `target_node` a node change is real (migration,
202
+ // ISS-0090), never silently dropped.
203
+ // VM (telmate 3.x proxmox_vm_qemu): `clone` (ForceNew clone source),
204
+ // `sshkeys` + `nameserver` (ForceNew cloud-init), and the NIC `macaddr`.
205
+ // Initial set from the provider schema — extend if a first real VM deploy
206
+ // surfaces another spurious-REPLACE attribute, exactly as ISS-0055 did for
207
+ // the LXC list.
208
+ const ignore =
209
+ resourceType === 'proxmox_vm_qemu'
210
+ ? 'nameserver, network[0].macaddr, sshkeys, clone'
211
+ : 'nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys';
212
+ injected.push(`${inner} ignore_changes = [${ignore}]`);
199
213
  injected.push(`${inner}}`);
200
214
  return injected.join('\n');
201
215
  });
202
216
  }
203
217
 
218
+ /**
219
+ * Extract the node a proxmox_lxc resource is deployed on from a parsed terraform
220
+ * state object. Pure logic (Rule 10), split from the file read for testability.
221
+ * Prefers the explicit `target_node` attribute; falls back to parsing the
222
+ * resource id (`<node>/lxc/<vmid>`). Returns null when there's no proxmox_lxc
223
+ * resource (e.g. an empty/fresh state).
224
+ */
225
+ export function targetNodeFromTfState(state: {
226
+ resources?: Array<{
227
+ type?: string;
228
+ instances?: Array<{ attributes?: { target_node?: string; id?: string } }>;
229
+ }>;
230
+ }): string | null {
231
+ const lxc = state.resources?.find((r) => r.type === 'proxmox_lxc');
232
+ const attrs = lxc?.instances?.[0]?.attributes;
233
+ if (!attrs) {
234
+ return null;
235
+ }
236
+ if (attrs.target_node) {
237
+ return attrs.target_node;
238
+ }
239
+ // id format: "<node>/lxc/<vmid>"
240
+ return attrs.id?.split('/')[0] || null;
241
+ }
242
+
243
+ /**
244
+ * Read the node a module's container is currently deployed on, from its terraform
245
+ * state file. This is celilo's authoritative record of placement (ISS-0090).
246
+ * Returns null when no state exists yet (a first deploy) or it can't be read —
247
+ * the caller then falls back to the service `default_target_node`.
248
+ */
249
+ async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
250
+ const statePath = join(
251
+ getModuleStoragePath(),
252
+ moduleId,
253
+ 'generated',
254
+ 'terraform',
255
+ 'terraform.tfstate',
256
+ );
257
+ if (!existsSync(statePath)) {
258
+ return null;
259
+ }
260
+ try {
261
+ return targetNodeFromTfState(JSON.parse(await readFile(statePath, 'utf-8')));
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * The node a module's container ACTUALLY lives on, from Proxmox (ISS-0090).
269
+ * Proxmox is the ultimate source of truth for current location — it sees a
270
+ * hand-migration that celilo's terraform state wouldn't. Returns null when the
271
+ * module has no deployed vmid yet, the service is unreachable, or the vmid isn't
272
+ * in the cluster — the caller then falls back to terraform state / the default.
273
+ * Never throws: a Proxmox outage must not block a deploy.
274
+ */
275
+ async function readProxmoxNodeForModule(
276
+ moduleId: string,
277
+ serviceId: string,
278
+ db: DbClient,
279
+ ): Promise<string | null> {
280
+ const vmid = getModuleSystems(moduleId, db).find(
281
+ (s) => s.infrastructure.type === 'container_service' && s.infrastructure.vmid != null,
282
+ )?.infrastructure.vmid;
283
+ if (vmid == null) return null;
284
+ try {
285
+ const creds = (await getServiceCredentials(serviceId)) as ProxmoxCredentials;
286
+ const result = await new ProxmoxClient(creds).nodeForVmid(vmid);
287
+ return result.success ? result.data : null;
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Decide which node to target for a deploy (ISS-0090). Pure (Rule 10):
295
+ * Proxmox reality > recorded terraform state > service default.
296
+ * `default_target_node` governs only a FIRST placement; a changed default must
297
+ * never relocate a running container. A hand-migration (seen by Proxmox but not
298
+ * tf-state) is adopted. Deliberate moves are an explicit migrate (ISS-0062).
299
+ */
300
+ export function decideTargetNode(opts: {
301
+ proxmoxNode: string | null;
302
+ stateNode: string | null;
303
+ defaultNode: string;
304
+ }): { node: string; source: 'proxmox' | 'state' | 'default' } {
305
+ if (opts.proxmoxNode) return { node: opts.proxmoxNode, source: 'proxmox' };
306
+ if (opts.stateNode) return { node: opts.stateNode, source: 'state' };
307
+ return { node: opts.defaultNode, source: 'default' };
308
+ }
309
+
204
310
  /**
205
311
  * Discover template files in directory recursively
206
312
  *
@@ -624,6 +730,9 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
624
730
  // Infrastructure Properties Resolution (Proxmox provider config)
625
731
  // For Proxmox services, extract provider config and store as temporary values
626
732
  // This happens during generation so templates can access target_node, lxc_template, etc.
733
+ // Resolved live each generate (ISS-0090) and injected into the context below,
734
+ // never cached in the DB. undefined for non-Proxmox / machine deploys.
735
+ let resolvedTargetNode: string | undefined;
627
736
  if (isContainerService && isProxmoxService && infrastructureSelection?.serviceId) {
628
737
  const service = await db
629
738
  .select()
@@ -638,20 +747,36 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
638
747
  storage: string;
639
748
  };
640
749
 
641
- // Store provider config values as temporary config (similar to IPAM allocation)
642
- const infraProperties = [
643
- { key: 'target_node', value: providerConfig.default_target_node },
644
- { key: 'lxc_template', value: providerConfig.lxc_template },
645
- { key: 'storage', value: providerConfig.storage },
646
- ];
647
-
648
- for (const prop of infraProperties) {
649
- upsertModuleConfig(db, moduleId, `__infra_${prop.key}`, prop.value);
750
+ // ISS-0090: deploy follows REALITY. Resolve the node from Proxmox (it sees
751
+ // a hand-migration that tf-state wouldn't), else the recorded terraform
752
+ // state, else the service default (FIRST placement only). A changed default
753
+ // must never relocate a running container; deliberate moves are an explicit
754
+ // migrate (ISS-0062).
755
+ const decision = decideTargetNode({
756
+ proxmoxNode: await readProxmoxNodeForModule(
757
+ moduleId,
758
+ infrastructureSelection.serviceId,
759
+ db,
760
+ ),
761
+ stateNode: await readDeployedTargetNode(moduleId),
762
+ defaultNode: providerConfig.default_target_node,
763
+ });
764
+ resolvedTargetNode = decision.node;
765
+ if (decision.source !== 'default' && decision.node !== providerConfig.default_target_node) {
766
+ const from = decision.source === 'proxmox' ? 'Proxmox' : 'terraform state';
767
+ log.info(
768
+ `${moduleId} → node '${decision.node}' (from ${from}; service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`,
769
+ );
650
770
  }
651
771
 
652
- log.success(
653
- `Infrastructure properties resolved from service: target_node=${providerConfig.default_target_node}`,
654
- );
772
+ // Persist only the non-drift provider values. target_node is reality — it's
773
+ // injected into the resolution context below, never cached (ISS-0090); drop
774
+ // any stale __infra_target_node a prior generate left behind.
775
+ upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
776
+ upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
777
+ deleteModuleConfig(db, moduleId, '__infra_target_node');
778
+
779
+ log.success(`Infrastructure resolved: target_node=${decision.node} (${decision.source})`);
655
780
  }
656
781
  }
657
782
 
@@ -683,8 +808,15 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
683
808
  context.selfConfig.target_ip = ipConfig.value!;
684
809
  }
685
810
 
686
- // Add infrastructure properties to context (target_node, lxc_template, storage)
687
- const infraKeys = ['target_node', 'lxc_template', 'storage'];
811
+ // target_node is the live-resolved reality (ISS-0090) — inject it directly,
812
+ // never from a cached __infra_target_node row (which drifts).
813
+ if (resolvedTargetNode) {
814
+ context.selfConfig.target_node = resolvedTargetNode;
815
+ }
816
+
817
+ // lxc_template / storage are provider config (intent, not drift-prone) — read
818
+ // them back from the __infra_* rows persisted above.
819
+ const infraKeys = ['lxc_template', 'storage'];
688
820
  for (const key of infraKeys) {
689
821
  const infraConfig = db
690
822
  .select()
@@ -705,6 +837,23 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
705
837
  };
706
838
  }
707
839
 
840
+ // Policy: fail loud at the source on a broken capability chain. A
841
+ // required `source: capability` var whose chain doesn't resolve is
842
+ // silently dropped during derivation, then surfaces downstream as a
843
+ // cryptic `$self:<x> not found` in some template. Assert it here against
844
+ // the resolved capabilities map so generate names the broken link and
845
+ // the provider to redeploy (ISS-0115; the data-plane sibling of ISS-0088).
846
+ const capProblems = findBrokenCapabilityDerivations(moduleId, manifest, context.capabilities);
847
+ if (capProblems.length > 0) {
848
+ return {
849
+ success: false,
850
+ error: `Cannot generate '${moduleId}': ${capProblems.length} capability-derived variable(s) won't resolve:\n${capProblems
851
+ .map((p) => ` - ${describeCapabilityProblem(p)}`)
852
+ .join('\n')}`,
853
+ details: capProblems,
854
+ };
855
+ }
856
+
708
857
  // Execution: Store derived variables in module_configs
709
858
  // Variables with derive_from are resolved in the context but not stored in module_configs.
710
859
  // Store them so hooks and host_vars can access them.
@@ -912,7 +1061,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
912
1061
  // every proxmox_lxc resource before resolution (terraform files only).
913
1062
  const content =
914
1063
  !isAnsibleTemplate && template.targetPath.endsWith('.tf')
915
- ? injectProxmoxLxcDns(template.content, Boolean(context.selfConfig.lxc_nameserver))
1064
+ ? injectProxmoxDns(template.content, Boolean(context.selfConfig.lxc_nameserver))
916
1065
  : template.content;
917
1066
  const result = isAnsibleTemplate
918
1067
  ? await convertSecretsToJinja(content, context, db)
@@ -60,7 +60,7 @@ describe('Fixture Test Utilities', () => {
60
60
  test('excludes secrets from config', async () => {
61
61
  const config = await getModuleTestConfig('dns-external');
62
62
  expect(config).toBeDefined();
63
- expect(config.vps_ip).toBe('188.166.157.2');
63
+ expect(config.vps_ip).toBe('192.0.2.20');
64
64
  expect(config.secrets).toBeUndefined();
65
65
  });
66
66
 
@@ -0,0 +1,33 @@
1
+ import { execSync } from 'node:child_process';
2
+
3
+ /**
4
+ * Guard for tests that can't run in the `unit` target / on the minimal CI
5
+ * runner — they shell out to external tools (ansible, wg, terraform, docker…)
6
+ * or assert platform-specific behavior. Such tests are integration tests by
7
+ * Rule 7.1 and must NOT gate the unit CI.
8
+ *
9
+ * Returns `true` (→ skip) when:
10
+ * - CELILO_UNIT_ONLY=1 is set (the `unit` target forces them off), OR
11
+ * - a required tool is absent on this host, OR
12
+ * - we're on the wrong platform.
13
+ *
14
+ * Usage: describe.skipIf(skipIntegration({ tools: ['ansible'] }))(...)
15
+ * test.skipIf(skipIntegration({ platform: 'darwin' }))(...)
16
+ */
17
+ function hasTool(tool: string): boolean {
18
+ try {
19
+ execSync(`command -v ${tool}`, { stdio: 'ignore' });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function skipIntegration(
27
+ req: { tools?: string[]; platform?: NodeJS.Platform } = {},
28
+ ): boolean {
29
+ if (process.env.CELILO_UNIT_ONLY === '1') return true;
30
+ if (req.tools?.some((t) => !hasTool(t))) return true;
31
+ if (req.platform && process.platform !== req.platform) return true;
32
+ return false;
33
+ }
@@ -31,6 +31,12 @@ export interface ProxmoxConfig {
31
31
  default_target_node: string;
32
32
  lxc_template: string;
33
33
  storage: string;
34
+ /**
35
+ * Cloud-init VM template to clone for `requires.system.type: vm` modules — the
36
+ * VM analogue of `lxc_template`. Optional: only services hosting VM modules set
37
+ * it (the operator builds the template once, per VM_INFRA_TYPE.md).
38
+ */
39
+ vm_template?: string;
34
40
  }
35
41
 
36
42
  /**
@@ -90,7 +90,7 @@ beforeEach(async () => {
90
90
  // Encrypt with the SAME master key the resolver will decrypt with (the one
91
91
  // beforeEach wrote into CELILO_DATA_DIR).
92
92
  const enc = encryptSecret(
93
- JSON.stringify({ 'lunacycle.net': 'pw1', 'celilo.computer': 'pw2' }),
93
+ JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }),
94
94
  await loadTestMasterKey(),
95
95
  );
96
96
  db.$client
@@ -133,7 +133,7 @@ describe('computed capability fields — DB integration', () => {
133
133
  expect(result.success).toBe(true);
134
134
  if (result.success) {
135
135
  // Non-scalar computed results serialize to JSON in the string path.
136
- expect(JSON.parse(result.value)).toEqual(['lunacycle.net', 'celilo.computer']);
136
+ expect(JSON.parse(result.value)).toEqual(['example.net', 'celilo.computer']);
137
137
  }
138
138
  });
139
139
 
@@ -170,7 +170,7 @@ describe('computed capability fields — DB integration', () => {
170
170
  const reg = ctx.capabilities.dns_registrar as Record<string, unknown>;
171
171
 
172
172
  // The real array, not the raw marker object.
173
- expect(reg.domain_list).toEqual(['lunacycle.net', 'celilo.computer']);
173
+ expect(reg.domain_list).toEqual(['example.net', 'celilo.computer']);
174
174
  expect(reg.provider).toBe('namecheap');
175
175
  });
176
176
 
@@ -23,7 +23,7 @@ function makeLookup(data: Record<string, unknown>): LookupFn {
23
23
 
24
24
  const FIXTURE = {
25
25
  secret: {
26
- ddns_passwords: { 'lunacycle.net': 'pw1', 'celilo.computer': 'pw2' },
26
+ ddns_passwords: { 'example.net': 'pw1', 'celilo.computer': 'pw2' },
27
27
  },
28
28
  self: {
29
29
  zone_names: { dmz: 'DMZ', app: 'App' },
@@ -34,7 +34,7 @@ const FIXTURE = {
34
34
  zones_with_dupes: ['x', 'y', 'x', 'z', 'y'],
35
35
  },
36
36
  system: {
37
- primary_domain: 'lunacycle.net',
37
+ primary_domain: 'example.net',
38
38
  },
39
39
  };
40
40
 
@@ -44,7 +44,7 @@ function evalOk(expr: string): unknown {
44
44
 
45
45
  describe('computed DSL — keys', () => {
46
46
  test('keys of the ddns_passwords secret map (the domain_list case)', () => {
47
- expect(evalOk('keys(secret.ddns_passwords)')).toEqual(['lunacycle.net', 'celilo.computer']);
47
+ expect(evalOk('keys(secret.ddns_passwords)')).toEqual(['example.net', 'celilo.computer']);
48
48
  });
49
49
 
50
50
  test('keys of a non-secret object', () => {
@@ -63,7 +63,7 @@ describe('computed DSL — values', () => {
63
63
  });
64
64
 
65
65
  test('keys of a secret map is allowed (key names are non-sensitive)', () => {
66
- expect(evalOk('keys(secret.ddns_passwords)')).toEqual(['lunacycle.net', 'celilo.computer']);
66
+ expect(evalOk('keys(secret.ddns_passwords)')).toEqual(['example.net', 'celilo.computer']);
67
67
  });
68
68
  });
69
69
 
@@ -107,7 +107,7 @@ describe('computed DSL — concat + unique (nesting/chaining)', () => {
107
107
  describe('computed DSL — format', () => {
108
108
  test('interpolates named parts', () => {
109
109
  expect(evalOk("format('{host}.{zone}', host=self.hostname, zone=system.primary_domain)")).toBe(
110
- 'dns-int.lunacycle.net',
110
+ 'dns-int.example.net',
111
111
  );
112
112
  });
113
113
 
@@ -164,14 +164,14 @@ describe('resolveDeclarativeDerivation', () => {
164
164
  capabilities: {
165
165
  dns_external: {
166
166
  server: {
167
- ip: '188.166.157.2',
167
+ ip: '192.0.2.20',
168
168
  },
169
169
  },
170
170
  },
171
171
  };
172
172
 
173
173
  const result = resolveDeclarativeDerivation(variable, context);
174
- expect(result).toBe('188.166.157.2');
174
+ expect(result).toBe('192.0.2.20');
175
175
  });
176
176
 
177
177
  test('resolves nested capability path', () => {
@@ -193,7 +193,7 @@ describe('resolveDeclarativeDerivation', () => {
193
193
  dns_external: {
194
194
  server: {
195
195
  ip: {
196
- primary: '188.166.157.2',
196
+ primary: '192.0.2.20',
197
197
  secondary: '188.166.157.3',
198
198
  },
199
199
  },
@@ -202,7 +202,7 @@ describe('resolveDeclarativeDerivation', () => {
202
202
  };
203
203
 
204
204
  const result = resolveDeclarativeDerivation(variable, context);
205
- expect(result).toBe('188.166.157.2');
205
+ expect(result).toBe('192.0.2.20');
206
206
  });
207
207
 
208
208
  test('throws on missing capability', () => {
@@ -350,13 +350,13 @@ describe('resolveDeclarativeDerivation', () => {
350
350
  secrets: {},
351
351
  capabilities: {
352
352
  dns_external: {
353
- server: { ip: '188.166.157.2' },
353
+ server: { ip: '192.0.2.20' },
354
354
  },
355
355
  },
356
356
  };
357
357
 
358
358
  const result = resolveDeclarativeDerivation(variable, context);
359
- expect(result).toBe('caddy.example.com@188.166.157.2');
359
+ expect(result).toBe('caddy.example.com@192.0.2.20');
360
360
  });
361
361
  });
362
362