@celilo/cli 0.4.0-alpha.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drizzle/0008_aspect_consent.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -6
- package/src/cli/command-registry.ts +38 -0
- package/src/cli/commands/backup-pull.test.ts +48 -0
- package/src/cli/commands/backup-pull.ts +116 -0
- package/src/cli/commands/events.test.ts +108 -0
- package/src/cli/commands/events.ts +243 -0
- package/src/cli/commands/module-generate.ts +5 -4
- package/src/cli/commands/module-import-aspect.test.ts +116 -0
- package/src/cli/commands/module-import.ts +12 -1
- package/src/cli/commands/storage-add-s3.ts +91 -46
- package/src/cli/completion.ts +2 -1
- package/src/cli/index.ts +11 -0
- package/src/db/client.ts +4 -0
- package/src/db/schema.ts +9 -1
- package/src/hooks/capability-loader.test.ts +31 -1
- package/src/hooks/capability-loader.ts +65 -16
- package/src/manifest/contracts/v1.ts +12 -0
- package/src/manifest/schema.ts +13 -1
- package/src/manifest/template-validator.ts +1 -0
- package/src/module/packaging/build.test.ts +75 -0
- package/src/module/packaging/build.ts +9 -20
- package/src/module/packaging/package-rules.ts +44 -0
- package/src/secrets/generators.test.ts +14 -1
- package/src/secrets/generators.ts +63 -1
- package/src/services/aspect-approvals.test.ts +30 -10
- package/src/services/aspect-approvals.ts +61 -31
- package/src/services/aspect-runner.test.ts +161 -8
- package/src/services/aspect-runner.ts +156 -34
- package/src/services/backup-create.ts +11 -2
- package/src/services/bus-ensure-flow.test.ts +19 -1
- package/src/services/bus-interview.ts +56 -0
- package/src/services/bus-secret-flow.test.ts +19 -1
- package/src/services/celilo-events.test.ts +122 -0
- package/src/services/celilo-events.ts +144 -0
- package/src/services/celilo-mgmt-hooks.test.ts +9 -1
- package/src/services/config-interview.ts +38 -19
- package/src/services/deploy-planner.test.ts +66 -0
- package/src/services/deploy-planner.ts +16 -2
- package/src/services/deploy-preflight.ts +18 -1
- package/src/services/deployed-systems.ts +30 -1
- package/src/services/dns-provider-backfill.test.ts +150 -0
- package/src/services/dns-provider-backfill.ts +72 -2
- package/src/services/e2e-guard.test.ts +38 -0
- package/src/services/e2e-guard.ts +43 -0
- package/src/services/module-deploy.ts +12 -26
- package/src/services/responder-probe.test.ts +87 -0
- package/src/services/responder-probe.ts +29 -0
- package/src/services/restore-from-file.ts +16 -6
- package/src/services/storage-providers/s3.test.ts +101 -0
- package/src/templates/generator.test.ts +77 -0
- package/src/templates/generator.ts +69 -2
- package/src/variables/context.ts +34 -0
- package/src/variables/lxc-nameserver.test.ts +86 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
discoverTemplateFiles,
|
|
10
10
|
generateTemplates,
|
|
11
11
|
getOutputFilename,
|
|
12
|
+
injectProxmoxLxcDns,
|
|
12
13
|
isTemplateFile,
|
|
13
14
|
readTemplateFiles,
|
|
14
15
|
writeGeneratedFiles,
|
|
@@ -633,4 +634,80 @@ resource "proxmox_lxc" "container" {
|
|
|
633
634
|
}
|
|
634
635
|
});
|
|
635
636
|
});
|
|
637
|
+
|
|
638
|
+
describe('injectProxmoxLxcDns', () => {
|
|
639
|
+
const LXC = [
|
|
640
|
+
'resource "proxmox_lxc" "caddy" {',
|
|
641
|
+
' target_node = "pve"',
|
|
642
|
+
' start = true',
|
|
643
|
+
' network {',
|
|
644
|
+
' bridge = "vmbr0"',
|
|
645
|
+
' }',
|
|
646
|
+
'}',
|
|
647
|
+
].join('\n');
|
|
648
|
+
|
|
649
|
+
test('injects nameserver + lifecycle when a nameserver is computable', () => {
|
|
650
|
+
const out = injectProxmoxLxcDns(LXC, true);
|
|
651
|
+
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
652
|
+
expect(out).toContain(' lifecycle {');
|
|
653
|
+
expect(out).toContain(' ignore_changes = [nameserver]');
|
|
654
|
+
// Injected immediately after the opening line, before author attributes.
|
|
655
|
+
const lines = out.split('\n');
|
|
656
|
+
expect(lines[0]).toBe('resource "proxmox_lxc" "caddy" {');
|
|
657
|
+
expect(lines[1]).toBe(' nameserver = "$self:lxc_nameserver"');
|
|
658
|
+
expect(lines[2]).toBe(' lifecycle {');
|
|
659
|
+
// Author's attributes and nested blocks are untouched.
|
|
660
|
+
expect(out).toContain(' target_node = "pve"');
|
|
661
|
+
expect(out).toContain(' network {');
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('injects lifecycle only (no nameserver) when none is computable', () => {
|
|
665
|
+
const out = injectProxmoxLxcDns(LXC, false);
|
|
666
|
+
expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
|
|
667
|
+
expect(out).toContain(' lifecycle {');
|
|
668
|
+
expect(out).toContain(' ignore_changes = [nameserver]');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test('is idempotent — already-injected content is returned unchanged', () => {
|
|
672
|
+
const once = injectProxmoxLxcDns(LXC, true);
|
|
673
|
+
const twice = injectProxmoxLxcDns(once, true);
|
|
674
|
+
expect(twice).toBe(once);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('does not double-inject nameserver when the template already has one', () => {
|
|
678
|
+
// A stale copied module (or an author-set nameserver) — injecting a second
|
|
679
|
+
// would be a terraform "Attribute redefined" error.
|
|
680
|
+
const stale = [
|
|
681
|
+
'resource "proxmox_lxc" "caddy" {',
|
|
682
|
+
' target_node = "pve"',
|
|
683
|
+
' nameserver = "$self:lxc_nameserver"',
|
|
684
|
+
' start = true',
|
|
685
|
+
'}',
|
|
686
|
+
].join('\n');
|
|
687
|
+
const out = injectProxmoxLxcDns(stale, true);
|
|
688
|
+
// Exactly one nameserver attribute survives.
|
|
689
|
+
expect(out.match(/nameserver\s*=/g)?.length).toBe(1);
|
|
690
|
+
// The lifecycle guard is still added.
|
|
691
|
+
expect(out).toContain('ignore_changes = [nameserver]');
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test('leaves non-proxmox_lxc resources untouched', () => {
|
|
695
|
+
const vm = ['resource "proxmox_vm" "build" {', ' cores = 4', '}'].join('\n');
|
|
696
|
+
expect(injectProxmoxLxcDns(vm, true)).toBe(vm);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test('injects into every proxmox_lxc block in a multi-resource file', () => {
|
|
700
|
+
const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`;
|
|
701
|
+
const out = injectProxmoxLxcDns(two, true);
|
|
702
|
+
expect(out.match(/ignore_changes = \[nameserver\]/g)?.length).toBe(2);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test('matches the indentation of the resource opening line', () => {
|
|
706
|
+
const indented = [' resource "proxmox_lxc" "x" {', ' cores = 1', ' }'].join('\n');
|
|
707
|
+
const out = injectProxmoxLxcDns(indented, true);
|
|
708
|
+
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
709
|
+
expect(out).toContain(' lifecycle {');
|
|
710
|
+
expect(out).toContain(' ignore_changes = [nameserver]');
|
|
711
|
+
});
|
|
712
|
+
});
|
|
636
713
|
});
|
|
@@ -126,6 +126,67 @@ export function getOutputFilename(templateFilename: string): string {
|
|
|
126
126
|
return result;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Inject framework-owned DNS-at-birth into every `proxmox_lxc` resource.
|
|
131
|
+
*
|
|
132
|
+
* An LXC's nameserver is infrastructure celilo owns — like vmid, target_ip, and
|
|
133
|
+
* inventory — not something a module author hand-writes. Authors declare zero
|
|
134
|
+
* DNS terraform; this stamps it onto each `proxmox_lxc` block at generate time.
|
|
135
|
+
* For every block it emits:
|
|
136
|
+
*
|
|
137
|
+
* - `nameserver = "$self:lxc_nameserver"` — only when a nameserver is
|
|
138
|
+
* computable (`hasNameserver`). The first LXC, deployed before any
|
|
139
|
+
* `dns_internal` provider exists, has no value: it inherits the Proxmox
|
|
140
|
+
* node default and the `dns-client-config` aspect repairs resolv.conf
|
|
141
|
+
* post-deploy.
|
|
142
|
+
* - `lifecycle { ignore_changes = [nameserver] }` — always. Existing LXCs
|
|
143
|
+
* were born without a nameserver, so setting one is an in-place UPDATE the
|
|
144
|
+
* terraform-safety guard (create-only, see terraform-safety.ts) rejects.
|
|
145
|
+
* `ignore_changes` makes nameserver birth-only: terraform sets it at create
|
|
146
|
+
* and never diffs it again, so the guard never trips on a redeploy. The
|
|
147
|
+
* aspect owns the live resolv.conf from then on (terraform = birth DNS,
|
|
148
|
+
* aspect = ongoing DNS). See v2/LXC_INTERNAL_DNS.md.
|
|
149
|
+
*
|
|
150
|
+
* Anchored on the resource's opening line — it never brace-matches the nested
|
|
151
|
+
* rootfs/network/features blocks, so it's robust to attribute order/formatting.
|
|
152
|
+
* Idempotent at file granularity: a `.tf` that already declares
|
|
153
|
+
* `ignore_changes = [nameserver]` (re-run, or an author who opted in) is
|
|
154
|
+
* returned untouched.
|
|
155
|
+
*
|
|
156
|
+
* Policy function (Rule 10.1) - pure string transformation, no I/O.
|
|
157
|
+
*
|
|
158
|
+
* @param content - Raw terraform template content (pre variable-resolution)
|
|
159
|
+
* @param hasNameserver - Whether `$self:lxc_nameserver` resolves to a value
|
|
160
|
+
* @returns Content with DNS injected into each proxmox_lxc resource
|
|
161
|
+
*/
|
|
162
|
+
export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): string {
|
|
163
|
+
// Already injected (idempotent) or author opted into the lifecycle — done.
|
|
164
|
+
if (content.includes('ignore_changes = [nameserver]')) {
|
|
165
|
+
return content;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A template that still carries a `nameserver = …` attribute — a stale copied
|
|
169
|
+
// module from before the per-template lines were reverted, or an author who
|
|
170
|
+
// set it by hand — already supplies the value. Injecting a second `nameserver`
|
|
171
|
+
// is a terraform "Attribute redefined" error. So skip the value line when one
|
|
172
|
+
// exists and just add the lifecycle guard (the load-bearing part). Both cases
|
|
173
|
+
// converge on exactly one nameserver + ignore_changes.
|
|
174
|
+
const alreadyHasNameserver = /^[ \t]*nameserver[ \t]*=/m.test(content);
|
|
175
|
+
|
|
176
|
+
const openLineRe = /^([ \t]*)resource\s+"proxmox_lxc"\s+"[^"]+"\s*\{[ \t]*$/gm;
|
|
177
|
+
return content.replace(openLineRe, (openLine, indent: string) => {
|
|
178
|
+
const inner = `${indent} `;
|
|
179
|
+
const injected = [openLine];
|
|
180
|
+
if (hasNameserver && !alreadyHasNameserver) {
|
|
181
|
+
injected.push(`${inner}nameserver = "$self:lxc_nameserver"`);
|
|
182
|
+
}
|
|
183
|
+
injected.push(`${inner}lifecycle {`);
|
|
184
|
+
injected.push(`${inner} ignore_changes = [nameserver]`);
|
|
185
|
+
injected.push(`${inner}}`);
|
|
186
|
+
return injected.join('\n');
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
129
190
|
/**
|
|
130
191
|
* Discover template files in directory recursively
|
|
131
192
|
*
|
|
@@ -833,9 +894,15 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
833
894
|
} else {
|
|
834
895
|
// Template files (.tpl, .j2) - apply variable resolution
|
|
835
896
|
const isAnsibleTemplate = template.targetPath.includes('ansible/');
|
|
897
|
+
// Framework-owned DNS-at-birth: stamp nameserver + ignore_changes onto
|
|
898
|
+
// every proxmox_lxc resource before resolution (terraform files only).
|
|
899
|
+
const content =
|
|
900
|
+
!isAnsibleTemplate && template.targetPath.endsWith('.tf')
|
|
901
|
+
? injectProxmoxLxcDns(template.content, Boolean(context.selfConfig.lxc_nameserver))
|
|
902
|
+
: template.content;
|
|
836
903
|
const result = isAnsibleTemplate
|
|
837
|
-
? await convertSecretsToJinja(
|
|
838
|
-
: await resolveTemplate(
|
|
904
|
+
? await convertSecretsToJinja(content, context, db)
|
|
905
|
+
: await resolveTemplate(content, context, db);
|
|
839
906
|
|
|
840
907
|
if (!result.success) {
|
|
841
908
|
resolutionErrors.push({
|
package/src/variables/context.ts
CHANGED
|
@@ -471,6 +471,40 @@ export async function buildResolutionContext(
|
|
|
471
471
|
systemConfigMap[row.key] = row.value;
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
// LXC nameserver list (v2/LXC_INTERNAL_DNS.md). Proxmox's `nameserver` is a
|
|
475
|
+
// space-separated primary/secondary list. Compose it so every celilo-built
|
|
476
|
+
// LXC boots with a working resolver — before Ansible's apt update, and
|
|
477
|
+
// independent of which Proxmox node it lands on (the nodes' DNS defaults
|
|
478
|
+
// diverge): the internal dns_internal resolver first (it recurses, so it
|
|
479
|
+
// also answers external names), then the public resolvers as fallback.
|
|
480
|
+
// Bootstrap (no dns_internal provider registered yet — e.g. the DNS module's
|
|
481
|
+
// own LXC) → public only, so apt still works. Injected as
|
|
482
|
+
// $self:lxc_nameserver, mirroring how inventory.* are auto-derived; the
|
|
483
|
+
// proxmox_lxc terraform template drops it straight into `nameserver`.
|
|
484
|
+
{
|
|
485
|
+
const publicDns: string[] = [];
|
|
486
|
+
for (const key of ['dns.primary', 'dns.fallback']) {
|
|
487
|
+
const raw = systemConfigMap[key];
|
|
488
|
+
if (raw) {
|
|
489
|
+
for (const part of raw.split(',')) {
|
|
490
|
+
const ip = part.trim();
|
|
491
|
+
if (ip) publicDns.push(ip);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const dnsInternal = capabilitiesMap.dns_internal as { server?: { ip?: unknown } } | undefined;
|
|
496
|
+
// The dns_internal capability advertises the provider's address, which may
|
|
497
|
+
// carry a CIDR suffix (technitium's server.ip resolves from target_ip,
|
|
498
|
+
// e.g. "192.168.0.151/24"). A nameserver must be a bare IP — strip it.
|
|
499
|
+
const rawInternalIp =
|
|
500
|
+
typeof dnsInternal?.server?.ip === 'string' ? dnsInternal.server.ip : undefined;
|
|
501
|
+
const internalIp = rawInternalIp ? rawInternalIp.split('/')[0] : undefined;
|
|
502
|
+
const nameservers = internalIp ? [internalIp, ...publicDns] : publicDns;
|
|
503
|
+
if (nameservers.length > 0) {
|
|
504
|
+
selfConfig.lxc_nameserver = nameservers.join(' ');
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
474
508
|
// Fetch system secrets (for $system_secret: variables)
|
|
475
509
|
const systemSecretsMap: Record<string, string> = {};
|
|
476
510
|
try {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
4
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
5
|
+
import { capabilities, moduleConfigs, modules, systemConfig } from '../db/schema';
|
|
6
|
+
import { buildResolutionContext } from './context';
|
|
7
|
+
|
|
8
|
+
const TEST_DB_PATH = './test-lxc-nameserver.db';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Coverage for the generate-time `lxc_nameserver` composition
|
|
12
|
+
* (v2/LXC_INTERNAL_DNS.md): proxmox_lxc templates read `$self:lxc_nameserver`,
|
|
13
|
+
* a space-separated primary/secondary list = internal dns_internal resolver
|
|
14
|
+
* first (CIDR stripped), then the public dns.primary/dns.fallback resolvers;
|
|
15
|
+
* public-only when no dns_internal provider is registered (bootstrap).
|
|
16
|
+
*/
|
|
17
|
+
describe('lxc_nameserver composition', () => {
|
|
18
|
+
let db: DbClient;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
db = createDbClient({ path: TEST_DB_PATH });
|
|
22
|
+
db.insert(modules)
|
|
23
|
+
.values({
|
|
24
|
+
id: 'consumer',
|
|
25
|
+
name: 'consumer',
|
|
26
|
+
version: '1.0.0',
|
|
27
|
+
manifestData: { requires: { machine: { zone: 'app' } } },
|
|
28
|
+
sourcePath: '/tmp/consumer',
|
|
29
|
+
})
|
|
30
|
+
.run();
|
|
31
|
+
db.insert(moduleConfigs)
|
|
32
|
+
.values({ moduleId: 'consumer', key: 'hostname', value: 'consumer', valueJson: '"consumer"' })
|
|
33
|
+
.run();
|
|
34
|
+
for (const [key, value] of [
|
|
35
|
+
['dns.primary', '1.1.1.1'],
|
|
36
|
+
['dns.fallback', '1.0.0.1,8.8.8.8'],
|
|
37
|
+
]) {
|
|
38
|
+
db.insert(systemConfig).values({ key, value }).run();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
db.$client.close();
|
|
44
|
+
for (const suffix of ['', '-shm', '-wal']) {
|
|
45
|
+
const p = `${TEST_DB_PATH}${suffix}`;
|
|
46
|
+
if (existsSync(p)) await rm(p);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function registerInternalDns(ip: string): void {
|
|
51
|
+
db.insert(modules)
|
|
52
|
+
.values({
|
|
53
|
+
id: 'dns-provider',
|
|
54
|
+
name: 'dns-provider',
|
|
55
|
+
version: '1.0.0',
|
|
56
|
+
manifestData: {},
|
|
57
|
+
sourcePath: '/tmp/dns',
|
|
58
|
+
})
|
|
59
|
+
.run();
|
|
60
|
+
db.insert(capabilities)
|
|
61
|
+
.values({
|
|
62
|
+
moduleId: 'dns-provider',
|
|
63
|
+
capabilityName: 'dns_internal',
|
|
64
|
+
version: '1.0.0',
|
|
65
|
+
data: { server: { ip } },
|
|
66
|
+
})
|
|
67
|
+
.run();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('internal resolver first (CIDR stripped) + public fallback', async () => {
|
|
71
|
+
registerInternalDns('192.168.0.151/24');
|
|
72
|
+
const ctx = await buildResolutionContext('consumer', db);
|
|
73
|
+
expect(ctx.selfConfig.lxc_nameserver).toBe('192.168.0.151 1.1.1.1 1.0.0.1 8.8.8.8');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('bootstrap: no dns_internal provider → public resolvers only', async () => {
|
|
77
|
+
const ctx = await buildResolutionContext('consumer', db);
|
|
78
|
+
expect(ctx.selfConfig.lxc_nameserver).toBe('1.1.1.1 1.0.0.1 8.8.8.8');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('already-bare internal IP is passed through unchanged', async () => {
|
|
82
|
+
registerInternalDns('10.0.20.30');
|
|
83
|
+
const ctx = await buildResolutionContext('consumer', db);
|
|
84
|
+
expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.20.30 1.1.1.1 1.0.0.1 8.8.8.8');
|
|
85
|
+
});
|
|
86
|
+
});
|