@celilo/cli 0.5.0-alpha.4 → 0.5.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/drizzle/0010_dns_internal_records.sql +12 -0
  2. package/drizzle/0011_backups_name.sql +1 -0
  3. package/drizzle/meta/_journal.json +14 -0
  4. package/package.json +2 -2
  5. package/src/ansible/inventory.test.ts +10 -10
  6. package/src/ansible/validation.test.ts +25 -15
  7. package/src/cli/command-registry.ts +13 -2
  8. package/src/cli/commands/events.test.ts +4 -4
  9. package/src/cli/commands/events.ts +2 -2
  10. package/src/cli/commands/service-add-proxmox.ts +9 -0
  11. package/src/cli/commands/system-doctor.ts +135 -40
  12. package/src/cli/commands/system-migrate.test.ts +40 -0
  13. package/src/cli/commands/system-migrate.ts +65 -0
  14. package/src/cli/completion.ts +1 -0
  15. package/src/cli/index.ts +7 -2
  16. package/src/config/paths.test.ts +61 -48
  17. package/src/db/client.ts +15 -146
  18. package/src/db/migrate.ts +14 -6
  19. package/src/db/schema-introspection.ts +88 -0
  20. package/src/db/schema.ts +38 -0
  21. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  22. package/src/hooks/capability-loader.ts +24 -15
  23. package/src/infrastructure/property-extractor.test.ts +15 -0
  24. package/src/infrastructure/property-extractor.ts +12 -0
  25. package/src/manifest/schema.ts +7 -0
  26. package/src/manifest/validate.test.ts +53 -0
  27. package/src/services/bus-interview.test.ts +2 -2
  28. package/src/services/bus-secret-flow.test.ts +2 -2
  29. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  30. package/src/services/deploy-preflight.ts +25 -0
  31. package/src/services/deploy-validation.test.ts +2 -2
  32. package/src/services/dns-internal-records.test.ts +126 -0
  33. package/src/services/dns-internal-records.ts +119 -0
  34. package/src/services/dns-provider-backfill.test.ts +2 -2
  35. package/src/services/dns-registrations.test.ts +10 -10
  36. package/src/services/fleet-checks.test.ts +495 -0
  37. package/src/services/fleet-checks.ts +663 -0
  38. package/src/services/module-build.test.ts +43 -38
  39. package/src/templates/generator.test.ts +62 -12
  40. package/src/templates/generator.ts +69 -50
  41. package/src/test-utils/fixtures.test.ts +1 -1
  42. package/src/test-utils/integration-guard.ts +33 -0
  43. package/src/types/infrastructure.ts +6 -0
  44. package/src/variables/computed/computed-integration.test.ts +3 -3
  45. package/src/variables/computed/computed.test.ts +5 -5
  46. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm';
5
5
  import { log } from '../cli/prompts';
6
6
  import { type DbClient, createDbClient } from '../db/client';
7
7
  import { moduleBuilds, modules } from '../db/schema';
8
+ import { skipIntegration } from '../test-utils/integration-guard';
8
9
  import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
9
10
 
10
11
  const TEST_DB_PATH = './test-module-build.db';
@@ -156,12 +157,14 @@ describe('Module Build Service', () => {
156
157
  // We're just testing that detection happens (message logged)
157
158
  }, 10000); // 10 second timeout
158
159
 
159
- test('should record build metadata in database', async () => {
160
- await mkdir(TEST_MODULE_DIR, { recursive: true });
161
- await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
162
- await writeFile(
163
- `${TEST_MODULE_DIR}/build/playbook.yml`,
164
- `---
160
+ test.skipIf(skipIntegration({ tools: ['ansible'] }))(
161
+ 'should record build metadata in database',
162
+ async () => {
163
+ await mkdir(TEST_MODULE_DIR, { recursive: true });
164
+ await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
165
+ await writeFile(
166
+ `${TEST_MODULE_DIR}/build/playbook.yml`,
167
+ `---
165
168
  - name: Quick test build
166
169
  hosts: localhost
167
170
  gather_facts: false
@@ -170,43 +173,45 @@ describe('Module Build Service', () => {
170
173
  ansible.builtin.debug:
171
174
  msg: "Build test"
172
175
  `,
173
- );
176
+ );
174
177
 
175
- db.insert(modules)
176
- .values({
177
- id: 'record-test',
178
- name: 'Record Test',
179
- version: '1.0.0',
180
- sourcePath: TEST_MODULE_DIR,
181
- manifestData: {
178
+ db.insert(modules)
179
+ .values({
182
180
  id: 'record-test',
183
181
  name: 'Record Test',
184
182
  version: '1.0.0',
185
- build: {
186
- script: 'build/playbook.yml',
183
+ sourcePath: TEST_MODULE_DIR,
184
+ manifestData: {
185
+ id: 'record-test',
186
+ name: 'Record Test',
187
+ version: '1.0.0',
188
+ build: {
189
+ script: 'build/playbook.yml',
190
+ },
187
191
  },
188
- },
189
- })
190
- .run();
191
-
192
- const result = await buildModuleFromSource('record-test', db);
193
-
194
- // Build should succeed (simple debug task)
195
- expect(result.success).toBe(true);
196
-
197
- // Verify build metadata was recorded
198
- const buildRecord = await db
199
- .select()
200
- .from(moduleBuilds)
201
- .where(eq(moduleBuilds.moduleId, 'record-test'))
202
- .get();
203
-
204
- expect(buildRecord).toBeDefined();
205
- expect(buildRecord?.moduleId).toBe('record-test');
206
- expect(buildRecord?.version).toBe('1.0.0');
207
- expect(buildRecord?.status).toBe('success');
208
- expect(buildRecord?.environment).toBe('system'); // No flake.nix
209
- }, 10000); // 10 second timeout for ansible execution
192
+ })
193
+ .run();
194
+
195
+ const result = await buildModuleFromSource('record-test', db);
196
+
197
+ // Build should succeed (simple debug task)
198
+ expect(result.success).toBe(true);
199
+
200
+ // Verify build metadata was recorded
201
+ const buildRecord = await db
202
+ .select()
203
+ .from(moduleBuilds)
204
+ .where(eq(moduleBuilds.moduleId, 'record-test'))
205
+ .get();
206
+
207
+ expect(buildRecord).toBeDefined();
208
+ expect(buildRecord?.moduleId).toBe('record-test');
209
+ expect(buildRecord?.version).toBe('1.0.0');
210
+ expect(buildRecord?.status).toBe('success');
211
+ expect(buildRecord?.environment).toBe('system'); // No flake.nix
212
+ },
213
+ 10000,
214
+ ); // 10 second timeout for ansible execution
210
215
 
211
216
  // Regression: a build.command that references $CELILO_MODULE_SOURCE_DIR
212
217
  // (the variable celilo-registry's manifest uses to find sibling packages
@@ -9,7 +9,7 @@ import {
9
9
  discoverTemplateFiles,
10
10
  generateTemplates,
11
11
  getOutputFilename,
12
- injectProxmoxLxcDns,
12
+ injectProxmoxDns,
13
13
  isTemplateFile,
14
14
  readTemplateFiles,
15
15
  targetNodeFromTfState,
@@ -636,7 +636,7 @@ resource "proxmox_lxc" "container" {
636
636
  });
637
637
  });
638
638
 
639
- describe('injectProxmoxLxcDns', () => {
639
+ describe('injectProxmoxDns', () => {
640
640
  const LXC = [
641
641
  'resource "proxmox_lxc" "caddy" {',
642
642
  ' target_node = "pve"',
@@ -648,7 +648,7 @@ resource "proxmox_lxc" "container" {
648
648
  ].join('\n');
649
649
 
650
650
  test('injects nameserver + lifecycle when a nameserver is computable', () => {
651
- const out = injectProxmoxLxcDns(LXC, true);
651
+ const out = injectProxmoxDns(LXC, true);
652
652
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
653
653
  expect(out).toContain(' lifecycle {');
654
654
  expect(out).toContain(
@@ -665,7 +665,7 @@ resource "proxmox_lxc" "container" {
665
665
  });
666
666
 
667
667
  test('injects lifecycle only (no nameserver) when none is computable', () => {
668
- const out = injectProxmoxLxcDns(LXC, false);
668
+ const out = injectProxmoxDns(LXC, false);
669
669
  expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
670
670
  expect(out).toContain(' lifecycle {');
671
671
  expect(out).toContain(
@@ -674,8 +674,8 @@ resource "proxmox_lxc" "container" {
674
674
  });
675
675
 
676
676
  test('is idempotent — already-injected content is returned unchanged', () => {
677
- const once = injectProxmoxLxcDns(LXC, true);
678
- const twice = injectProxmoxLxcDns(once, true);
677
+ const once = injectProxmoxDns(LXC, true);
678
+ const twice = injectProxmoxDns(once, true);
679
679
  expect(twice).toBe(once);
680
680
  });
681
681
 
@@ -689,27 +689,77 @@ resource "proxmox_lxc" "container" {
689
689
  ' start = true',
690
690
  '}',
691
691
  ].join('\n');
692
- const out = injectProxmoxLxcDns(stale, true);
692
+ const out = injectProxmoxDns(stale, true);
693
693
  // Exactly one nameserver attribute survives.
694
694
  expect(out.match(/nameserver\s*=/g)?.length).toBe(1);
695
695
  // The lifecycle guard is still added (ISS-0055 extended the ignore list).
696
696
  expect(out).toContain('ignore_changes = [nameserver,');
697
697
  });
698
698
 
699
- test('leaves non-proxmox_lxc resources untouched', () => {
700
- const vm = ['resource "proxmox_vm" "build" {', ' cores = 4', '}'].join('\n');
701
- expect(injectProxmoxLxcDns(vm, true)).toBe(vm);
699
+ test('injects cloud-init DNS + the VM ForceNew ignore list into proxmox_vm_qemu', () => {
700
+ const vm = [
701
+ 'resource "proxmox_vm_qemu" "builder" {',
702
+ ' target_node = "pve"',
703
+ ' clone = "ubuntu-2204-cloudinit"',
704
+ ' network {',
705
+ ' bridge = "vmbr0"',
706
+ ' }',
707
+ '}',
708
+ ].join('\n');
709
+ const out = injectProxmoxDns(vm, true);
710
+ expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
711
+ expect(out).toContain(' lifecycle {');
712
+ // The VM ForceNew list (telmate 3.x), NOT the LXC one.
713
+ expect(out).toContain(
714
+ ' ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]',
715
+ );
716
+ const lines = out.split('\n');
717
+ expect(lines[0]).toBe('resource "proxmox_vm_qemu" "builder" {');
718
+ expect(lines[1]).toBe(' nameserver = "$self:lxc_nameserver"');
719
+ expect(out).toContain(' clone = "ubuntu-2204-cloudinit"');
720
+ });
721
+
722
+ test('branches the ignore list per block in a mixed lxc + vm file', () => {
723
+ // The forgejo-runner builder pattern: one template with both a count-gated
724
+ // proxmox_lxc (light) and proxmox_vm_qemu (builder) — each gets its own list.
725
+ const mixed = [
726
+ 'resource "proxmox_lxc" "light" {',
727
+ ' target_node = "pve"',
728
+ '}',
729
+ '',
730
+ 'resource "proxmox_vm_qemu" "builder" {',
731
+ ' clone = "ubuntu-2204-cloudinit"',
732
+ '}',
733
+ ].join('\n');
734
+ const out = injectProxmoxDns(mixed, true);
735
+ expect(out).toContain(
736
+ 'ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
737
+ );
738
+ expect(out).toContain('ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]');
739
+ });
740
+
741
+ test('leaves non-Proxmox-compute resources untouched', () => {
742
+ // Not proxmox_lxc / proxmox_vm_qemu — an external-zone droplet, and the bare
743
+ // `proxmox_vm` type (which is NOT the cloud-init qemu resource we target).
744
+ const droplet = [
745
+ 'resource "digitalocean_droplet" "edge" {',
746
+ ' size = "s-1vcpu-1gb"',
747
+ '}',
748
+ ].join('\n');
749
+ const bareVm = ['resource "proxmox_vm" "x" {', ' cores = 4', '}'].join('\n');
750
+ expect(injectProxmoxDns(droplet, true)).toBe(droplet);
751
+ expect(injectProxmoxDns(bareVm, true)).toBe(bareVm);
702
752
  });
703
753
 
704
754
  test('injects into every proxmox_lxc block in a multi-resource file', () => {
705
755
  const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`;
706
- const out = injectProxmoxLxcDns(two, true);
756
+ const out = injectProxmoxDns(two, true);
707
757
  expect(out.match(/ignore_changes = \[nameserver,/g)?.length).toBe(2);
708
758
  });
709
759
 
710
760
  test('matches the indentation of the resource opening line', () => {
711
761
  const indented = [' resource "proxmox_lxc" "x" {', ' cores = 1', ' }'].join('\n');
712
- const out = injectProxmoxLxcDns(indented, true);
762
+ const out = injectProxmoxDns(indented, true);
713
763
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
714
764
  expect(out).toContain(' lifecycle {');
715
765
  expect(out).toContain(
@@ -19,6 +19,10 @@ import {
19
19
  import { getSingularSystemSpec } from '../manifest/schema';
20
20
  import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
21
21
  import { validateZoneRequirements } from '../manifest/validate';
22
+ import {
23
+ describeCapabilityProblem,
24
+ findBrokenCapabilityDerivations,
25
+ } from '../services/fleet-checks';
22
26
  import { selectInfrastructure } from '../services/infrastructure-selector';
23
27
  import { upsertModuleConfig } from '../services/module-config';
24
28
  import type { InfrastructureSelection } from '../types/infrastructure';
@@ -129,82 +133,80 @@ export function getOutputFilename(templateFilename: string): string {
129
133
  }
130
134
 
131
135
  /**
132
- * Inject framework-owned DNS-at-birth into every `proxmox_lxc` resource.
136
+ * Inject framework-owned DNS-at-birth into every Proxmox compute resource
137
+ * `proxmox_lxc` and `proxmox_vm_qemu`.
133
138
  *
134
- * An LXC's nameserver is infrastructure celilo owns — like vmid, target_ip, and
139
+ * A node's nameserver is infrastructure celilo owns — like vmid, target_ip, and
135
140
  * 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:
141
+ * DNS terraform; this stamps it onto each Proxmox resource block at generate
142
+ * time. For every block it emits:
138
143
  *
139
144
  * - `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.
145
+ * computable (`hasNameserver`). The attribute is `nameserver` for both an
146
+ * LXC and a cloud-init VM. The first system, deployed before any
147
+ * `dns_internal` provider exists, has no value: it inherits the node default
148
+ * and the `dns-client-config` aspect repairs resolv.conf post-deploy.
149
+ * - `lifecycle { ignore_changes = [...] }` — always. The list is the
150
+ * resource's create-time / ForceNew attributes, so an unchanged redeploy is
151
+ * a no-op and a real change an in-place UPDATE — never the destructive
152
+ * REPLACE the terraform-safety guard (create-only, see terraform-safety.ts)
153
+ * rejects. terraform = birth DNS, the `dns-client-config` aspect = ongoing
154
+ * DNS. The per-type lists are in the callback. See v2/LXC_INTERNAL_DNS.md.
151
155
  *
152
156
  * 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.
157
+ * disk/network/features blocks, so it's robust to attribute order/formatting.
154
158
  * 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.
159
+ * `ignore_changes = [nameserver` (re-run, or an author who opted in) is returned
160
+ * untouched.
157
161
  *
158
162
  * Policy function (Rule 10.1) - pure string transformation, no I/O.
159
163
  *
160
164
  * @param content - Raw terraform template content (pre variable-resolution)
161
165
  * @param hasNameserver - Whether `$self:lxc_nameserver` resolves to a value
162
- * @returns Content with DNS injected into each proxmox_lxc resource
166
+ * @returns Content with DNS injected into each Proxmox compute resource
163
167
  */
164
- export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): string {
168
+ export function injectProxmoxDns(content: string, hasNameserver: boolean): string {
165
169
  // 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.
170
+ // Match the `[nameserver` prefix (no closing bracket) so this stays true for
171
+ // both the LXC and the VM variant otherwise re-generate double-injects.
169
172
  if (content.includes('ignore_changes = [nameserver')) {
170
173
  return content;
171
174
  }
172
175
 
173
176
  // 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.
177
+ // module, or an author who set it by hand already supplies the value.
178
+ // Injecting a second `nameserver` is a terraform "Attribute redefined" error.
179
+ // So skip the value line when one exists and just add the lifecycle guard.
179
180
  const alreadyHasNameserver = /^[ \t]*nameserver[ \t]*=/m.test(content);
180
181
 
181
- const openLineRe = /^([ \t]*)resource\s+"proxmox_lxc"\s+"[^"]+"\s*\{[ \t]*$/gm;
182
- return content.replace(openLineRe, (openLine, indent: string) => {
182
+ const openLineRe = /^([ \t]*)resource\s+"(proxmox_lxc|proxmox_vm_qemu)"\s+"[^"]+"\s*\{[ \t]*$/gm;
183
+ return content.replace(openLineRe, (openLine, indent: string, resourceType: string) => {
183
184
  const inner = `${indent} `;
184
185
  const injected = [openLine];
185
186
  if (hasNameserver && !alreadyHasNameserver) {
186
187
  injected.push(`${inner}nameserver = "$self:lxc_nameserver"`);
187
188
  }
188
189
  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
- );
190
+ // The create-time / ForceNew attributes Proxmox assigns, per resource type.
191
+ // Listing them makes an unchanged redeploy a no-op and a real change an
192
+ // in-place UPDATE, never a destructive REPLACE. A non-existent attribute
193
+ // here fails `terraform validate`, so each list is exactly the schema
194
+ // attributes the resource actually has.
195
+ // LXC (ISS-0055/0089): the create-time MAC + rootfs volume, plus
196
+ // `ssh_public_keys` (ForceNew a fleet-key rotation must not REPLACE the
197
+ // container). `rootfs.size` is NOT ignored, so a disk grow still applies.
198
+ // We do NOT ignore `target_node` a node change is real (migration,
199
+ // ISS-0090), never silently dropped.
200
+ // VM (telmate 3.x proxmox_vm_qemu): `clone` (ForceNew clone source),
201
+ // `sshkeys` + `nameserver` (ForceNew cloud-init), and the NIC `macaddr`.
202
+ // Initial set from the provider schema extend if a first real VM deploy
203
+ // surfaces another spurious-REPLACE attribute, exactly as ISS-0055 did for
204
+ // the LXC list.
205
+ const ignore =
206
+ resourceType === 'proxmox_vm_qemu'
207
+ ? 'nameserver, network[0].macaddr, sshkeys, clone'
208
+ : 'nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys';
209
+ injected.push(`${inner} ignore_changes = [${ignore}]`);
208
210
  injected.push(`${inner}}`);
209
211
  return injected.join('\n');
210
212
  });
@@ -780,6 +782,23 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
780
782
  };
781
783
  }
782
784
 
785
+ // Policy: fail loud at the source on a broken capability chain. A
786
+ // required `source: capability` var whose chain doesn't resolve is
787
+ // silently dropped during derivation, then surfaces downstream as a
788
+ // cryptic `$self:<x> not found` in some template. Assert it here against
789
+ // the resolved capabilities map so generate names the broken link and
790
+ // the provider to redeploy (ISS-0115; the data-plane sibling of ISS-0088).
791
+ const capProblems = findBrokenCapabilityDerivations(moduleId, manifest, context.capabilities);
792
+ if (capProblems.length > 0) {
793
+ return {
794
+ success: false,
795
+ error: `Cannot generate '${moduleId}': ${capProblems.length} capability-derived variable(s) won't resolve:\n${capProblems
796
+ .map((p) => ` - ${describeCapabilityProblem(p)}`)
797
+ .join('\n')}`,
798
+ details: capProblems,
799
+ };
800
+ }
801
+
783
802
  // Execution: Store derived variables in module_configs
784
803
  // Variables with derive_from are resolved in the context but not stored in module_configs.
785
804
  // Store them so hooks and host_vars can access them.
@@ -987,7 +1006,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
987
1006
  // every proxmox_lxc resource before resolution (terraform files only).
988
1007
  const content =
989
1008
  !isAnsibleTemplate && template.targetPath.endsWith('.tf')
990
- ? injectProxmoxLxcDns(template.content, Boolean(context.selfConfig.lxc_nameserver))
1009
+ ? injectProxmoxDns(template.content, Boolean(context.selfConfig.lxc_nameserver))
991
1010
  : template.content;
992
1011
  const result = isAnsibleTemplate
993
1012
  ? 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