@celilo/cli 3.0.0 → 4.0.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.
@@ -1,477 +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
-
157
- describe('resolveStaticContentRetention — operator config with a default', () => {
158
- let dir: string;
159
- let db: DbClient;
160
-
161
- beforeEach(async () => {
162
- dir = mkdtempSync(join(tmpdir(), 'static-retention-'));
163
- process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
164
- db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
165
- db.insert(modules)
166
- .values({
167
- id: 'caddy',
168
- name: 'Caddy',
169
- version: '2.3.8',
170
- manifestData: {
171
- variables: { owns: [{ name: 'static_release_retention', default: 5 }] },
172
- },
173
- sourcePath: join(dir, 'modules', 'caddy'),
174
- })
175
- .onConflictDoNothing()
176
- .run();
177
- });
178
-
179
- afterEach(() => {
180
- db.$client.close();
181
- resetTestDbPath();
182
- try {
183
- rmSync(dir, { recursive: true, force: true });
184
- } catch {
185
- /* ignore */
186
- }
187
- });
188
-
189
- it('operator config wins over the manifest default', () => {
190
- db.insert(moduleConfigs)
191
- .values({ moduleId: 'caddy', key: 'static_release_retention', value: '2', valueJson: '2' })
192
- .run();
193
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(2);
194
- });
195
-
196
- it('falls back to the manifest default, then the constant', () => {
197
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
198
-
199
- db.update(modules).set({ manifestData: {} }).where(eq(modules.id, 'caddy')).run();
200
- expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
201
- });
202
-
203
- it('rejects a retention that cannot keep the current release', () => {
204
- db.insert(moduleConfigs)
205
- .values({ moduleId: 'caddy', key: 'static_release_retention', value: '0', valueJson: '0' })
206
- .run();
207
- expect(() => resolveStaticContentRetention(db, 'caddy')).toThrow(/positive integer/);
208
- });
209
- });
210
-
211
- describe('staticContentVarsYaml — deterministic desired state', () => {
212
- it('carries slug, content_hash, hostnames and the derived source dir, keyed by slug', () => {
213
- const yaml = staticContentVarsYaml({
214
- retention: 3,
215
- releases: [
216
- {
217
- slug: 'hello-foo',
218
- contentHash: HASH2,
219
- hostnames: ['www.example.com'],
220
- sourceDir: '/data/modules/hello-foo/site/dist',
221
- },
222
- {
223
- slug: 'lunacycle',
224
- contentHash: HASH1,
225
- hostnames: ['lunacycle.net', 'www.example.com'],
226
- sourceDir: '/data/modules/lunacycle/site/dist',
227
- },
228
- ],
229
- });
230
-
231
- expect(yaml).toContain('static_release_retention: 3');
232
- expect(yaml).toContain('slug: lunacycle');
233
- expect(yaml).toContain(`content_hash: ${HASH1}`);
234
- expect(yaml).toContain('source_dir: /data/modules/lunacycle/site/dist');
235
- // Deterministic: same input, same bytes — the idempotence of the WRITE
236
- // depends on it.
237
- expect(yaml).toBe(
238
- staticContentVarsYaml({
239
- retention: 3,
240
- releases: [
241
- {
242
- slug: 'hello-foo',
243
- contentHash: HASH2,
244
- hostnames: ['www.example.com'],
245
- sourceDir: '/data/modules/hello-foo/site/dist',
246
- },
247
- {
248
- slug: 'lunacycle',
249
- contentHash: HASH1,
250
- hostnames: ['lunacycle.net', 'www.example.com'],
251
- sourceDir: '/data/modules/lunacycle/site/dist',
252
- },
253
- ],
254
- }),
255
- );
256
- });
257
- });
258
-
259
- describe('the state web overlay (celilo#1265)', () => {
260
- let dir: string;
261
- let db: DbClient;
262
-
263
- beforeEach(async () => {
264
- dir = mkdtempSync(join(tmpdir(), 'static-converge-'));
265
- const dbPath = join(dir, 'celilo.db');
266
- process.env.CELILO_DB_PATH = dbPath;
267
- db = await setupTestDatabaseAt(dbPath);
268
- writeSite('lunacycle', '<html>luna</html>');
269
- db.insert(modules)
270
- .values({
271
- id: 'lunacycle',
272
- name: 'Lunacycle',
273
- version: '1.0.0',
274
- manifestData: {},
275
- sourcePath: join(dir, 'modules', 'lunacycle'),
276
- })
277
- .onConflictDoNothing()
278
- .run();
279
- });
280
-
281
- afterEach(() => {
282
- db.$client.close();
283
- resetTestDbPath();
284
- try {
285
- rmSync(dir, { recursive: true, force: true });
286
- } catch {
287
- /* ignore */
288
- }
289
- });
290
-
291
- function writeSite(moduleId: string, html: string): string {
292
- const webRoot = join(dir, 'modules', moduleId, 'site', 'dist');
293
- mkdirSync(webRoot, { recursive: true });
294
- writeFileSync(join(webRoot, 'index.html'), html, 'utf-8');
295
- return webRoot;
296
- }
297
-
298
- function insertRoute(route: { slug: string; moduleId: string; hostname: string }) {
299
- db.insert(webRoutes)
300
- .values({
301
- slug: route.slug,
302
- moduleId: route.moduleId,
303
- type: 'static',
304
- path: '/',
305
- hostname: route.hostname,
306
- websocket: false,
307
- contentHash: HASH1,
308
- })
309
- .run();
310
- }
311
-
312
- it('a release with a state overlay carries its directory; one without does not', () => {
313
- insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
314
-
315
- // No overlay in state: the release names no overlay_dir, and the converge
316
- // role has nothing second to copy.
317
- const bare = planStaticContent(db);
318
- expect(bare).toHaveLength(1);
319
- expect(bare[0]?.overlayDir).toBeUndefined();
320
- expect(staticContentVarsYaml({ retention: 1, releases: bare })).not.toContain('overlay_dir');
321
-
322
- // The hook wrote generated files into `<module>/state/site`: the converge
323
- // must copy them over the release, or a rebuilt host loses the file.
324
- const overlay = join(dir, 'modules', 'lunacycle', 'state', 'site');
325
- mkdirSync(overlay, { recursive: true });
326
- writeFileSync(join(overlay, 'ca.crt'), '---CERT---', 'utf-8');
327
-
328
- const overlaid = planStaticContent(db);
329
- expect(overlaid[0]?.overlayDir).toBe(overlay);
330
- const yaml = staticContentVarsYaml({ retention: 1, releases: overlaid });
331
- expect(yaml).toContain('overlay_dir:');
332
- });
333
- });
334
-
335
- describe('writeStaticContentVars — lands in the generated inventory', () => {
336
- let dir: string;
337
- let db: DbClient;
338
-
339
- beforeEach(async () => {
340
- dir = mkdtempSync(join(tmpdir(), 'static-vars-'));
341
- process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
342
- db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
343
- mkdirSync(join(dir, 'modules', 'caddy', 'site', 'dist'), { recursive: true });
344
- writeFileSync(
345
- join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
346
- '<html>caddy</html>',
347
- 'utf-8',
348
- );
349
- db.insert(modules)
350
- .values({
351
- id: 'caddy',
352
- name: 'Caddy',
353
- version: '2.3.8',
354
- manifestData: {},
355
- sourcePath: join(dir, 'modules', 'caddy'),
356
- })
357
- .onConflictDoNothing()
358
- .run();
359
- });
360
-
361
- afterEach(() => {
362
- db.$client.close();
363
- resetTestDbPath();
364
- try {
365
- rmSync(dir, { recursive: true, force: true });
366
- } catch {
367
- /* ignore */
368
- }
369
- });
370
-
371
- it('writes the vars file and never writes into a module tree (task 4.7)', async () => {
372
- db.insert(webRoutes)
373
- .values({
374
- slug: 'caddy',
375
- moduleId: 'caddy',
376
- type: 'static',
377
- path: '/',
378
- hostname: 'www.example.com',
379
- websocket: false,
380
- contentHash: HASH1,
381
- })
382
- .run();
383
-
384
- const moduleTreeBefore = readFileSync(
385
- join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
386
- 'utf-8',
387
- );
388
- const result = await writeStaticContentVars(
389
- db,
390
- 'caddy',
391
- join(dir, 'modules', 'caddy', 'generated'),
392
- );
393
-
394
- expect(result.success).toBe(true);
395
- const varsPath = staticContentVarsPath(join(dir, 'modules', 'caddy', 'generated'));
396
- expect(existsSync(varsPath)).toBe(true);
397
- expect(readFileSync(varsPath, 'utf-8')).toContain('slug: caddy');
398
-
399
- // Task 4.7: the converge path is read-only over the module tree. The
400
- // clientConfig write into it is celilo#1018 and belongs to that thread —
401
- // THIS path must not add a second writer.
402
- expect(readFileSync(join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'), 'utf-8')).toBe(
403
- moduleTreeBefore,
404
- );
405
- });
406
-
407
- it('buildStaticContentPlan carries the resolved retention alongside the releases', async () => {
408
- db.insert(webRoutes)
409
- .values({
410
- slug: 'caddy',
411
- moduleId: 'caddy',
412
- type: 'static',
413
- path: '/',
414
- hostname: 'www.example.com',
415
- websocket: false,
416
- contentHash: HASH1,
417
- })
418
- .run();
419
-
420
- const plan = buildStaticContentPlan(db, 'caddy');
421
- expect(plan.retention).toBe(5);
422
- expect(plan.releases).toHaveLength(1);
423
- });
424
- });
425
-
426
- describe('the stale-project guard reaches the whole generated tree (celilo#1248)', () => {
427
- let dir: string;
428
-
429
- beforeEach(() => {
430
- dir = mkdtempSync(join(tmpdir(), 'static-guard-reach-'));
431
- });
432
- afterEach(() => {
433
- rmSync(dir, { recursive: true, force: true });
434
- });
435
-
436
- it('sees a converge that lives in a role task file, not in playbook.yml', () => {
437
- // Mirror DERIVED from the real module, never hand-written: a hand-built
438
- // tree would give confident reach data about a world that does not exist,
439
- // which is the same bug one level up.
440
- const real = resolve(import.meta.dir, '../../../..', 'modules/caddy/ansible');
441
- cpSync(real, join(dir, 'ansible'), { recursive: true });
442
-
443
- // The premise this guards. caddy's playbook is a thin hosts/vars/roles
444
- // stanza; if it ever DOES name static_releases, this test stops proving
445
- // anything and should be re-derived rather than deleted.
446
- const playbook = readFileSync(join(dir, 'ansible/playbook.yml.tpl'), 'utf-8');
447
- expect(playbook).not.toContain('static_releases');
448
-
449
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(true);
450
- });
451
-
452
- it('still refuses a project that mentions the var nowhere', () => {
453
- // A pre-converge generated project. Hand-built deliberately, and it has to
454
- // be: no deploy on today's code can produce a tree without the role tasks,
455
- // so this legacy shape is the fixture and not a shortcut.
456
- mkdirSync(join(dir, 'ansible/roles/caddy/tasks'), { recursive: true });
457
- writeFileSync(
458
- join(dir, 'ansible/playbook.yml'),
459
- '---\n- hosts: caddy\n roles:\n - caddy\n',
460
- );
461
- writeFileSync(
462
- join(dir, 'ansible/roles/caddy/tasks/main.yml'),
463
- '---\n- name: install\n debug: {}\n',
464
- );
465
-
466
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(false);
467
- });
468
-
469
- it('ignores non-text files rather than reading assets as utf-8', () => {
470
- mkdirSync(join(dir, 'ansible/files'), { recursive: true });
471
- writeFileSync(join(dir, 'ansible/files/logo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
472
- writeFileSync(join(dir, 'ansible/site.yml'), 'vars:\n static_releases: []\n');
473
-
474
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(true);
475
- expect(ansibleTreeMentions(join(dir, 'ansible'), 'PNG')).toBe(false);
476
- });
477
- });