@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
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { describe, expect, test } from 'bun:test';
17
+ import { unlinkSync, writeFileSync } from 'node:fs';
18
+ import { BROWSER_EXECUTABLE_PATH } from '@celilo/capabilities';
17
19
  import {
18
20
  PREREQUISITES,
19
21
  checkPrerequisite,
@@ -52,6 +54,8 @@ describe('PREREQUISITES table', () => {
52
54
  expect(names).toContain('git');
53
55
  expect(names).toContain('curl');
54
56
  expect(names).toContain('unzip');
57
+ expect(names).toContain('browser');
58
+ expect(names).toContain('fonts');
55
59
  });
56
60
  });
57
61
 
@@ -313,6 +317,75 @@ describe('checkPrerequisite', () => {
313
317
  });
314
318
  });
315
319
 
320
+ // ── Absolute-path prerequisites (the browser) ─────────────────────────
321
+
322
+ describe('absolute-path prerequisites', () => {
323
+ test('a real executable at an absolute path is present, with its version', () => {
324
+ // `bun --version` prints a bare semver, and Bun.which finds a real path
325
+ // for it — so this exercises the absolute-path branch end to end.
326
+ const bunPath = Bun.which('bun');
327
+ if (!bunPath) throw new Error('bun is not on PATH');
328
+ const result = checkPrerequisite({
329
+ name: 'browser',
330
+ description: 'stand-in for the provisioned browser',
331
+ command: bunPath,
332
+ versionFlag: '--version',
333
+ versionRegex: /(\d+\.\d+\.\d+)/,
334
+ minVersion: null,
335
+ });
336
+ expect(result.present).toBe(true);
337
+ expect(result.binaryPath).toBe(bunPath);
338
+ expect(result.version).toMatch(/^\d+\.\d+\.\d+$/);
339
+ });
340
+
341
+ test('a DIRECTORY at the declared path is not present', () => {
342
+ // The case that motivates the whole check: a build directory with no
343
+ // binary in it satisfies a path test and then fails at launch.
344
+ const result = checkPrerequisite({
345
+ name: 'browser',
346
+ description: 'stand-in for the provisioned browser',
347
+ command: '/tmp',
348
+ versionFlag: '--version',
349
+ versionRegex: /(\d+\.\d+\.\d+)/,
350
+ minVersion: null,
351
+ });
352
+ expect(result.present).toBe(false);
353
+ expect(result.binaryPath).toBeNull();
354
+ });
355
+
356
+ test('a non-executable file at the declared path is not present', () => {
357
+ const path = `/tmp/celilo-prereq-not-executable-${process.pid}`;
358
+ writeFileSync(path, 'not a browser', { mode: 0o644 });
359
+ try {
360
+ const result = checkPrerequisite({
361
+ name: 'browser',
362
+ description: 'stand-in for the provisioned browser',
363
+ command: path,
364
+ versionFlag: '--version',
365
+ versionRegex: /(\d+\.\d+\.\d+)/,
366
+ minVersion: null,
367
+ });
368
+ expect(result.present).toBe(false);
369
+ } finally {
370
+ unlinkSync(path);
371
+ }
372
+ });
373
+
374
+ test('the browser row names install_browser as its remedy', () => {
375
+ const spec = PREREQUISITES.find((p) => p.name === 'browser');
376
+ if (!spec) throw new Error('browser spec missing');
377
+ expect(spec.command).toBe(BROWSER_EXECUTABLE_PATH);
378
+ expect(getInstallHint('browser', 'apt')).toContain('install_browser');
379
+ });
380
+
381
+ test('fc-match output yields the resolved font family', () => {
382
+ const spec = PREREQUISITES.find((p) => p.name === 'fonts');
383
+ if (!spec) throw new Error('fonts spec missing');
384
+ const sample = 'DejaVuSans.ttf: "DejaVu Sans" "Book"';
385
+ expect(sample.match(spec.versionRegex)?.[1]).toBe('DejaVu Sans');
386
+ });
387
+ });
388
+
316
389
  // ── failingPrerequisites ──────────────────────────────────────────────
317
390
 
318
391
  describe('failingPrerequisites', () => {
@@ -11,13 +11,19 @@
11
11
  * runtime invokers (ansible/terraform shell-outs) all consume it from
12
12
  * here.
13
13
  *
14
- * All entries are universally required — no required-vs-recommended
14
+ * All entries are universally DECLARED — no required-vs-recommended
15
15
  * distinction. The platform is the platform; an operator who currently
16
- * doesn't use Terraform still installs Terraform, on the theory that
17
- * predictability beats marginal install-friction savings.
16
+ * doesn't use Terraform still sees a terraform row, on the theory that
17
+ * predictability beats marginal install-friction savings. Declaring is not
18
+ * the same as installing: terraform and the browser are both installed by
19
+ * `celilo-mgmt` only on request, and both report here either way, which is
20
+ * how an operator finds out a host is missing one.
18
21
  */
19
22
 
20
23
  import { spawnSync } from 'node:child_process';
24
+ import { constants, accessSync, statSync } from 'node:fs';
25
+ import { isAbsolute } from 'node:path';
26
+ import { BROWSER_EXECUTABLE_PATH } from '@celilo/capabilities';
21
27
 
22
28
  // ── Types ─────────────────────────────────────────────────────────────
23
29
 
@@ -33,15 +39,22 @@ export type PackageManager = 'apt' | 'dnf' | 'yum' | 'pacman' | 'apk' | 'brew' |
33
39
  * table below; consumers don't construct these themselves.
34
40
  */
35
41
  export interface PrerequisiteSpec {
36
- /** Binary name as invoked on the command line (e.g. 'ansible'). */
42
+ /** Row label in doctor output, and the binary name when `command` is
43
+ * absent (e.g. 'ansible'). */
37
44
  name: string;
38
45
  /** One-line "what this is for" text used in doctor output. */
39
46
  description: string;
40
- /** Flag that prints the version (`--version` for most; `version`
41
- * for terraform; `-V` for ssh; `-v` for unzip). */
47
+ /** What to execute, when that differs from `name`. An ABSOLUTE path
48
+ * switches the presence check from `command -v` to "this exact file
49
+ * exists and is executable" — the celilo-provisioned browser lives at
50
+ * a known path and is never on PATH. */
51
+ command?: string;
52
+ /** Argument that makes the tool report what the row displays
53
+ * (`--version` for most; `version` for terraform; `-V` for ssh;
54
+ * `-v` for unzip; a font pattern for fc-match). */
42
55
  versionFlag: string;
43
- /** Captures the version string from the tool's version output.
44
- * First capture group must be the bare version (e.g. `2.16.3`). */
56
+ /** Captures the displayed string from that output. First capture group
57
+ * must be the bare value (e.g. `2.16.3`). */
45
58
  versionRegex: RegExp;
46
59
  /** Minimum semver. `null` means presence-only — any version OK. */
47
60
  minVersion: string | null;
@@ -54,9 +67,9 @@ export interface PrerequisiteSpec {
54
67
  export interface PrereqCheck {
55
68
  name: string;
56
69
  description: string;
57
- /** `command -v <name>` succeeded. */
70
+ /** The tool was found — on PATH, or at its declared absolute path. */
58
71
  present: boolean;
59
- /** Resolved path on PATH (or null when absent). */
72
+ /** Resolved executable (or null when absent). */
60
73
  binaryPath: string | null;
61
74
  /** Version captured from `--version` output, or null on
62
75
  * parse-fail / not-present. */
@@ -139,6 +152,42 @@ export const PREREQUISITES: PrerequisiteSpec[] = [
139
152
  versionRegex: /UnZip\s+(\d+\.\d+)/,
140
153
  minVersion: null,
141
154
  },
155
+ // Declared unconditionally even though installation is opt-in — same as
156
+ // terraform above. `system doctor` is the ONLY place an operator learns
157
+ // this host has no browser, because a not-provisioned browser is
158
+ // deliberately not a check failure.
159
+ //
160
+ // The check probes the PROVISIONED PATH and must never ask Playwright
161
+ // which executable it would use: `chromium.executablePath()` reports the
162
+ // FULL browser while a headless launch opens the SHELL — measured
163
+ // disagreeing on one machine in one run. On a shell-only host that would
164
+ // validate a path which does not exist while the binary that actually
165
+ // runs is fine. Running it is also what separates a real install from a
166
+ // build directory with no binary in it, which satisfies a path test and
167
+ // then fails at launch.
168
+ {
169
+ name: 'browser',
170
+ description: 'Runs browser-driven module health checks',
171
+ command: BROWSER_EXECUTABLE_PATH,
172
+ versionFlag: '--version',
173
+ // e.g. "Chromium 148.0.7778.0"
174
+ versionRegex: /(\d+\.\d+\.\d+(?:\.\d+)?)/,
175
+ minVersion: null,
176
+ },
177
+ // Fonts belong here rather than in the record consumers read: the party
178
+ // who needs to know a retained screenshot has legible glyphs is the
179
+ // operator looking at it. `fc-match sans-serif` resolves an actual face,
180
+ // so it answers "is there a text font" rather than "is fontconfig
181
+ // installed", and the family name is what the row displays.
182
+ {
183
+ name: 'fonts',
184
+ description: 'Legible text in browser screenshots',
185
+ command: 'fc-match',
186
+ versionFlag: 'sans-serif',
187
+ // e.g. 'DejaVuSans.ttf: "DejaVu Sans" "Book"'
188
+ versionRegex: /"([^"]+)"/,
189
+ minVersion: null,
190
+ },
142
191
  ];
143
192
 
144
193
  // ── Per-OS install hints ──────────────────────────────────────────────
@@ -210,6 +259,14 @@ const INSTALL_HINTS: Record<string, Partial<Record<PackageManager, string>>> = {
210
259
  apk: 'sudo apk add curl',
211
260
  // macOS ships /usr/bin/curl built-in.
212
261
  },
262
+ fonts: {
263
+ apt: 'sudo apt-get install fontconfig fonts-dejavu-core',
264
+ dnf: 'sudo dnf install fontconfig dejavu-sans-fonts',
265
+ yum: 'sudo yum install fontconfig dejavu-sans-fonts',
266
+ pacman: 'sudo pacman -S fontconfig ttf-dejavu',
267
+ apk: 'sudo apk add fontconfig font-dejavu',
268
+ brew: 'brew install fontconfig',
269
+ },
213
270
  unzip: {
214
271
  apt: 'sudo apt-get install unzip',
215
272
  dnf: 'sudo dnf install unzip',
@@ -227,6 +284,9 @@ const INSTALL_HINTS: Record<string, Partial<Record<PackageManager, string>>> = {
227
284
  const FALLBACK_HINTS: Record<string, string> = {
228
285
  bun: 'See https://bun.sh/install',
229
286
  terraform: 'See https://developer.hashicorp.com/terraform/install',
287
+ // Not a package: celilo installs it itself, on request.
288
+ browser:
289
+ 'celilo module config set celilo-mgmt install_browser true && celilo module deploy celilo-mgmt',
230
290
  };
231
291
 
232
292
  // ── Detection ─────────────────────────────────────────────────────────
@@ -301,13 +361,30 @@ export function compareVersions(a: string, b: string): number {
301
361
  * is a misbehaving binary or a stuck PATH lookup; either way we
302
362
  * shouldn't wedge the doctor command waiting on it.
303
363
  */
364
+ /**
365
+ * Resolve an absolute-path prerequisite. Returns the path only when it is
366
+ * a real file that is executable — a symlink pointing at nothing, or a
367
+ * build directory with no binary in it, resolves to null.
368
+ */
369
+ function executableAt(path: string): string | null {
370
+ try {
371
+ // statSync follows symlinks, so a dangling link throws here.
372
+ if (!statSync(path).isFile()) return null;
373
+ accessSync(path, constants.X_OK);
374
+ return path;
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
304
380
  export function checkPrerequisite(
305
381
  spec: PrerequisiteSpec,
306
382
  pm: PackageManager = detectPackageManager(),
307
383
  ): PrereqCheck {
308
384
  const installHint = getInstallHint(spec.name, pm);
309
385
 
310
- const binaryPath = Bun.which(spec.name);
386
+ const command = spec.command ?? spec.name;
387
+ const binaryPath = isAbsolute(command) ? executableAt(command) : Bun.which(command);
311
388
  if (!binaryPath) {
312
389
  return {
313
390
  name: spec.name,
@@ -322,7 +399,7 @@ export function checkPrerequisite(
322
399
 
323
400
  let version: string | null = null;
324
401
  try {
325
- const result = spawnSync(spec.name, [spec.versionFlag], {
402
+ const result = spawnSync(binaryPath, [spec.versionFlag], {
326
403
  encoding: 'utf-8',
327
404
  timeout: 5000,
328
405
  // Some tools (notably git on macOS) refuse to run with a
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { beforeEach, describe, expect, test } from 'bun:test';
12
12
  import type { DbClient } from '../db/client';
13
+ import { deallocateForModule } from '../ipam/auto-allocator';
13
14
  import type { ModuleManifest } from '../manifest/schema';
14
15
  import { getModuleConfigValue } from '../services/module-config';
15
16
  import { setupTestDatabase } from '../test-utils/database';
@@ -41,6 +42,22 @@ const storedIp = (moduleId: string, variable = 'dns_ingress_ip'): string | undef
41
42
  return typeof value === 'string' ? value : undefined;
42
43
  };
43
44
 
45
+ /** Reservation reasons currently held, so a test can assert one is GONE. */
46
+ const reservedReasons = (): string[] =>
47
+ (db.$client.prepare('SELECT reason FROM ip_reservations').all() as Array<{ reason: string }>).map(
48
+ (r) => r.reason,
49
+ );
50
+
51
+ /**
52
+ * What `module remove` does to a module's IPAM state. Removal also drops the
53
+ * module row, which cascades the stored config value away — the tests below
54
+ * clear it by hand so a "reinstall" starts from the same state a real one does.
55
+ */
56
+ const removeModule = async (moduleId: string): Promise<void> => {
57
+ await deallocateForModule(moduleId, db);
58
+ db.$client.prepare('DELETE FROM module_configs WHERE module_id = ?').run(moduleId);
59
+ };
60
+
44
61
  beforeEach(async () => {
45
62
  db = await setupTestDatabase();
46
63
  db.$client
@@ -137,3 +154,94 @@ describe('ensureIngressIps', () => {
137
154
  expect(storedIp('caddy-internal', 'ingress_ip')).toMatch(/^10\.226\.1\.\d+$/);
138
155
  });
139
156
  });
157
+
158
+ /**
159
+ * The release half (celilo#892). `ensureIngressIps` reserved the address and
160
+ * nothing ever gave it back, so every install/remove cycle permanently burned
161
+ * one address from the internal static range — silently, and with no way to
162
+ * tell the dead row from the live one, since both carry the same reason.
163
+ *
164
+ * On celilo-mgr this left 192.168.0.153 and .154 both reading
165
+ * `ingress:caddy-internal:ingress_ip`, only one of them real.
166
+ */
167
+ describe('releasing ingress IPs on module removal', () => {
168
+ test('removing a module releases its ingress reservation', async () => {
169
+ await ensureIngressIps('technitium', wantsIngress, db);
170
+ expect(reservedReasons()).toContain('ingress:technitium:dns_ingress_ip');
171
+
172
+ await removeModule('technitium');
173
+
174
+ expect(reservedReasons()).not.toContain('ingress:technitium:dns_ingress_ip');
175
+ });
176
+
177
+ test('a reinstall REUSES the address instead of advancing to the next one', async () => {
178
+ // The user-visible symptom: without the release, the range walks forward
179
+ // one address per install/remove cycle until it runs out.
180
+ await ensureIngressIps('technitium', wantsIngress, db);
181
+ const first = storedIp('technitium');
182
+
183
+ await removeModule('technitium');
184
+ await ensureIngressIps('technitium', wantsIngress, db);
185
+
186
+ expect(storedIp('technitium')).toBe(first);
187
+ });
188
+
189
+ test('the exclusion count returns to its pre-install value', async () => {
190
+ const before = reservedReasons().length;
191
+
192
+ await ensureIngressIps('caddy-internal', wantsWebIngress, db);
193
+ expect(reservedReasons().length).toBe(before + 1);
194
+
195
+ await removeModule('caddy-internal');
196
+
197
+ expect(reservedReasons().length).toBe(before);
198
+ });
199
+
200
+ test('releases the LEGACY `dns-ingress:<module>` reason too', async () => {
201
+ // Written before celilo#879 generalized the reason format. celilo-mgr holds
202
+ // one of these right now, so a fix matching only the current format leaves
203
+ // the installed base leaking.
204
+ db.$client
205
+ .prepare('INSERT INTO ip_reservations (ip_start, zone, reason) VALUES (?, ?, ?)')
206
+ .run('10.226.1.42', 'internal', 'dns-ingress:technitium');
207
+
208
+ await removeModule('technitium');
209
+
210
+ expect(reservedReasons()).not.toContain('dns-ingress:technitium');
211
+ });
212
+
213
+ test('releases a module holding BOTH formats at once', async () => {
214
+ // An upgraded installation: the legacy row from before celilo#879 plus a
215
+ // current one written since. Releasing only one still leaks.
216
+ await ensureIngressIps('technitium', wantsIngress, db);
217
+ db.$client
218
+ .prepare('INSERT INTO ip_reservations (ip_start, zone, reason) VALUES (?, ?, ?)')
219
+ .run('10.226.1.42', 'internal', 'dns-ingress:technitium');
220
+
221
+ await removeModule('technitium');
222
+
223
+ expect(reservedReasons()).toEqual([]);
224
+ });
225
+
226
+ test("leaves OTHER modules' reservations alone", async () => {
227
+ await ensureIngressIps('technitium', wantsIngress, db);
228
+ await ensureIngressIps('knot-unbound-internal', wantsIngress, db);
229
+
230
+ await removeModule('technitium');
231
+
232
+ expect(reservedReasons()).toEqual(['ingress:knot-unbound-internal:dns_ingress_ip']);
233
+ });
234
+
235
+ test('releases even when the module has no zone-IP allocation', async () => {
236
+ // `deallocateForModule` returns early when there is no `ip_allocations`
237
+ // row, which is the normal case for a module deployed onto a machine
238
+ // rather than a celilo-provisioned container. Releasing after that early
239
+ // return would skip exactly the modules that leak.
240
+ await ensureIngressIps('caddy-internal', wantsWebIngress, db);
241
+
242
+ const hadAllocation = await deallocateForModule('caddy-internal', db);
243
+
244
+ expect(hadAllocation).toBe(false);
245
+ expect(reservedReasons()).toEqual([]);
246
+ });
247
+ });