@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.
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/cli/command-registry.ts +13 -2
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +2 -2
- package/src/cli/commands/service-add-proxmox.ts +9 -0
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +7 -2
- package/src/config/paths.test.ts +61 -48
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +38 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +24 -15
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/schema.ts +7 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +2 -2
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +10 -10
- package/src/services/fleet-checks.test.ts +495 -0
- package/src/services/fleet-checks.ts +663 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/templates/generator.test.ts +62 -12
- package/src/templates/generator.ts +69 -50
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- 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(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
`${TEST_MODULE_DIR}/build
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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('
|
|
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 =
|
|
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 =
|
|
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 =
|
|
678
|
-
const twice =
|
|
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 =
|
|
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('
|
|
700
|
-
const vm = [
|
|
701
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
136
|
+
* Inject framework-owned DNS-at-birth into every Proxmox compute resource —
|
|
137
|
+
* `proxmox_lxc` and `proxmox_vm_qemu`.
|
|
133
138
|
*
|
|
134
|
-
*
|
|
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
|
|
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
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* post-deploy.
|
|
144
|
-
* - `lifecycle { ignore_changes = [
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
-
*
|
|
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
|
|
156
|
-
*
|
|
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
|
|
166
|
+
* @returns Content with DNS injected into each Proxmox compute resource
|
|
163
167
|
*/
|
|
164
|
-
export function
|
|
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
|
-
//
|
|
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
|
|
175
|
-
//
|
|
176
|
-
//
|
|
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
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
?
|
|
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('
|
|
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({ '
|
|
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(['
|
|
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(['
|
|
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: { '
|
|
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: '
|
|
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(['
|
|
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(['
|
|
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.
|
|
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: '
|
|
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('
|
|
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: '
|
|
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('
|
|
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: '
|
|
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@
|
|
359
|
+
expect(result).toBe('caddy.example.com@192.0.2.20');
|
|
360
360
|
});
|
|
361
361
|
});
|
|
362
362
|
|