@celilo/cli 0.4.0-alpha.1 → 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.
Files changed (55) hide show
  1. package/drizzle/0008_aspect_consent.sql +1 -0
  2. package/drizzle/meta/_journal.json +7 -0
  3. package/package.json +5 -6
  4. package/src/cli/command-registry.ts +38 -0
  5. package/src/cli/commands/backup-pull.test.ts +48 -0
  6. package/src/cli/commands/backup-pull.ts +116 -0
  7. package/src/cli/commands/events.test.ts +108 -0
  8. package/src/cli/commands/events.ts +243 -0
  9. package/src/cli/commands/module-generate.ts +5 -4
  10. package/src/cli/commands/module-import-aspect.test.ts +116 -0
  11. package/src/cli/commands/module-import.ts +12 -1
  12. package/src/cli/commands/storage-add-s3.ts +91 -46
  13. package/src/cli/completion.ts +2 -1
  14. package/src/cli/index.ts +11 -0
  15. package/src/db/client.ts +4 -0
  16. package/src/db/schema.ts +9 -1
  17. package/src/hooks/capability-loader.test.ts +31 -1
  18. package/src/hooks/capability-loader.ts +65 -16
  19. package/src/manifest/contracts/v1.ts +12 -0
  20. package/src/manifest/schema.ts +13 -1
  21. package/src/manifest/template-validator.ts +1 -0
  22. package/src/module/packaging/build.test.ts +75 -0
  23. package/src/module/packaging/build.ts +9 -20
  24. package/src/module/packaging/package-rules.ts +44 -0
  25. package/src/secrets/generators.test.ts +14 -1
  26. package/src/secrets/generators.ts +63 -1
  27. package/src/services/aspect-approvals.test.ts +30 -10
  28. package/src/services/aspect-approvals.ts +61 -31
  29. package/src/services/aspect-runner.test.ts +161 -8
  30. package/src/services/aspect-runner.ts +156 -34
  31. package/src/services/backup-create.ts +11 -2
  32. package/src/services/bus-ensure-flow.test.ts +19 -1
  33. package/src/services/bus-interview.ts +56 -0
  34. package/src/services/bus-secret-flow.test.ts +19 -1
  35. package/src/services/celilo-events.test.ts +122 -0
  36. package/src/services/celilo-events.ts +144 -0
  37. package/src/services/celilo-mgmt-hooks.test.ts +9 -1
  38. package/src/services/config-interview.ts +38 -19
  39. package/src/services/deploy-planner.test.ts +66 -0
  40. package/src/services/deploy-planner.ts +16 -2
  41. package/src/services/deploy-preflight.ts +18 -1
  42. package/src/services/deployed-systems.ts +30 -1
  43. package/src/services/dns-provider-backfill.test.ts +150 -0
  44. package/src/services/dns-provider-backfill.ts +72 -2
  45. package/src/services/e2e-guard.test.ts +38 -0
  46. package/src/services/e2e-guard.ts +43 -0
  47. package/src/services/module-deploy.ts +12 -26
  48. package/src/services/responder-probe.test.ts +87 -0
  49. package/src/services/responder-probe.ts +29 -0
  50. package/src/services/restore-from-file.ts +16 -6
  51. package/src/services/storage-providers/s3.test.ts +101 -0
  52. package/src/templates/generator.test.ts +77 -0
  53. package/src/templates/generator.ts +69 -2
  54. package/src/variables/context.ts +34 -0
  55. 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(template.content, context, db)
838
- : await resolveTemplate(template.content, context, db);
904
+ ? await convertSecretsToJinja(content, context, db)
905
+ : await resolveTemplate(content, context, db);
839
906
 
840
907
  if (!result.success) {
841
908
  resolutionErrors.push({
@@ -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
+ });