@celilo/cli 1.1.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.
Files changed (43) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +16 -1
  3. package/package.json +4 -4
  4. package/src/cli/commands/hook-run.ts +5 -8
  5. package/src/cli/commands/ipam.ts +93 -0
  6. package/src/cli/commands/machine-add.ts +22 -0
  7. package/src/cli/commands/system-audit.ts +2 -0
  8. package/src/cli/commands/system-doctor.ts +148 -5
  9. package/src/cli/commands/system-update.ts +2 -0
  10. package/src/cli/completion.ts +38 -5
  11. package/src/cli/index.ts +10 -1
  12. package/src/cli/tui/audit-state.ts +2 -0
  13. package/src/db/schema.ts +41 -1
  14. package/src/hooks/artifact-retention.test.ts +136 -0
  15. package/src/hooks/artifact-retention.ts +159 -0
  16. package/src/hooks/executor.test.ts +80 -0
  17. package/src/hooks/executor.ts +68 -23
  18. package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
  19. package/src/hooks/types.ts +20 -2
  20. package/src/ipam/allocator.test.ts +38 -0
  21. package/src/ipam/allocator.ts +63 -1
  22. package/src/ipam/auto-allocator.ts +7 -0
  23. package/src/policy/module-business-baseline.ts +404 -0
  24. package/src/policy/no-module-business-in-core.test.ts +504 -0
  25. package/src/services/alerting/keys.ts +21 -1
  26. package/src/services/alerting/run-monitor.ts +6 -1
  27. package/src/services/aspect-reconcile.test.ts +460 -0
  28. package/src/services/aspect-runner.test.ts +1 -0
  29. package/src/services/aspect-runner.ts +408 -37
  30. package/src/services/audit/browser-pin.test.ts +167 -0
  31. package/src/services/audit/browser-pin.ts +185 -0
  32. package/src/services/audit/index.test.ts +1 -0
  33. package/src/services/audit/index.ts +3 -0
  34. package/src/services/audit/types.ts +1 -0
  35. package/src/services/deploy-ansible-recap.test.ts +76 -0
  36. package/src/services/deploy-ansible.ts +56 -1
  37. package/src/services/health-runner.ts +15 -1
  38. package/src/services/module-deploy.ts +70 -16
  39. package/src/services/update/orchestrator.test.ts +1 -0
  40. package/src/system/browser-provisioning.test.ts +67 -0
  41. package/src/system/prereqs.test.ts +73 -0
  42. package/src/system/prereqs.ts +89 -12
  43. package/src/templates/ingress-ip.test.ts +108 -0
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import type { ContractHookSignature } from '../manifest/contracts';
4
5
  import {
@@ -272,6 +273,85 @@ describe('Hook Executor', () => {
272
273
  });
273
274
  });
274
275
 
276
+ describe('invokeHook artifacts', () => {
277
+ test('collects EVERY file the hook wrote, not just the newest .png', async () => {
278
+ const { logger } = createCapturingLogger();
279
+ const result = await invokeHook(
280
+ __dirname,
281
+ 'container_created',
282
+ '1.0',
283
+ { script: './test-fixtures/artifact-writing-hook.ts', timeout: 10000 },
284
+ { vps_ip: '10.0.0.5' },
285
+ {},
286
+ {},
287
+ logger,
288
+ );
289
+
290
+ expect(result.success).toBe(true);
291
+ const names = (result.artifactPaths ?? []).map((p) => p.split('/').pop());
292
+ // The old behaviour returned exactly one of these — the .png — and
293
+ // threw away the DOM and the request log, which is the least useful
294
+ // third of a post-mortem on its own.
295
+ expect(names).toEqual(['spa-failure.html', 'spa-failure.png', 'spa-failure.requests.txt']);
296
+
297
+ // Per-run, so a consumer writing FIXED filenames cannot overwrite its
298
+ // own previous run — which is what makes retention meaningful.
299
+ const dir = (result.artifactPaths ?? [])[0];
300
+ expect(dir).toContain('/screenshots/container_created-');
301
+
302
+ rmSync(join(__dirname, 'screenshots'), { recursive: true, force: true });
303
+ });
304
+
305
+ test('a hook that writes nothing leaves no directory behind', async () => {
306
+ // Every hook invocation creates a run directory. If empty ones were
307
+ // kept, a module would accrue one per run forever — ~96 a day for a
308
+ // 15-minute monitor — and nothing would ever reclaim them.
309
+ const { logger } = createCapturingLogger();
310
+ const result = await invokeHook(
311
+ __dirname,
312
+ 'container_created',
313
+ '1.0',
314
+ { script: './test-fixtures/success-hook.ts', timeout: 10000 },
315
+ { vps_ip: '10.0.0.5' },
316
+ {},
317
+ {},
318
+ logger,
319
+ );
320
+
321
+ expect(result.success).toBe(true);
322
+ expect(result.artifactPaths).toBeUndefined();
323
+ expect(existsSync(join(__dirname, 'screenshots', 'container_created'))).toBe(false);
324
+ const runDirs = existsSync(join(__dirname, 'screenshots'))
325
+ ? readdirSync(join(__dirname, 'screenshots'))
326
+ : [];
327
+ expect(runDirs).toEqual([]);
328
+ });
329
+
330
+ test('an early return before execution leaks no directory', async () => {
331
+ // The capability pre-flight returns between "create the directory" and
332
+ // the try/finally that reclaims it. Creating the directory too early
333
+ // therefore leaked one on every such run, and nothing cleans them up.
334
+ const { logger } = createCapturingLogger();
335
+ const result = await invokeHook(
336
+ __dirname,
337
+ 'container_created',
338
+ '1.0',
339
+ { script: './test-fixtures/success-hook.ts', timeout: 10000 },
340
+ { vps_ip: '10.0.0.5' },
341
+ {},
342
+ {},
343
+ logger,
344
+ { requiredCapabilities: ['dns_internal'], capabilities: {} },
345
+ );
346
+
347
+ expect(result.success).toBe(false);
348
+ const runDirs = existsSync(join(__dirname, 'screenshots'))
349
+ ? readdirSync(join(__dirname, 'screenshots'))
350
+ : [];
351
+ expect(runDirs).toEqual([]);
352
+ });
353
+ });
354
+
275
355
  describe('invokeHook', () => {
276
356
  test('full successful invocation', async () => {
277
357
  const { logger, messages } = createCapturingLogger();
@@ -19,12 +19,13 @@
19
19
  * Execution function (Rule 10.1) - performs side effects (script execution)
20
20
  */
21
21
 
22
- import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
23
- import { join, resolve } from 'node:path';
22
+ import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
23
+ import { dirname, join, resolve } from 'node:path';
24
24
  import {
25
25
  type DeployedSystem,
26
26
  isCompiledHook,
27
27
  isMissingProviderInputError,
28
+ moduleArtifactDir,
28
29
  } from '@celilo/capabilities';
29
30
  import {
30
31
  type ContractHookSignature,
@@ -33,6 +34,7 @@ import {
33
34
  supportedContractVersions,
34
35
  } from '../manifest/contracts';
35
36
  import { isPrivilegedCapability } from '../manifest/validate';
37
+ import { pruneModuleArtifacts } from './artifact-retention';
36
38
  import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
37
39
 
38
40
  /** Default total timeout: 60 seconds */
@@ -373,22 +375,45 @@ export function checkRequiredCapabilities(
373
375
  * @param since - Only consider files created after this timestamp (ms)
374
376
  * @returns Path to screenshot, or undefined
375
377
  */
376
- function findRecentScreenshot(dir: string, since: number): string | undefined {
377
- if (!existsSync(dir)) return undefined;
378
+ /**
379
+ * Every file the hook wrote to this run's artifact directory.
380
+ *
381
+ * No mtime filter is needed and none is wanted: the directory is created
382
+ * fresh for this run, so everything in it was written by this run. The
383
+ * previous version returned only the newest `.png`, which discarded the
384
+ * page content and the observed-request log — the two things that make a
385
+ * screenshot diagnosable.
386
+ */
387
+ function collectArtifacts(dir: string): string[] {
388
+ try {
389
+ return readdirSync(dir)
390
+ .map((file) => join(dir, file))
391
+ .filter((path) => {
392
+ try {
393
+ return statSync(path).isFile();
394
+ } catch {
395
+ return false;
396
+ }
397
+ })
398
+ .sort();
399
+ } catch {
400
+ // Best effort — never mask the original error with a readdir failure.
401
+ return [];
402
+ }
403
+ }
378
404
 
405
+ /** `undefined` rather than `[]`, so an empty result carries no field at all. */
406
+ function nonEmpty(paths: string[]): string[] | undefined {
407
+ return paths.length > 0 ? paths : undefined;
408
+ }
409
+
410
+ /** Remove a run's artifact directory when the hook wrote nothing into it. */
411
+ function discardIfEmpty(dir: string): void {
379
412
  try {
380
- const files = readdirSync(dir).filter((f) => f.endsWith('.png'));
381
- for (const file of files) {
382
- const fullPath = join(dir, file);
383
- const stat = statSync(fullPath);
384
- if (stat.mtimeMs >= since) {
385
- return fullPath;
386
- }
387
- }
413
+ if (readdirSync(dir).length === 0) rmdirSync(dir);
388
414
  } catch {
389
- // Best effort — don't mask the original error
415
+ // Best effort — a leftover directory is not worth failing a hook over.
390
416
  }
391
- return undefined;
392
417
  }
393
418
 
394
419
  /**
@@ -470,9 +495,16 @@ export async function invokeHook(
470
495
  };
471
496
  }
472
497
 
473
- // Prepare screenshot directory
474
- const screenshotDir = join(modulePath, 'screenshots');
475
- mkdirSync(screenshotDir, { recursive: true });
498
+ // Prepare this run's artifact directory. Per-run, because a hook writing
499
+ // fixed filenames a reasonable thing to do — would otherwise overwrite
500
+ // its own previous artifacts and leave retention nothing to retain.
501
+ // `moduleArtifactDir` is the one definition of that layout; a bus
502
+ // subscriber, which gets no HookContext, calls it directly.
503
+ // Path now, directory later. Creating it here would leak an empty
504
+ // directory on every early return between this point and the try/finally
505
+ // below — the capability pre-flight is one — and nothing ever cleans
506
+ // those up, so a module accrues one per affected run forever.
507
+ const screenshotDir = moduleArtifactDir(modulePath, `${hookName}-${startTime}`);
476
508
 
477
509
  // Build context
478
510
  const loadedCapabilities = options.capabilities ?? {};
@@ -515,6 +547,13 @@ export async function invokeHook(
515
547
  debug,
516
548
  );
517
549
 
550
+ // Create the artifact directory only once every early return is behind
551
+ // us, so the `finally` below is guaranteed to run and reclaim it.
552
+ mkdirSync(screenshotDir, { recursive: true });
553
+ // Prune on write, so retention needs no scheduler of its own and cannot
554
+ // fall behind a module that runs often.
555
+ pruneModuleArtifacts(dirname(screenshotDir));
556
+
518
557
  // Execute
519
558
  try {
520
559
  logger.info(`Executing hook: ${hookName}`);
@@ -535,16 +574,17 @@ export async function invokeHook(
535
574
  return {
536
575
  success: true,
537
576
  outputs,
577
+ artifactPaths: nonEmpty(collectArtifacts(screenshotDir)),
538
578
  duration: Date.now() - startTime,
539
579
  };
540
580
  } catch (error) {
541
581
  const message = error instanceof Error ? error.message : String(error);
542
582
  logger.error(`Hook ${hookName} failed: ${message}`);
543
583
 
544
- // Check for screenshots captured by the hook
545
- const screenshotPath = findRecentScreenshot(screenshotDir, startTime);
546
- if (screenshotPath) {
547
- logger.info(`Screenshot saved: ${screenshotPath}`);
584
+ // Artifacts the hook wrote before it failed — the post-mortem.
585
+ const artifactPaths = nonEmpty(collectArtifacts(screenshotDir));
586
+ if (artifactPaths) {
587
+ logger.info(`Artifacts saved:\n ${artifactPaths.join('\n ')}`);
548
588
  }
549
589
 
550
590
  // Recognise the cross-module structured error so the orchestrator can
@@ -557,7 +597,7 @@ export async function invokeHook(
557
597
  success: false,
558
598
  outputs: {},
559
599
  error: message,
560
- screenshotPath,
600
+ artifactPaths,
561
601
  duration: Date.now() - startTime,
562
602
  missingProviderInput: {
563
603
  providerModuleId: error.providerModuleId,
@@ -572,8 +612,13 @@ export async function invokeHook(
572
612
  success: false,
573
613
  outputs: {},
574
614
  error: message,
575
- screenshotPath,
615
+ artifactPaths,
576
616
  duration: Date.now() - startTime,
577
617
  };
618
+ } finally {
619
+ // Most hooks write nothing, and every hook invocation would otherwise
620
+ // leave an empty directory behind. Discard it; a run that produced
621
+ // artifacts keeps its directory.
622
+ discardIfEmpty(screenshotDir);
578
623
  }
579
624
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Test fixture: a hook that writes a post-mortem set to its artifact
3
+ * directory, the way a browser-driven check does — a screenshot, the page
4
+ * content, and the observed requests.
5
+ *
6
+ * Three files with three different extensions on purpose: the executor
7
+ * used to report only the newest `.png`, which silently discarded the two
8
+ * that make the screenshot diagnosable.
9
+ */
10
+
11
+ import { writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { defineHook } from '@celilo/capabilities';
14
+
15
+ export default defineHook({
16
+ hook: 'container_created',
17
+ requires: [],
18
+ handler: async (ctx) => {
19
+ const dir = ctx.screenshotDir as string;
20
+ writeFileSync(join(dir, 'spa-failure.png'), 'not-really-a-png');
21
+ writeFileSync(join(dir, 'spa-failure.html'), '<html></html>');
22
+ writeFileSync(join(dir, 'spa-failure.requests.txt'), 'GET /api/getActiveMonth 200');
23
+ return { api_key: 'wrote-artifacts' };
24
+ },
25
+ });
@@ -91,8 +91,13 @@ export interface HookResult {
91
91
  success: boolean;
92
92
  outputs: Record<string, unknown>;
93
93
  error?: string;
94
- /** Path to failure screenshot, if captured */
95
- screenshotPath?: string;
94
+ /**
95
+ * Every file the hook wrote to its per-run artifact directory.
96
+ * All of them, not just the newest image: a screenshot without the
97
+ * DOM and the observed requests is the least useful third of a
98
+ * post-mortem.
99
+ */
100
+ artifactPaths?: string[];
96
101
  /** Duration in milliseconds */
97
102
  duration: number;
98
103
  /**
@@ -120,3 +125,16 @@ import type { HookName } from '@celilo/capabilities';
120
125
  * Hook manifest section - maps hook names to definitions
121
126
  */
122
127
  export type HookManifest = Partial<Record<HookName, HookDefinition>>;
128
+
129
+ /**
130
+ * Render collected artifacts for an operator-facing error message.
131
+ *
132
+ * One definition, because three call sites rendered the single old
133
+ * `screenshotPath` three separate times and would have drifted the moment
134
+ * one of them learned about the others.
135
+ */
136
+ export function describeArtifacts(artifactPaths: string[] | undefined): string {
137
+ if (!artifactPaths || artifactPaths.length === 0) return '';
138
+ const label = artifactPaths.length === 1 ? 'Artifact saved' : 'Artifacts saved';
139
+ return `\n\n${label}:\n ${artifactPaths.join('\n ')}`;
140
+ }
@@ -25,6 +25,7 @@ import {
25
25
  reserveVMID,
26
26
  unreserveIP,
27
27
  unreserveVMID,
28
+ updateReservationReason,
28
29
  } from './allocator';
29
30
 
30
31
  describe('IPAM Allocator', () => {
@@ -295,6 +296,43 @@ describe('IPAM Allocator', () => {
295
296
  const reservations = await listReservations(db);
296
297
  expect(reservations).toHaveLength(2);
297
298
  });
299
+
300
+ // Editing in place, rather than include-then-exclude: that dance drops the
301
+ // row for a moment and can race an allocation into the held address.
302
+ test('should edit a reservation reason in place', async () => {
303
+ await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
304
+
305
+ const updated = await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
306
+
307
+ expect(updated).toBe(true);
308
+ const reservations = await db.select().from(ipReservations).all();
309
+ expect(reservations).toHaveLength(1);
310
+ expect(reservations[0].reason).toBe('Retired LB');
311
+ });
312
+
313
+ test('should leave the address held while editing', async () => {
314
+ await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
315
+
316
+ await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
317
+
318
+ expect(await isIPAvailable('10.0.10.50', 'dmz', db)).toBe(false);
319
+ });
320
+
321
+ test('should edit only the addressed row', async () => {
322
+ await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
323
+ await reserveIP('10.0.10.51', 'dmz', 'LB', null, db);
324
+
325
+ await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
326
+
327
+ const reasons = (await listReservations(db)).map((r) => r.reason);
328
+ expect(reasons.sort()).toEqual(['LB', 'Retired LB']);
329
+ });
330
+
331
+ test('should report when no reservation exists to edit', async () => {
332
+ const updated = await updateReservationReason('10.0.10.99', 'dmz', 'Nothing there', db);
333
+
334
+ expect(updated).toBe(false);
335
+ });
298
336
  });
299
337
 
300
338
  describe('getAllocatedIPsInSubnet', () => {
@@ -4,7 +4,7 @@
4
4
  * Prevents conflicts and tracks allocations
5
5
  */
6
6
 
7
- import { and, eq } from 'drizzle-orm';
7
+ import { and, eq, like, or } from 'drizzle-orm';
8
8
  import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
9
9
  import type { DbClient } from '../db/client';
10
10
  import { ipAllocations, ipReservations, systemConfig, vmidReservations } from '../db/schema';
@@ -246,6 +246,68 @@ export async function unreserveIP(
246
246
  .where(and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone)));
247
247
  }
248
248
 
249
+ /**
250
+ * Release every ingress-IP reservation a module holds (celilo#892).
251
+ *
252
+ * `ensureIngressIps` reserves an `internal`-subnet address at generate time and
253
+ * nothing released it at removal, so each install/remove cycle permanently
254
+ * burned one address from the static range — with no error and no way to tell
255
+ * the dead row from the live one, since both carry the same reason string.
256
+ *
257
+ * TWO reason formats exist in live databases and both must go: the current
258
+ * `ingress:<module>:<variable>` (since celilo#879) and the pre-879
259
+ * `dns-ingress:<module>`, which celilo-mgr still holds. Matching only the
260
+ * current one would leave the installed base leaking.
261
+ *
262
+ * Deletion is by REASON, not by the stored config value: a module can hold an
263
+ * ingress reservation with no `ip_allocations` row (it deploys onto a machine
264
+ * rather than a container), and the config rows are about to be cascade-deleted
265
+ * anyway. The reason string is the only thing that names the owner.
266
+ *
267
+ * @returns The addresses released, for reporting.
268
+ */
269
+ export async function releaseIngressReservations(
270
+ moduleId: string,
271
+ db: DbOrTransaction,
272
+ ): Promise<string[]> {
273
+ // Module IDs are validated kebab-case, so they carry no LIKE wildcards.
274
+ const ownedByModule = or(
275
+ like(ipReservations.reason, `ingress:${moduleId}:%`),
276
+ eq(ipReservations.reason, `dns-ingress:${moduleId}`),
277
+ );
278
+
279
+ const held = await db.select().from(ipReservations).where(ownedByModule).all();
280
+ if (held.length === 0) return [];
281
+
282
+ await db.delete(ipReservations).where(ownedByModule);
283
+
284
+ return held.map((r: typeof ipReservations.$inferSelect) => r.ipStart);
285
+ }
286
+
287
+ /**
288
+ * Change an existing reservation's reason in place.
289
+ *
290
+ * The alternative — include then re-exclude — drops the row for a moment and
291
+ * can race an allocation into the address it was holding.
292
+ *
293
+ * @returns False when no reservation exists for that IP in that zone.
294
+ */
295
+ export async function updateReservationReason(
296
+ ipStart: string,
297
+ zone: IpamZone,
298
+ reason: string,
299
+ db: DbOrTransaction,
300
+ ): Promise<boolean> {
301
+ const ip = stripCIDR(ipStart);
302
+ const match = and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone));
303
+
304
+ const existing = await db.select().from(ipReservations).where(match).all();
305
+ if (existing.length === 0) return false;
306
+
307
+ await db.update(ipReservations).set({ reason }).where(match);
308
+ return true;
309
+ }
310
+
249
311
  /**
250
312
  * List all IP reservations
251
313
  */
@@ -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) {