@celilo/cli 3.1.0 → 4.1.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 (57) hide show
  1. package/CELILO_SUBSYSTEMS.md +1 -0
  2. package/package.json +5 -4
  3. package/src/cli/command-tree-parser.test.ts +18 -0
  4. package/src/cli/command-tree-parser.ts +12 -0
  5. package/src/cli/commands/api.ts +67 -14
  6. package/src/cli/commands/events.ts +5 -2
  7. package/src/cli/commands/machine-reclassify.ts +79 -0
  8. package/src/cli/commands/publish/workspace.ts +9 -0
  9. package/src/cli/commands/system-audit.ts +22 -0
  10. package/src/cli/commands/system-reboot.ts +126 -0
  11. package/src/cli/commands/system-update.ts +5 -0
  12. package/src/cli/completion.ts +3 -2
  13. package/src/cli/index.ts +19 -0
  14. package/src/cli/tui/audit-state.ts +4 -0
  15. package/src/hooks/broker.ts +31 -0
  16. package/src/hooks/capability-loader.ts +87 -44
  17. package/src/hooks/executor.ts +20 -2
  18. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  19. package/src/hooks/timeout-names-provider.test.ts +87 -0
  20. package/src/policy/module-business-baseline.ts +0 -6
  21. package/src/services/api-access.test.ts +44 -0
  22. package/src/services/audit/index.test.ts +7 -0
  23. package/src/services/audit/index.ts +15 -0
  24. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  25. package/src/services/audit/machine-interface-zones.ts +43 -0
  26. package/src/services/audit/reboot-pending.test.ts +63 -0
  27. package/src/services/audit/reboot-pending.ts +85 -0
  28. package/src/services/audit/recurrence-gate.test.ts +21 -0
  29. package/src/services/audit/server-bun-pin.test.ts +44 -0
  30. package/src/services/audit/server-bun-pin.ts +95 -0
  31. package/src/services/audit/types.ts +2 -0
  32. package/src/services/capability-compat.test.ts +22 -0
  33. package/src/services/capability-compat.ts +15 -2
  34. package/src/services/celilo-events.test.ts +39 -0
  35. package/src/services/celilo-events.ts +41 -4
  36. package/src/services/deploy-preflight.ts +12 -3
  37. package/src/services/deploy-validation.test.ts +81 -2
  38. package/src/services/deploy-validation.ts +19 -0
  39. package/src/services/fleet-checks.ts +40 -10
  40. package/src/services/machine-interface-zones.test.ts +73 -0
  41. package/src/services/machine-interface-zones.ts +101 -0
  42. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  43. package/src/services/module-deploy.ts +48 -13
  44. package/src/services/provider-arrival.test.ts +62 -46
  45. package/src/services/provider-arrival.ts +41 -10
  46. package/src/services/provider-converge.test.ts +16 -0
  47. package/src/services/provider-converge.ts +106 -4
  48. package/src/services/system-reboot.test.ts +231 -0
  49. package/src/services/system-reboot.ts +357 -0
  50. package/src/services/update/orchestrator.test.ts +164 -0
  51. package/src/services/update/orchestrator.ts +43 -12
  52. package/src/services/update/types.ts +10 -1
  53. package/src/templates/generator.test.ts +15 -4
  54. package/src/templates/generator.ts +12 -4
  55. package/src/variables/context.ts +14 -1
  56. package/src/services/static-content-converge.test.ts +0 -516
  57. package/src/services/static-content-converge.ts +0 -390
@@ -656,7 +656,7 @@ resource "proxmox_lxc" "container" {
656
656
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
657
657
  expect(out).toContain(' lifecycle {');
658
658
  expect(out).toContain(
659
- ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
659
+ ' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
660
660
  );
661
661
  // Injected immediately after the opening line, before author attributes.
662
662
  const lines = out.split('\n');
@@ -673,10 +673,21 @@ resource "proxmox_lxc" "container" {
673
673
  expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
674
674
  expect(out).toContain(' lifecycle {');
675
675
  expect(out).toContain(
676
- ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
676
+ ' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
677
677
  );
678
678
  });
679
679
 
680
+ // celilo#1402: `ostemplate` and `rootfs[0].storage` are ForceNew and do
681
+ // NOT round-trip through `terraform import` — Proxmox reports neither. Drop
682
+ // them from the ignore list and every import of an existing container
683
+ // computes a REPLACE, which the post-import guard refuses, leaving the
684
+ // container redeployable by no route at all.
685
+ test('ignores the ForceNew attributes an import cannot recover', () => {
686
+ const out = injectProxmoxDns(LXC, true);
687
+ expect(out).toContain('ostemplate');
688
+ expect(out).toContain('rootfs[0].storage');
689
+ });
690
+
680
691
  test('is idempotent — already-injected content is returned unchanged', () => {
681
692
  const once = injectProxmoxDns(LXC, true);
682
693
  const twice = injectProxmoxDns(once, true);
@@ -737,7 +748,7 @@ resource "proxmox_lxc" "container" {
737
748
  ].join('\n');
738
749
  const out = injectProxmoxDns(mixed, true);
739
750
  expect(out).toContain(
740
- 'ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
751
+ 'ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
741
752
  );
742
753
  expect(out).toContain('ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]');
743
754
  });
@@ -767,7 +778,7 @@ resource "proxmox_lxc" "container" {
767
778
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
768
779
  expect(out).toContain(' lifecycle {');
769
780
  expect(out).toContain(
770
- ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
781
+ ' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
771
782
  );
772
783
  });
773
784
  });
@@ -199,9 +199,17 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
199
199
  // in-place UPDATE, never a destructive REPLACE. A non-existent attribute
200
200
  // here fails `terraform validate`, so each list is exactly the schema
201
201
  // attributes the resource actually has.
202
- // LXC (ISS-0055/0089): the create-time MAC + rootfs volume, plus
203
- // `ssh_public_keys` (ForceNew — a fleet-key rotation must not REPLACE the
204
- // container). `rootfs.size` is NOT ignored, so a disk grow still applies.
202
+ // LXC (ISS-0055/0089): the create-time MAC, plus `ssh_public_keys`
203
+ // (ForceNew — a fleet-key rotation must not REPLACE the container), plus
204
+ // the two ForceNew attributes an IMPORT cannot recover: `ostemplate`
205
+ // (Proxmox never reports the source template) and `rootfs[0].storage`
206
+ // (import records the volume, not the config's storage name). Without
207
+ // them every import of an existing container computes a REPLACE, the
208
+ // post-import guard refuses it, and the container becomes redeployable
209
+ // by no route at all (celilo#1402). `rootfs[0].volume` was listed here
210
+ // and terraform reported it "Redundant ... has no effect" — it is
211
+ // computed, never configured — so it is gone.
212
+ // `rootfs.size` is NOT ignored, so a disk grow still applies.
205
213
  // We do NOT ignore `target_node` — a node change is real (migration,
206
214
  // ISS-0090), never silently dropped.
207
215
  // VM (telmate 3.x proxmox_vm_qemu): `clone` (ForceNew clone source),
@@ -212,7 +220,7 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
212
220
  const ignore =
213
221
  resourceType === 'proxmox_vm_qemu'
214
222
  ? 'nameserver, network[0].macaddr, sshkeys, clone'
215
- : 'nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys';
223
+ : 'nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys';
216
224
  injected.push(`${inner} ignore_changes = [${ignore}]`);
217
225
  injected.push(`${inner}}`);
218
226
  return injected.join('\n');
@@ -423,8 +423,21 @@ async function assembleResolutionContext(
423
423
  const deployedZone = machineRow.zone ?? zone;
424
424
  const machineIdentity = resolveMachineIdentityAddress(machineRow, deployedZone);
425
425
  if (!machineIdentity) {
426
+ // Distinguish "no such interface" from "the interface is there under
427
+ // a stale zone label" — the second is what a zone-model change
428
+ // produces, and its remediation is a re-classification, not new
429
+ // hardware (celilo#1382).
430
+ const { diffInterfaceZones, explainMissingZoneInterface } = await import(
431
+ '../services/machine-interface-zones'
432
+ );
433
+ const { detectZoneFromIp } = await import('../services/zone-detector');
434
+ const changes = await diffInterfaceZones(machineRow.interfaces, detectZoneFromIp);
426
435
  throw new Error(
427
- `Machine '${infraSelection.machineId}' for module '${moduleId}' has no non-loopback interface in zone '${deployedZone}'`,
436
+ `Machine '${machineRow.hostname}' for module '${moduleId}' ${explainMissingZoneInterface(
437
+ machineRow.interfaces,
438
+ deployedZone,
439
+ changes,
440
+ )}`,
428
441
  );
429
442
  }
430
443
  upsertDeployedSystem(db, moduleId, {
@@ -1,516 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
- import {
3
- cpSync,
4
- existsSync,
5
- mkdirSync,
6
- mkdtempSync,
7
- readFileSync,
8
- rmSync,
9
- writeFileSync,
10
- } from 'node:fs';
11
- import { tmpdir } from 'node:os';
12
- import { join, resolve } from 'node:path';
13
- import { eq } from 'drizzle-orm';
14
- import type { DbClient } from '../db/client';
15
- import { moduleConfigs, modules, webRoutes } from '../db/schema';
16
- import { setupTestDatabaseAt } from '../test-utils/database';
17
- import { resetTestDbPath } from '../test-utils/db-path';
18
- import {
19
- ansibleTreeMentions,
20
- buildStaticContentPlan,
21
- planStaticContent,
22
- resolveStaticContentRetention,
23
- staticContentVarsPath,
24
- staticContentVarsYaml,
25
- writeStaticContentVars,
26
- } from './static-content-converge';
27
-
28
- const HASH1 = 'a'.repeat(64);
29
- const HASH2 = 'b'.repeat(64);
30
-
31
- describe('planStaticContent — the declared release set (D10 task 4.1)', () => {
32
- let dir: string;
33
- let db: DbClient;
34
-
35
- beforeEach(async () => {
36
- dir = mkdtempSync(join(tmpdir(), 'static-converge-'));
37
- const dbPath = join(dir, 'celilo.db');
38
- process.env.CELILO_DB_PATH = dbPath;
39
- db = await setupTestDatabaseAt(dbPath);
40
-
41
- // Two installed modules with real web roots on disk.
42
- writeSite('lunacycle', '<html>luna</html>');
43
- writeSite('hello-foo', '<html>foo</html>');
44
- db.insert(modules)
45
- .values({
46
- id: 'lunacycle',
47
- name: 'Lunacycle',
48
- version: '1.0.0',
49
- manifestData: {},
50
- sourcePath: join(dir, 'modules', 'lunacycle'),
51
- })
52
- .onConflictDoNothing()
53
- .run();
54
- db.insert(modules)
55
- .values({
56
- id: 'hello-foo',
57
- name: 'Hello Foo',
58
- version: '0.1.2',
59
- manifestData: {},
60
- sourcePath: join(dir, 'modules', 'hello-foo'),
61
- })
62
- .onConflictDoNothing()
63
- .run();
64
- });
65
-
66
- afterEach(() => {
67
- db.$client.close();
68
- resetTestDbPath();
69
- try {
70
- rmSync(dir, { recursive: true, force: true });
71
- } catch {
72
- /* ignore */
73
- }
74
- });
75
-
76
- function writeSite(moduleId: string, html: string): string {
77
- const webRoot = join(dir, 'modules', moduleId, 'site', 'dist');
78
- mkdirSync(webRoot, { recursive: true });
79
- writeFileSync(join(webRoot, 'index.html'), html, 'utf-8');
80
- return webRoot;
81
- }
82
-
83
- function insertRoute(route: {
84
- slug: string;
85
- moduleId: string;
86
- hostname: string;
87
- path?: string;
88
- type?: 'static' | 'reverse_proxy';
89
- contentHash?: string | null;
90
- }) {
91
- db.insert(webRoutes)
92
- .values({
93
- slug: route.slug,
94
- moduleId: route.moduleId,
95
- type: route.type ?? 'static',
96
- path: route.path ?? '/',
97
- hostname: route.hostname,
98
- websocket: false,
99
- contentHash: route.contentHash ?? HASH1,
100
- })
101
- .run();
102
- }
103
-
104
- it('groups by slug across hostnames and derives the source dir', () => {
105
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
106
- insertRoute({
107
- slug: 'lunacycle',
108
- moduleId: 'lunacycle',
109
- hostname: 'www.example.com',
110
- path: '/x',
111
- });
112
- insertRoute({
113
- slug: 'hello-foo',
114
- moduleId: 'hello-foo',
115
- hostname: 'www.example.com',
116
- path: '/foo',
117
- });
118
-
119
- const releases = planStaticContent(db);
120
-
121
- // Reverse-proxy rows and rows without a hash are not releases.
122
- expect(releases).toHaveLength(2);
123
- const luna = releases.find((r) => r.slug === 'lunacycle');
124
- expect(luna?.hostnames).toEqual(['lunacycle.net', 'www.example.com']);
125
- expect(luna?.sourceDir).toBe(join(dir, 'modules', 'lunacycle', 'site', 'dist'));
126
- expect(luna?.contentHash).toBe(HASH1);
127
- });
128
-
129
- it('a slug claimed by two modules is a contradiction, not a merge', () => {
130
- insertRoute({ slug: 'about', moduleId: 'lunacycle', hostname: 'a.example.com' });
131
- insertRoute({ slug: 'about', moduleId: 'hello-foo', hostname: 'b.example.com' });
132
-
133
- expect(() => planStaticContent(db)).toThrow(/claimed by 2 modules/);
134
- });
135
-
136
- it('rows sharing a slug must share a hash', () => {
137
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
138
- insertRoute({
139
- slug: 'lunacycle',
140
- moduleId: 'lunacycle',
141
- hostname: 'www.example.com',
142
- path: '/x',
143
- contentHash: HASH2,
144
- });
145
-
146
- expect(() => planStaticContent(db)).toThrow(/content hashes/);
147
- });
148
-
149
- it('a missing web root fails loudly instead of converging an empty release', () => {
150
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
151
- rmSync(join(dir, 'modules', 'lunacycle', 'site', 'dist'), { recursive: true, force: true });
152
-
153
- expect(() => planStaticContent(db)).toThrow(/no built site/);
154
- });
155
-
156
- function pause(moduleId: string) {
157
- db.update(modules).set({ state: 'PAUSED' }).where(eq(modules.id, moduleId)).run();
158
- }
159
-
160
- it("a paused module's slug is left alone, even one that would throw, and the rest converge (celilo#1383)", () => {
161
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
162
- insertRoute({
163
- slug: 'lunacycle',
164
- moduleId: 'lunacycle',
165
- hostname: 'www.lunacycle.net',
166
- contentHash: HASH2,
167
- });
168
- rmSync(join(dir, 'modules', 'lunacycle', 'site', 'dist'), { recursive: true, force: true });
169
- insertRoute({
170
- slug: 'hello-foo',
171
- moduleId: 'hello-foo',
172
- hostname: 'www.example.com',
173
- path: '/foo',
174
- });
175
- pause('lunacycle');
176
-
177
- expect(planStaticContent(db).map((r) => r.slug)).toEqual(['hello-foo']);
178
- });
179
-
180
- it('a paused module that is publishing (an unpause redeploy) still converges its own slug', () => {
181
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
182
- pause('lunacycle');
183
-
184
- expect(planStaticContent(db, 'lunacycle').map((r) => r.slug)).toEqual(['lunacycle']);
185
- });
186
-
187
- it('a slug shared by a paused and an active module is still a contradiction', () => {
188
- insertRoute({ slug: 'about', moduleId: 'lunacycle', hostname: 'a.example.com' });
189
- insertRoute({ slug: 'about', moduleId: 'hello-foo', hostname: 'b.example.com' });
190
- pause('lunacycle');
191
-
192
- expect(() => planStaticContent(db)).toThrow(/claimed by 2 modules/);
193
- });
194
- });
195
-
196
- describe('resolveStaticContentRetention — operator config with a default', () => {
197
- let dir: string;
198
- let db: DbClient;
199
-
200
- beforeEach(async () => {
201
- dir = mkdtempSync(join(tmpdir(), 'static-retention-'));
202
- process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
203
- db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
204
- db.insert(modules)
205
- .values({
206
- id: 'caddy',
207
- name: 'Caddy',
208
- version: '2.3.8',
209
- manifestData: {
210
- variables: { owns: [{ name: 'static_release_retention', default: 5 }] },
211
- },
212
- sourcePath: join(dir, 'modules', 'caddy'),
213
- })
214
- .onConflictDoNothing()
215
- .run();
216
- });
217
-
218
- afterEach(() => {
219
- db.$client.close();
220
- resetTestDbPath();
221
- try {
222
- rmSync(dir, { recursive: true, force: true });
223
- } catch {
224
- /* ignore */
225
- }
226
- });
227
-
228
- it('operator config wins over the manifest default', () => {
229
- db.insert(moduleConfigs)
230
- .values({ moduleId: 'caddy', key: 'static_release_retention', value: '2', valueJson: '2' })
231
- .run();
232
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(2);
233
- });
234
-
235
- it('falls back to the manifest default, then the constant', () => {
236
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
237
-
238
- db.update(modules).set({ manifestData: {} }).where(eq(modules.id, 'caddy')).run();
239
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
240
- });
241
-
242
- it('rejects a retention that cannot keep the current release', () => {
243
- db.insert(moduleConfigs)
244
- .values({ moduleId: 'caddy', key: 'static_release_retention', value: '0', valueJson: '0' })
245
- .run();
246
- expect(() => resolveStaticContentRetention(db, 'caddy')).toThrow(/positive integer/);
247
- });
248
- });
249
-
250
- describe('staticContentVarsYaml — deterministic desired state', () => {
251
- it('carries slug, content_hash, hostnames and the derived source dir, keyed by slug', () => {
252
- const yaml = staticContentVarsYaml({
253
- retention: 3,
254
- releases: [
255
- {
256
- slug: 'hello-foo',
257
- contentHash: HASH2,
258
- hostnames: ['www.example.com'],
259
- sourceDir: '/data/modules/hello-foo/site/dist',
260
- },
261
- {
262
- slug: 'lunacycle',
263
- contentHash: HASH1,
264
- hostnames: ['lunacycle.net', 'www.example.com'],
265
- sourceDir: '/data/modules/lunacycle/site/dist',
266
- },
267
- ],
268
- });
269
-
270
- expect(yaml).toContain('static_release_retention: 3');
271
- expect(yaml).toContain('slug: lunacycle');
272
- expect(yaml).toContain(`content_hash: ${HASH1}`);
273
- expect(yaml).toContain('source_dir: /data/modules/lunacycle/site/dist');
274
- // Deterministic: same input, same bytes — the idempotence of the WRITE
275
- // depends on it.
276
- expect(yaml).toBe(
277
- staticContentVarsYaml({
278
- retention: 3,
279
- releases: [
280
- {
281
- slug: 'hello-foo',
282
- contentHash: HASH2,
283
- hostnames: ['www.example.com'],
284
- sourceDir: '/data/modules/hello-foo/site/dist',
285
- },
286
- {
287
- slug: 'lunacycle',
288
- contentHash: HASH1,
289
- hostnames: ['lunacycle.net', 'www.example.com'],
290
- sourceDir: '/data/modules/lunacycle/site/dist',
291
- },
292
- ],
293
- }),
294
- );
295
- });
296
- });
297
-
298
- describe('the state web overlay (celilo#1265)', () => {
299
- let dir: string;
300
- let db: DbClient;
301
-
302
- beforeEach(async () => {
303
- dir = mkdtempSync(join(tmpdir(), 'static-converge-'));
304
- const dbPath = join(dir, 'celilo.db');
305
- process.env.CELILO_DB_PATH = dbPath;
306
- db = await setupTestDatabaseAt(dbPath);
307
- writeSite('lunacycle', '<html>luna</html>');
308
- db.insert(modules)
309
- .values({
310
- id: 'lunacycle',
311
- name: 'Lunacycle',
312
- version: '1.0.0',
313
- manifestData: {},
314
- sourcePath: join(dir, 'modules', 'lunacycle'),
315
- })
316
- .onConflictDoNothing()
317
- .run();
318
- });
319
-
320
- afterEach(() => {
321
- db.$client.close();
322
- resetTestDbPath();
323
- try {
324
- rmSync(dir, { recursive: true, force: true });
325
- } catch {
326
- /* ignore */
327
- }
328
- });
329
-
330
- function writeSite(moduleId: string, html: string): string {
331
- const webRoot = join(dir, 'modules', moduleId, 'site', 'dist');
332
- mkdirSync(webRoot, { recursive: true });
333
- writeFileSync(join(webRoot, 'index.html'), html, 'utf-8');
334
- return webRoot;
335
- }
336
-
337
- function insertRoute(route: { slug: string; moduleId: string; hostname: string }) {
338
- db.insert(webRoutes)
339
- .values({
340
- slug: route.slug,
341
- moduleId: route.moduleId,
342
- type: 'static',
343
- path: '/',
344
- hostname: route.hostname,
345
- websocket: false,
346
- contentHash: HASH1,
347
- })
348
- .run();
349
- }
350
-
351
- it('a release with a state overlay carries its directory; one without does not', () => {
352
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
353
-
354
- // No overlay in state: the release names no overlay_dir, and the converge
355
- // role has nothing second to copy.
356
- const bare = planStaticContent(db);
357
- expect(bare).toHaveLength(1);
358
- expect(bare[0]?.overlayDir).toBeUndefined();
359
- expect(staticContentVarsYaml({ retention: 1, releases: bare })).not.toContain('overlay_dir');
360
-
361
- // The hook wrote generated files into `<module>/state/site`: the converge
362
- // must copy them over the release, or a rebuilt host loses the file.
363
- const overlay = join(dir, 'modules', 'lunacycle', 'state', 'site');
364
- mkdirSync(overlay, { recursive: true });
365
- writeFileSync(join(overlay, 'ca.crt'), '---CERT---', 'utf-8');
366
-
367
- const overlaid = planStaticContent(db);
368
- expect(overlaid[0]?.overlayDir).toBe(overlay);
369
- const yaml = staticContentVarsYaml({ retention: 1, releases: overlaid });
370
- expect(yaml).toContain('overlay_dir:');
371
- });
372
- });
373
-
374
- describe('writeStaticContentVars — lands in the generated inventory', () => {
375
- let dir: string;
376
- let db: DbClient;
377
-
378
- beforeEach(async () => {
379
- dir = mkdtempSync(join(tmpdir(), 'static-vars-'));
380
- process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
381
- db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
382
- mkdirSync(join(dir, 'modules', 'caddy', 'site', 'dist'), { recursive: true });
383
- writeFileSync(
384
- join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
385
- '<html>caddy</html>',
386
- 'utf-8',
387
- );
388
- db.insert(modules)
389
- .values({
390
- id: 'caddy',
391
- name: 'Caddy',
392
- version: '2.3.8',
393
- manifestData: {},
394
- sourcePath: join(dir, 'modules', 'caddy'),
395
- })
396
- .onConflictDoNothing()
397
- .run();
398
- });
399
-
400
- afterEach(() => {
401
- db.$client.close();
402
- resetTestDbPath();
403
- try {
404
- rmSync(dir, { recursive: true, force: true });
405
- } catch {
406
- /* ignore */
407
- }
408
- });
409
-
410
- it('writes the vars file and never writes into a module tree (task 4.7)', async () => {
411
- db.insert(webRoutes)
412
- .values({
413
- slug: 'caddy',
414
- moduleId: 'caddy',
415
- type: 'static',
416
- path: '/',
417
- hostname: 'www.example.com',
418
- websocket: false,
419
- contentHash: HASH1,
420
- })
421
- .run();
422
-
423
- const moduleTreeBefore = readFileSync(
424
- join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
425
- 'utf-8',
426
- );
427
- const result = await writeStaticContentVars(
428
- db,
429
- 'caddy',
430
- join(dir, 'modules', 'caddy', 'generated'),
431
- );
432
-
433
- expect(result.success).toBe(true);
434
- const varsPath = staticContentVarsPath(join(dir, 'modules', 'caddy', 'generated'));
435
- expect(existsSync(varsPath)).toBe(true);
436
- expect(readFileSync(varsPath, 'utf-8')).toContain('slug: caddy');
437
-
438
- // Task 4.7: the converge path is read-only over the module tree. The
439
- // clientConfig write into it is celilo#1018 and belongs to that thread —
440
- // THIS path must not add a second writer.
441
- expect(readFileSync(join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'), 'utf-8')).toBe(
442
- moduleTreeBefore,
443
- );
444
- });
445
-
446
- it('buildStaticContentPlan carries the resolved retention alongside the releases', async () => {
447
- db.insert(webRoutes)
448
- .values({
449
- slug: 'caddy',
450
- moduleId: 'caddy',
451
- type: 'static',
452
- path: '/',
453
- hostname: 'www.example.com',
454
- websocket: false,
455
- contentHash: HASH1,
456
- })
457
- .run();
458
-
459
- const plan = buildStaticContentPlan(db, 'caddy');
460
- expect(plan.retention).toBe(5);
461
- expect(plan.releases).toHaveLength(1);
462
- });
463
- });
464
-
465
- describe('the stale-project guard reaches the whole generated tree (celilo#1248)', () => {
466
- let dir: string;
467
-
468
- beforeEach(() => {
469
- dir = mkdtempSync(join(tmpdir(), 'static-guard-reach-'));
470
- });
471
- afterEach(() => {
472
- rmSync(dir, { recursive: true, force: true });
473
- });
474
-
475
- it('sees a converge that lives in a role task file, not in playbook.yml', () => {
476
- // Mirror DERIVED from the real module, never hand-written: a hand-built
477
- // tree would give confident reach data about a world that does not exist,
478
- // which is the same bug one level up.
479
- const real = resolve(import.meta.dir, '../../../..', 'modules/caddy/ansible');
480
- cpSync(real, join(dir, 'ansible'), { recursive: true });
481
-
482
- // The premise this guards. caddy's playbook is a thin hosts/vars/roles
483
- // stanza; if it ever DOES name static_releases, this test stops proving
484
- // anything and should be re-derived rather than deleted.
485
- const playbook = readFileSync(join(dir, 'ansible/playbook.yml.tpl'), 'utf-8');
486
- expect(playbook).not.toContain('static_releases');
487
-
488
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(true);
489
- });
490
-
491
- it('still refuses a project that mentions the var nowhere', () => {
492
- // A pre-converge generated project. Hand-built deliberately, and it has to
493
- // be: no deploy on today's code can produce a tree without the role tasks,
494
- // so this legacy shape is the fixture and not a shortcut.
495
- mkdirSync(join(dir, 'ansible/roles/caddy/tasks'), { recursive: true });
496
- writeFileSync(
497
- join(dir, 'ansible/playbook.yml'),
498
- '---\n- hosts: caddy\n roles:\n - caddy\n',
499
- );
500
- writeFileSync(
501
- join(dir, 'ansible/roles/caddy/tasks/main.yml'),
502
- '---\n- name: install\n debug: {}\n',
503
- );
504
-
505
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(false);
506
- });
507
-
508
- it('ignores non-text files rather than reading assets as utf-8', () => {
509
- mkdirSync(join(dir, 'ansible/files'), { recursive: true });
510
- writeFileSync(join(dir, 'ansible/files/logo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
511
- writeFileSync(join(dir, 'ansible/site.yml'), 'vars:\n static_releases: []\n');
512
-
513
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(true);
514
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'PNG')).toBe(false);
515
- });
516
- });