@celilo/cli 1.2.0 → 1.3.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.
@@ -10,6 +10,7 @@
10
10
  import { eq } from 'drizzle-orm';
11
11
  import type { DbClient } from '../db/client';
12
12
  import { ipAllocations } from '../db/schema';
13
+ import { releaseIngressReservations } from './allocator';
13
14
 
14
15
  export interface IpamAllocation {
15
16
  moduleId: string;
@@ -258,6 +259,12 @@ export async function allocateForModule(
258
259
  * @returns True if allocation was removed, false if none existed
259
260
  */
260
261
  export async function deallocateForModule(moduleId: string, db: DbClient): Promise<boolean> {
262
+ // Ingress reservations are released FIRST and unconditionally (celilo#892).
263
+ // A module can hold one with no `ip_allocations` row at all — it deploys onto
264
+ // a machine rather than a celilo-provisioned container — so releasing it
265
+ // after the early return below would skip exactly the modules that leak.
266
+ await releaseIngressReservations(moduleId, db);
267
+
261
268
  // Check if allocation exists before deleting
262
269
  const existing = getAllocation(moduleId, db);
263
270
  if (!existing) {
@@ -0,0 +1,460 @@
1
+ /**
2
+ * The INBOUND direction — aspects applied to systems that have just come into
3
+ * existence (celilo#902).
4
+ *
5
+ * The outbound direction (`maybeRunAspectForTrigger`, one provider across the
6
+ * whole fleet) is covered in `aspect-runner.test.ts`. Nothing there could have
7
+ * caught this bug: the fan-out enumerates the fleet once, at the moment the
8
+ * providing module deploys, and every assertion is about systems that already
9
+ * existed at that instant.
10
+ *
11
+ * The runner is injected throughout, so these exercise the GATING — which
12
+ * provider's aspect runs on which host, and why — without driving Ansible.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { closeDb, getDb } from '../db/client';
20
+ import { runMigrations } from '../db/migrate';
21
+ import { type ModuleState, modules } from '../db/schema';
22
+ import type { BaseModuleAspect } from '../manifest/schema';
23
+ import {
24
+ computeAspectScopeHash,
25
+ recordAspectApproval,
26
+ recordAspectConsent,
27
+ } from './aspect-approvals';
28
+ import {
29
+ type AspectRunResult,
30
+ planAspectFanOut,
31
+ reconcileAspectsForSystems,
32
+ verifyAspectCoverage,
33
+ } from './aspect-runner';
34
+ import type { AnsibleHostRecap } from './deploy-ansible';
35
+ import { upsertDeployedSystem } from './deployed-systems';
36
+
37
+ const dnsAspect: BaseModuleAspect = {
38
+ ansible_role: 'dns-client-config',
39
+ applicable_zones: ['dmz', 'app', 'secure', 'internal'],
40
+ // Deliberately ONLY on_install: the inbound reconcile must run anyway
41
+ // (design D2), and this is the manifest shape both shipping aspects carry.
42
+ triggers: ['on_install'],
43
+ };
44
+
45
+ /** Seed a provider module with an aspect, approved unless told otherwise. */
46
+ function seedProvider(opts: {
47
+ moduleId: string;
48
+ aspect?: BaseModuleAspect;
49
+ state?: ModuleState;
50
+ approval?: 'approved' | 'denied' | 'none';
51
+ }): void {
52
+ const db = getDb();
53
+ const aspect = opts.aspect ?? dnsAspect;
54
+ db.insert(modules)
55
+ .values({
56
+ id: opts.moduleId,
57
+ name: opts.moduleId,
58
+ version: '1.0.0',
59
+ state: opts.state ?? 'INSTALLED',
60
+ manifestData: {
61
+ id: opts.moduleId,
62
+ name: opts.moduleId,
63
+ version: '1.0.0',
64
+ celilo_contract: '1.0',
65
+ base_module_aspect: aspect,
66
+ },
67
+ sourcePath: `/tmp/${opts.moduleId}`,
68
+ })
69
+ .run();
70
+
71
+ const approval = opts.approval ?? 'approved';
72
+ if (approval === 'approved') {
73
+ recordAspectApproval({
74
+ moduleId: opts.moduleId,
75
+ version: '1.0.0',
76
+ scopeHash: computeAspectScopeHash(aspect),
77
+ approver: 'test',
78
+ db,
79
+ });
80
+ } else if (approval === 'denied') {
81
+ recordAspectConsent({
82
+ moduleId: opts.moduleId,
83
+ version: '1.0.0',
84
+ scopeHash: computeAspectScopeHash(aspect),
85
+ approver: 'test',
86
+ consented: false,
87
+ db,
88
+ });
89
+ }
90
+ }
91
+
92
+ /** A module with a deployed system, used as a fan-out TARGET. */
93
+ function seedTargetSystem(opts: {
94
+ moduleId: string;
95
+ hostname: string;
96
+ zone: 'dmz' | 'app' | 'secure' | 'internal';
97
+ ip: string;
98
+ }): void {
99
+ const db = getDb();
100
+ db.insert(modules)
101
+ .values({
102
+ id: opts.moduleId,
103
+ name: opts.moduleId,
104
+ version: '1.0.0',
105
+ state: 'INSTALLED',
106
+ manifestData: {
107
+ id: opts.moduleId,
108
+ name: opts.moduleId,
109
+ version: '1.0.0',
110
+ celilo_contract: '1.0',
111
+ },
112
+ sourcePath: `/tmp/${opts.moduleId}`,
113
+ })
114
+ .run();
115
+ upsertDeployedSystem(db, opts.moduleId, {
116
+ name: 'main',
117
+ hostname: opts.hostname,
118
+ ipv4Address: opts.ip,
119
+ zone: opts.zone,
120
+ infraType: 'container_service',
121
+ });
122
+ }
123
+
124
+ interface RunnerCall {
125
+ moduleId: string;
126
+ trigger: string;
127
+ onlyHostnames?: string[];
128
+ }
129
+
130
+ /** A stand-in runner that records what it was asked to do. */
131
+ function fakeRunner(calls: RunnerCall[], success = true) {
132
+ return (async (args: {
133
+ moduleId: string;
134
+ options: { trigger: string; onlyHostnames?: string[] };
135
+ }): Promise<AspectRunResult> => {
136
+ calls.push({
137
+ moduleId: args.moduleId,
138
+ trigger: args.options.trigger,
139
+ onlyHostnames: args.options.onlyHostnames,
140
+ });
141
+ return {
142
+ success,
143
+ output: '',
144
+ error: success ? undefined : 'ansible said no',
145
+ plan: { targetSystems: [], skipped: [] },
146
+ recap: [],
147
+ };
148
+ }) as unknown as Parameters<typeof reconcileAspectsForSystems>[0]['runner'];
149
+ }
150
+
151
+ describe('reconcileAspectsForSystems', () => {
152
+ let dir: string;
153
+
154
+ beforeEach(async () => {
155
+ dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-reconcile-test-'));
156
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
157
+ await runMigrations(process.env.CELILO_DB_PATH);
158
+ });
159
+
160
+ afterEach(() => {
161
+ closeDb();
162
+ process.env.CELILO_DB_PATH = undefined;
163
+ try {
164
+ rmSync(dir, { recursive: true, force: true });
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ });
169
+
170
+ it('applies an approved aspect to a system in a covered zone', async () => {
171
+ seedProvider({ moduleId: 'knot-unbound-internal' });
172
+ const calls: RunnerCall[] = [];
173
+
174
+ const result = await reconcileAspectsForSystems({
175
+ systems: [{ hostname: 'caddy-int', zone: 'dmz' }],
176
+ db: getDb(),
177
+ runner: fakeRunner(calls),
178
+ });
179
+
180
+ expect(calls).toHaveLength(1);
181
+ expect(calls[0].moduleId).toBe('knot-unbound-internal');
182
+ expect(calls[0].onlyHostnames).toEqual(['caddy-int']);
183
+ expect(result.failures).toHaveLength(0);
184
+ });
185
+
186
+ it('runs an aspect that declares ONLY on_install — triggers do not gate this direction', async () => {
187
+ // Design D2, and the whole reason this fix does not need a manifest change.
188
+ // If this ever starts consulting `aspect.triggers`, every shipping aspect
189
+ // would need `on_new_system_in_zone` added — which changes its scope hash,
190
+ // invalidates the operator's approval, and raises a re-approval interview
191
+ // on the live fleet as a side effect of a bug fix.
192
+ seedProvider({ moduleId: 'knot-unbound-internal', aspect: dnsAspect });
193
+ expect(dnsAspect.triggers).toEqual(['on_install']);
194
+ const calls: RunnerCall[] = [];
195
+
196
+ await reconcileAspectsForSystems({
197
+ systems: [{ hostname: 'vpn', zone: 'app' }],
198
+ db: getDb(),
199
+ runner: fakeRunner(calls),
200
+ });
201
+
202
+ expect(calls).toHaveLength(1);
203
+ expect(calls[0].trigger).toBe('on_new_system_in_zone');
204
+ });
205
+
206
+ it('does not apply an aspect to a system outside its applicable_zones', async () => {
207
+ seedProvider({
208
+ moduleId: 'knot-unbound-internal',
209
+ aspect: { ...dnsAspect, applicable_zones: ['dmz'] },
210
+ });
211
+ const calls: RunnerCall[] = [];
212
+
213
+ const result = await reconcileAspectsForSystems({
214
+ systems: [{ hostname: 'vpn', zone: 'app' }],
215
+ db: getDb(),
216
+ runner: fakeRunner(calls),
217
+ });
218
+
219
+ expect(calls).toHaveLength(0);
220
+ expect(result.outcomes[0].reason).toBe('no_covered_systems');
221
+ });
222
+
223
+ it('does not apply a DENIED aspect, and does not re-prompt', async () => {
224
+ seedProvider({ moduleId: 'knot-unbound-internal', approval: 'denied' });
225
+ const calls: RunnerCall[] = [];
226
+
227
+ const result = await reconcileAspectsForSystems({
228
+ systems: [{ hostname: 'caddy-int', zone: 'dmz' }],
229
+ db: getDb(),
230
+ runner: fakeRunner(calls),
231
+ requestConsent: async () => {
232
+ throw new Error('must not interview for an already-denied aspect');
233
+ },
234
+ });
235
+
236
+ expect(calls).toHaveLength(0);
237
+ expect(result.outcomes[0].reason).toBe('denied');
238
+ });
239
+
240
+ it('skips a PAUSED provider — the documented escape hatch for a wedged aspect', async () => {
241
+ // Design D4a. An inbound failure is fatal to the deploy, so an operator
242
+ // whose non-essential aspect is wedging every deploy pauses its provider,
243
+ // deploys, and unpauses. If this stopped working there would be no way out.
244
+ seedProvider({ moduleId: 'knot-unbound-internal', state: 'PAUSED' });
245
+ const calls: RunnerCall[] = [];
246
+
247
+ const result = await reconcileAspectsForSystems({
248
+ systems: [{ hostname: 'caddy-int', zone: 'dmz' }],
249
+ db: getDb(),
250
+ runner: fakeRunner(calls),
251
+ });
252
+
253
+ expect(calls).toHaveLength(0);
254
+ expect(result.outcomes[0].reason).toBe('paused');
255
+ expect(result.failures).toHaveLength(0);
256
+ });
257
+
258
+ it('skips the deploying module’s own aspect — on_install already fans it out', async () => {
259
+ seedProvider({ moduleId: 'knot-unbound-internal' });
260
+ const calls: RunnerCall[] = [];
261
+
262
+ await reconcileAspectsForSystems({
263
+ systems: [{ hostname: 'dns-int', zone: 'dmz' }],
264
+ db: getDb(),
265
+ excludeModuleIds: ['knot-unbound-internal'],
266
+ runner: fakeRunner(calls),
267
+ });
268
+
269
+ expect(calls).toHaveLength(0);
270
+ });
271
+
272
+ it('reports a failed aspect as a failure the caller can act on', async () => {
273
+ seedProvider({ moduleId: 'knot-unbound-internal' });
274
+ const calls: RunnerCall[] = [];
275
+
276
+ const result = await reconcileAspectsForSystems({
277
+ systems: [{ hostname: 'caddy-int', zone: 'dmz' }],
278
+ db: getDb(),
279
+ runner: fakeRunner(calls, false),
280
+ });
281
+
282
+ expect(result.failures).toHaveLength(1);
283
+ expect(result.failures[0].providerModuleId).toBe('knot-unbound-internal');
284
+ expect(result.failures[0].error).toBe('ansible said no');
285
+ });
286
+
287
+ it('does nothing when no systems were created', async () => {
288
+ seedProvider({ moduleId: 'knot-unbound-internal' });
289
+ const calls: RunnerCall[] = [];
290
+
291
+ const result = await reconcileAspectsForSystems({
292
+ systems: [],
293
+ db: getDb(),
294
+ runner: fakeRunner(calls),
295
+ });
296
+
297
+ expect(calls).toHaveLength(0);
298
+ expect(result.outcomes).toHaveLength(0);
299
+ });
300
+ });
301
+
302
+ describe('planAspectFanOut onlyHostnames', () => {
303
+ let dir: string;
304
+
305
+ beforeEach(async () => {
306
+ dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-only-test-'));
307
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
308
+ await runMigrations(process.env.CELILO_DB_PATH);
309
+ });
310
+
311
+ afterEach(() => {
312
+ closeDb();
313
+ process.env.CELILO_DB_PATH = undefined;
314
+ try {
315
+ rmSync(dir, { recursive: true, force: true });
316
+ } catch {
317
+ /* ignore */
318
+ }
319
+ });
320
+
321
+ it('narrows the plan to the named hosts', async () => {
322
+ seedTargetSystem({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
323
+ seedTargetSystem({ moduleId: 'authentik', hostname: 'auth', zone: 'app', ip: '10.0.20.10' });
324
+
325
+ const all = await planAspectFanOut(dnsAspect);
326
+ expect(all.targetSystems.map((t) => t.hostname).sort()).toEqual(['auth', 'caddy']);
327
+
328
+ const narrowed = await planAspectFanOut(dnsAspect, { onlyHostnames: ['auth'] });
329
+ expect(narrowed.targetSystems.map((t) => t.hostname)).toEqual(['auth']);
330
+ });
331
+
332
+ it('NARROWS ONLY — a host outside applicable_zones is still not a target', async () => {
333
+ // Otherwise an inbound reconcile could apply an aspect to a system whose
334
+ // zone the operator never approved, which is the consent surface.
335
+ seedTargetSystem({ moduleId: 'signal', hostname: 'signal', zone: 'app', ip: '10.0.20.90' });
336
+
337
+ const dmzOnly: BaseModuleAspect = { ...dnsAspect, applicable_zones: ['dmz'] };
338
+ const plan = await planAspectFanOut(dmzOnly, { onlyHostnames: ['signal'] });
339
+
340
+ expect(plan.targetSystems).toHaveLength(0);
341
+ });
342
+ });
343
+
344
+ describe('verifyAspectCoverage', () => {
345
+ let dir: string;
346
+
347
+ beforeEach(async () => {
348
+ dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-coverage-test-'));
349
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
350
+ await runMigrations(process.env.CELILO_DB_PATH);
351
+ });
352
+
353
+ afterEach(() => {
354
+ closeDb();
355
+ process.env.CELILO_DB_PATH = undefined;
356
+ try {
357
+ rmSync(dir, { recursive: true, force: true });
358
+ } catch {
359
+ /* ignore */
360
+ }
361
+ });
362
+
363
+ /** A runner that reports a fixed check-mode recap for the named host. */
364
+ function recapRunner(recap: AnsibleHostRecap[]) {
365
+ return (async (): Promise<AspectRunResult> => ({
366
+ success: true,
367
+ output: '',
368
+ plan: { targetSystems: [], skipped: [] },
369
+ recap,
370
+ })) as unknown as Parameters<typeof verifyAspectCoverage>[0]['runner'];
371
+ }
372
+
373
+ function seedFleet(): void {
374
+ seedProvider({ moduleId: 'knot-unbound-internal' });
375
+ seedTargetSystem({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
376
+ }
377
+
378
+ it('reports a host with no changes and no skips as applied', async () => {
379
+ seedFleet();
380
+ const findings = await verifyAspectCoverage({
381
+ db: getDb(),
382
+ runner: recapRunner([
383
+ { host: 'caddy', ok: 3, changed: 0, unreachable: 0, failed: 0, skipped: 0 },
384
+ ]),
385
+ });
386
+ expect(findings).toHaveLength(1);
387
+ expect(findings[0].state).toBe('applied');
388
+ });
389
+
390
+ it('reports a host the role would change as missing', async () => {
391
+ seedFleet();
392
+ const findings = await verifyAspectCoverage({
393
+ db: getDb(),
394
+ runner: recapRunner([
395
+ { host: 'caddy', ok: 3, changed: 1, unreachable: 0, failed: 0, skipped: 0 },
396
+ ]),
397
+ });
398
+ expect(findings[0].state).toBe('missing');
399
+ expect(findings[0].detail).toContain('celilo module deploy knot-unbound-internal');
400
+ });
401
+
402
+ it('reports SKIPPED tasks as unknown — never as applied', async () => {
403
+ // The unsafe direction, and the reason this is not a boolean. A role of
404
+ // command:/shell: tasks finishes a check run with changed=0 having never
405
+ // been applied; calling that "applied" is a confidently clean answer about
406
+ // an unconverged host — the same failure as the stored verdict this whole
407
+ // approach exists to avoid (celilo#902 design D6).
408
+ seedFleet();
409
+ const findings = await verifyAspectCoverage({
410
+ db: getDb(),
411
+ runner: recapRunner([
412
+ { host: 'caddy', ok: 1, changed: 0, unreachable: 0, failed: 0, skipped: 2 },
413
+ ]),
414
+ });
415
+ expect(findings[0].state).toBe('unknown');
416
+ expect(findings[0].state).not.toBe('applied');
417
+ expect(findings[0].detail).toContain('check mode cannot evaluate');
418
+ });
419
+
420
+ it('reports a host with NO recap line as unknown, not as applied', async () => {
421
+ // An empty recap means nothing was measured. Reading it as success is how a
422
+ // verification turns into the thing it was meant to replace.
423
+ seedFleet();
424
+ const findings = await verifyAspectCoverage({ db: getDb(), runner: recapRunner([]) });
425
+ expect(findings[0].state).toBe('unknown');
426
+ expect(findings[0].detail).toContain('no recap');
427
+ });
428
+
429
+ it('reports an unreachable host distinctly from a measured one', async () => {
430
+ seedFleet();
431
+ const findings = await verifyAspectCoverage({
432
+ db: getDb(),
433
+ runner: recapRunner([
434
+ { host: 'caddy', ok: 0, changed: 0, unreachable: 1, failed: 0, skipped: 0 },
435
+ ]),
436
+ });
437
+ expect(findings[0].state).toBe('unreachable');
438
+ });
439
+
440
+ it('reports a PAUSED provider’s entitled systems, so pausing cannot go unnoticed', async () => {
441
+ seedProvider({ moduleId: 'knot-unbound-internal', state: 'PAUSED' });
442
+ seedTargetSystem({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
443
+
444
+ const findings = await verifyAspectCoverage({
445
+ db: getDb(),
446
+ runner: recapRunner([]),
447
+ });
448
+ expect(findings).toHaveLength(1);
449
+ expect(findings[0].state).toBe('unknown');
450
+ expect(findings[0].detail).toContain('PAUSED');
451
+ });
452
+
453
+ it('does not verify an unapproved aspect, and never raises an interview', async () => {
454
+ seedProvider({ moduleId: 'knot-unbound-internal', approval: 'none' });
455
+ seedTargetSystem({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
456
+
457
+ const findings = await verifyAspectCoverage({ db: getDb(), runner: recapRunner([]) });
458
+ expect(findings).toHaveLength(0);
459
+ });
460
+ });
@@ -439,6 +439,7 @@ describe('aspect-runner', () => {
439
439
  output: success ? 'fake-output' : '',
440
440
  error: success ? undefined : 'fake-error',
441
441
  plan: { targetSystems: [], skipped: [] },
442
+ recap: [],
442
443
  };
443
444
  };
444
445
  return { fake, calls };