@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11

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 +211 -1
  9. package/src/api-clients/proxmox.ts +399 -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 +171 -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 +92 -12
  95. package/src/templates/generator.ts +165 -80
  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,6 +5,7 @@ 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';
9
10
  import { getModuleStoragePath } from '../config/paths';
10
11
  import { type DbClient, getDb } from '../db/client';
@@ -19,8 +20,14 @@ import {
19
20
  import { getSingularSystemSpec } from '../manifest/schema';
20
21
  import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
21
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';
22
29
  import { selectInfrastructure } from '../services/infrastructure-selector';
23
- import { upsertModuleConfig } from '../services/module-config';
30
+ import { deleteModuleConfig, upsertModuleConfig } from '../services/module-config';
24
31
  import type { InfrastructureSelection } from '../types/infrastructure';
25
32
  import { convertSecretsToJinja } from '../variables/ansible-resolver';
26
33
  import { buildResolutionContext } from '../variables/context';
@@ -129,82 +136,80 @@ export function getOutputFilename(templateFilename: string): string {
129
136
  }
130
137
 
131
138
  /**
132
- * 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`.
133
141
  *
134
- * 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
135
143
  * inventory — not something a module author hand-writes. Authors declare zero
136
- * DNS terraform; this stamps it onto each `proxmox_lxc` block at generate time.
137
- * 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:
138
146
  *
139
147
  * - `nameserver = "$self:lxc_nameserver"` — only when a nameserver is
140
- * computable (`hasNameserver`). The first LXC, deployed before any
141
- * `dns_internal` provider exists, has no value: it inherits the Proxmox
142
- * node default and the `dns-client-config` aspect repairs resolv.conf
143
- * post-deploy.
144
- * - `lifecycle { ignore_changes = [nameserver] }` — always. Existing LXCs
145
- * were born without a nameserver, so setting one is an in-place UPDATE the
146
- * terraform-safety guard (create-only, see terraform-safety.ts) rejects.
147
- * `ignore_changes` makes nameserver birth-only: terraform sets it at create
148
- * and never diffs it again, so the guard never trips on a redeploy. The
149
- * aspect owns the live resolv.conf from then on (terraform = birth DNS,
150
- * 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.
151
158
  *
152
159
  * Anchored on the resource's opening line — it never brace-matches the nested
153
- * 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.
154
161
  * Idempotent at file granularity: a `.tf` that already declares
155
- * `ignore_changes = [nameserver]` (re-run, or an author who opted in) is
156
- * returned untouched.
162
+ * `ignore_changes = [nameserver` (re-run, or an author who opted in) is returned
163
+ * untouched.
157
164
  *
158
165
  * Policy function (Rule 10.1) - pure string transformation, no I/O.
159
166
  *
160
167
  * @param content - Raw terraform template content (pre variable-resolution)
161
168
  * @param hasNameserver - Whether `$self:lxc_nameserver` resolves to a value
162
- * @returns Content with DNS injected into each proxmox_lxc resource
169
+ * @returns Content with DNS injected into each Proxmox compute resource
163
170
  */
164
- export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): string {
171
+ export function injectProxmoxDns(content: string, hasNameserver: boolean): string {
165
172
  // Already injected (idempotent) or author opted into the lifecycle — done.
166
- // Match the `[nameserver` prefix (no closing bracket) so this stays true
167
- // whether the list is the original `[nameserver]` or the ISS-0055-extended
168
- // `[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.
169
175
  if (content.includes('ignore_changes = [nameserver')) {
170
176
  return content;
171
177
  }
172
178
 
173
179
  // A template that still carries a `nameserver = …` attribute — a stale copied
174
- // module from before the per-template lines were reverted, or an author who
175
- // set it by hand already supplies the value. Injecting a second `nameserver`
176
- // is a terraform "Attribute redefined" error. So skip the value line when one
177
- // exists and just add the lifecycle guard (the load-bearing part). Both cases
178
- // 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.
179
183
  const alreadyHasNameserver = /^[ \t]*nameserver[ \t]*=/m.test(content);
180
184
 
181
- const openLineRe = /^([ \t]*)resource\s+"proxmox_lxc"\s+"[^"]+"\s*\{[ \t]*$/gm;
182
- 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) => {
183
187
  const inner = `${indent} `;
184
188
  const injected = [openLine];
185
189
  if (hasNameserver && !alreadyHasNameserver) {
186
190
  injected.push(`${inner}nameserver = "$self:lxc_nameserver"`);
187
191
  }
188
192
  injected.push(`${inner}lifecycle {`);
189
- // ISS-0055: ignore the ForceNew attributes Proxmox assigns at create time —
190
- // the MAC (network hwaddr) and the rootfs volume path. telmate marks these
191
- // ForceNew, so leaving them out of the config makes every re-deploy plan a
192
- // destructive REPLACE. Ignoring just these two makes an unchanged re-deploy a
193
- // no-op (the computed network id/type stay stable once the block is no longer
194
- // being replaced) and a real change (e.g. a memory bump) an in-place UPDATE.
195
- // We deliberately do NOT list network[0].id / network[0].type — they aren't
196
- // schema attributes in telmate ~>2.9 and `terraform validate` rejects them.
197
- // `nameserver` stays for the original DNS reason; `rootfs.size` is NOT ignored
198
- // so a disk grow still applies in place.
199
- // ISS-0089: also ignore `ssh_public_keys` it's ForceNew, and on a module
200
- // deployed before the current fleet key, the LXC's birth keys differ from the
201
- // regenerated config, forcing a spurious REPLACE on redeploy. Rotating the
202
- // authorized keys must never recreate the container. We do NOT ignore
203
- // `target_node` — a node change is real and is handled by migration (ISS-0090),
204
- // never silently dropped.
205
- injected.push(
206
- `${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]`,
207
- );
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}]`);
208
213
  injected.push(`${inner}}`);
209
214
  return injected.join('\n');
210
215
  });
@@ -259,6 +264,49 @@ async function readDeployedTargetNode(moduleId: string): Promise<string | null>
259
264
  }
260
265
  }
261
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
+
262
310
  /**
263
311
  * Discover template files in directory recursively
264
312
  *
@@ -682,6 +730,9 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
682
730
  // Infrastructure Properties Resolution (Proxmox provider config)
683
731
  // For Proxmox services, extract provider config and store as temporary values
684
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;
685
736
  if (isContainerService && isProxmoxService && infrastructureSelection?.serviceId) {
686
737
  const service = await db
687
738
  .select()
@@ -694,39 +745,49 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
694
745
  default_target_node: string;
695
746
  lxc_template: string;
696
747
  storage: string;
748
+ // Optional: only set once a cloud-init VM template has been built/selected
749
+ // for the service (see service reconfigure). Required by `type: vm` modules;
750
+ // absent for services that only deploy LXCs.
751
+ vm_template?: string;
697
752
  };
698
753
 
699
- // ISS-0090: target the node the container ACTUALLY lives on, not the
700
- // service default. `default_target_node` governs only NEW placement; a
701
- // changed default must NEVER relocate a running system. celilo's terraform
702
- // state is its authoritative record of where it placed this container (kept
703
- // in sync by deploy and by `module migrate`), so read the deployed node from
704
- // there. Fall back to the default only when there's no state yet — a first
705
- // deploy. Deliberate relocation is an explicit `module migrate`, not a
706
- // side-effect of the default changing.
707
- let targetNode = providerConfig.default_target_node;
708
- const deployedNode = await readDeployedTargetNode(moduleId);
709
- if (deployedNode) {
710
- if (deployedNode !== targetNode) {
711
- log.info(
712
- `${moduleId} is already deployed on node '${deployedNode}' — targeting it (service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`,
713
- );
714
- }
715
- targetNode = deployedNode;
754
+ // ISS-0090: deploy follows REALITY. Resolve the node from Proxmox (it sees
755
+ // a hand-migration that tf-state wouldn't), else the recorded terraform
756
+ // state, else the service default (FIRST placement only). A changed default
757
+ // must never relocate a running container; deliberate moves are an explicit
758
+ // migrate (ISS-0062).
759
+ const decision = decideTargetNode({
760
+ proxmoxNode: await readProxmoxNodeForModule(
761
+ moduleId,
762
+ infrastructureSelection.serviceId,
763
+ db,
764
+ ),
765
+ stateNode: await readDeployedTargetNode(moduleId),
766
+ defaultNode: providerConfig.default_target_node,
767
+ });
768
+ resolvedTargetNode = decision.node;
769
+ if (decision.source !== 'default' && decision.node !== providerConfig.default_target_node) {
770
+ const from = decision.source === 'proxmox' ? 'Proxmox' : 'terraform state';
771
+ log.info(
772
+ `${moduleId} → node '${decision.node}' (from ${from}; service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`,
773
+ );
716
774
  }
717
775
 
718
- // Store provider config values as temporary config (similar to IPAM allocation)
719
- const infraProperties = [
720
- { key: 'target_node', value: targetNode },
721
- { key: 'lxc_template', value: providerConfig.lxc_template },
722
- { key: 'storage', value: providerConfig.storage },
723
- ];
724
-
725
- for (const prop of infraProperties) {
726
- upsertModuleConfig(db, moduleId, `__infra_${prop.key}`, prop.value);
776
+ // Persist only the non-drift provider values. target_node is reality it's
777
+ // injected into the resolution context below, never cached (ISS-0090); drop
778
+ // any stale __infra_target_node a prior generate left behind.
779
+ upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
780
+ upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
781
+ // vm_template is only present once a VM template exists for the service.
782
+ // Persist it when set so `type: vm` modules can resolve $self:vm_template;
783
+ // a `type: vm` deploy against a service without one then fails loudly at
784
+ // variable resolution (the intended "no VM template configured" error).
785
+ if (providerConfig.vm_template) {
786
+ upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template);
727
787
  }
788
+ deleteModuleConfig(db, moduleId, '__infra_target_node');
728
789
 
729
- log.success(`Infrastructure properties resolved from service: target_node=${targetNode}`);
790
+ log.success(`Infrastructure resolved: target_node=${decision.node} (${decision.source})`);
730
791
  }
731
792
  }
732
793
 
@@ -758,8 +819,15 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
758
819
  context.selfConfig.target_ip = ipConfig.value!;
759
820
  }
760
821
 
761
- // Add infrastructure properties to context (target_node, lxc_template, storage)
762
- const infraKeys = ['target_node', 'lxc_template', 'storage'];
822
+ // target_node is the live-resolved reality (ISS-0090) — inject it directly,
823
+ // never from a cached __infra_target_node row (which drifts).
824
+ if (resolvedTargetNode) {
825
+ context.selfConfig.target_node = resolvedTargetNode;
826
+ }
827
+
828
+ // lxc_template / storage are provider config (intent, not drift-prone) — read
829
+ // them back from the __infra_* rows persisted above.
830
+ const infraKeys = ['lxc_template', 'storage', 'vm_template'];
763
831
  for (const key of infraKeys) {
764
832
  const infraConfig = db
765
833
  .select()
@@ -780,6 +848,23 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
780
848
  };
781
849
  }
782
850
 
851
+ // Policy: fail loud at the source on a broken capability chain. A
852
+ // required `source: capability` var whose chain doesn't resolve is
853
+ // silently dropped during derivation, then surfaces downstream as a
854
+ // cryptic `$self:<x> not found` in some template. Assert it here against
855
+ // the resolved capabilities map so generate names the broken link and
856
+ // the provider to redeploy (ISS-0115; the data-plane sibling of ISS-0088).
857
+ const capProblems = findBrokenCapabilityDerivations(moduleId, manifest, context.capabilities);
858
+ if (capProblems.length > 0) {
859
+ return {
860
+ success: false,
861
+ error: `Cannot generate '${moduleId}': ${capProblems.length} capability-derived variable(s) won't resolve:\n${capProblems
862
+ .map((p) => ` - ${describeCapabilityProblem(p)}`)
863
+ .join('\n')}`,
864
+ details: capProblems,
865
+ };
866
+ }
867
+
783
868
  // Execution: Store derived variables in module_configs
784
869
  // Variables with derive_from are resolved in the context but not stored in module_configs.
785
870
  // Store them so hooks and host_vars can access them.
@@ -987,7 +1072,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
987
1072
  // every proxmox_lxc resource before resolution (terraform files only).
988
1073
  const content =
989
1074
  !isAnsibleTemplate && template.targetPath.endsWith('.tf')
990
- ? injectProxmoxLxcDns(template.content, Boolean(context.selfConfig.lxc_nameserver))
1075
+ ? injectProxmoxDns(template.content, Boolean(context.selfConfig.lxc_nameserver))
991
1076
  : template.content;
992
1077
  const result = isAnsibleTemplate
993
1078
  ? 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