@celilo/cli 1.0.0 → 1.2.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 (32) hide show
  1. package/CELILO_CORE_MODULES.md +15 -2
  2. package/CELILO_SUBSYSTEMS.md +13 -0
  3. package/package.json +2 -2
  4. package/src/cli/commands/hook-run.ts +5 -8
  5. package/src/cli/commands/system-audit.ts +2 -0
  6. package/src/cli/commands/system-doctor.ts +30 -1
  7. package/src/cli/commands/system-update.ts +2 -0
  8. package/src/cli/tui/audit-state.ts +2 -0
  9. package/src/db/schema.ts +41 -1
  10. package/src/hooks/artifact-retention.test.ts +136 -0
  11. package/src/hooks/artifact-retention.ts +159 -0
  12. package/src/hooks/executor.test.ts +80 -0
  13. package/src/hooks/executor.ts +68 -23
  14. package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
  15. package/src/hooks/types.ts +20 -2
  16. package/src/policy/module-business-baseline.ts +404 -0
  17. package/src/policy/no-module-business-in-core.test.ts +504 -0
  18. package/src/services/alerting/keys.ts +21 -1
  19. package/src/services/alerting/run-monitor.ts +6 -1
  20. package/src/services/audit/browser-pin.test.ts +167 -0
  21. package/src/services/audit/browser-pin.ts +185 -0
  22. package/src/services/audit/index.test.ts +1 -0
  23. package/src/services/audit/index.ts +3 -0
  24. package/src/services/audit/types.ts +1 -0
  25. package/src/services/health-runner.ts +15 -1
  26. package/src/services/module-deploy.ts +4 -4
  27. package/src/services/update/orchestrator.test.ts +1 -0
  28. package/src/system/browser-provisioning.test.ts +67 -0
  29. package/src/system/prereqs.test.ts +73 -0
  30. package/src/system/prereqs.ts +89 -12
  31. package/src/templates/generator.ts +46 -28
  32. package/src/templates/{dns-ingress-ip.test.ts → ingress-ip.test.ts} +38 -22
@@ -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
@@ -654,38 +654,58 @@ celilo module import modules/${moduleId}
654
654
  * @returns Generation result
655
655
  */
656
656
  /** Narrower than `GenerateResult`: this step produces no files, only an outcome. */
657
- export type DnsIngressResult = { success: true } | { success: false; error: string };
657
+ export type IngressIpResult = { success: true } | { success: false; error: string };
658
658
 
659
659
  /**
660
- * Allocate-and-reserve the module's dedicated DNS-ingress IP, ONCE (ISS-0156).
660
+ * Allocate-and-reserve a module's dedicated `internal`-subnet ingress IPs, ONCE
661
+ * (ISS-0156, celilo#879).
661
662
  *
662
- * A `dns_internal` provider deploys into a PROTECTED zone (dmz) so it can see
663
- * protected-zone query sources for split-horizon views. `internal` devices have
664
- * no route into the 10-net, so they reach the resolver through a firewall DNAT
665
- * on a dedicated `internal`-subnet address. A module opts in by declaring a
666
- * `dns_ingress_ip` infrastructure variable.
663
+ * A service can need to live in a PROTECTED zone the `dns_internal` resolver
664
+ * sits in `dmz` so it can see protected-zone query sources for split-horizon,
665
+ * and `caddy-internal` sits there so systems in the segmented zones can reach
666
+ * it. `internal` devices have no route into the 10-net, so they reach such a
667
+ * service through a firewall DNAT on a dedicated `internal`-subnet address.
668
+ * That DNAT is internal-side only: it is NOT a public port-forward, and the two
669
+ * are routinely confused.
670
+ *
671
+ * A module opts in by declaring an infrastructure variable whose name ends in
672
+ * `ingress_ip` — `dns_ingress_ip` for the resolver's `:53`, `ingress_ip` for a
673
+ * private web ingress's `:80/:443`. The hook then passes the stored value to
674
+ * `firewall.exposeService({ ingressIp })`.
667
675
  *
668
676
  * **Idempotence is the whole point, and it is load-bearing.** `module generate`
669
677
  * runs repeatedly over a module's life. Re-allocating here on the second run
670
- * would move the address internal clients use to reach DNS, every time — while
671
- * every command still reports success. That is why the stored value is reused
672
- * rather than re-derived, and why this is a named function instead of a branch
673
- * buried in `generateTemplates`: an invariant nothing can call is an invariant
674
- * nothing can test, and this one had no test at all.
678
+ * would move the address internal clients use to reach the service, every time
679
+ * — while every command still reports success. That is why the stored value is
680
+ * reused rather than re-derived, and why this is a named function instead of a
681
+ * branch buried in `generateTemplates`: an invariant nothing can call is an
682
+ * invariant nothing can test, and this one had no test at all.
675
683
  */
676
- export async function ensureDnsIngressIp(
684
+ export async function ensureIngressIps(
677
685
  moduleId: string,
678
686
  manifest: ModuleManifest,
679
687
  db: DbClient,
680
- ): Promise<DnsIngressResult> {
681
- const wantsDnsIngress = manifest.variables?.owns?.some(
682
- (v) => v.name === 'dns_ingress_ip' && v.source === 'infrastructure',
688
+ ): Promise<IngressIpResult> {
689
+ const wanted = (manifest.variables?.owns ?? []).filter(
690
+ (v) => v.name.endsWith('ingress_ip') && v.source === 'infrastructure',
683
691
  );
684
- if (!wantsDnsIngress) return { success: true };
692
+ if (wanted.length === 0) return { success: true };
693
+
694
+ for (const variable of wanted) {
695
+ const result = await allocateIngressIp(moduleId, variable.name, db);
696
+ if (!result.success) return result;
697
+ }
698
+ return { success: true };
699
+ }
685
700
 
686
- const existing = getModuleConfigValue(moduleId, 'dns_ingress_ip', db)?.value;
701
+ async function allocateIngressIp(
702
+ moduleId: string,
703
+ variableName: string,
704
+ db: DbClient,
705
+ ): Promise<IngressIpResult> {
706
+ const existing = getModuleConfigValue(moduleId, variableName, db)?.value;
687
707
  if (typeof existing === 'string' && existing.length > 0) {
688
- log.success(`Using existing DNS-ingress IP ${existing} for ${moduleId}`);
708
+ log.success(`Using existing ingress IP ${existing} (${variableName}) for ${moduleId}`);
689
709
  return { success: true };
690
710
  }
691
711
 
@@ -695,9 +715,7 @@ export async function ensureDnsIngressIp(
695
715
  if (!subnetRow?.value) {
696
716
  return {
697
717
  success: false,
698
- error:
699
- 'network.internal.subnet is not configured — required to allocate the ' +
700
- 'dns_internal DNS-ingress IP (ISS-0156). Ensure the internal network is set up first.',
718
+ error: `network.internal.subnet is not configured — required to allocate the ${variableName} ingress IP (ISS-0156). Ensure the internal network is set up first.`,
701
719
  };
702
720
  }
703
721
 
@@ -705,14 +723,14 @@ export async function ensureDnsIngressIp(
705
723
  const { stripCIDR } = await import('../ipam/subnet-parser');
706
724
  try {
707
725
  const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db));
708
- await reserveIP(ip, 'internal', `dns-ingress:${moduleId}`, null, db);
709
- upsertModuleConfig(db, moduleId, 'dns_ingress_ip', ip);
710
- log.success(`Allocated DNS-ingress IP ${ip} (internal subnet) for ${moduleId}`);
726
+ await reserveIP(ip, 'internal', `ingress:${moduleId}:${variableName}`, null, db);
727
+ upsertModuleConfig(db, moduleId, variableName, ip);
728
+ log.success(`Allocated ingress IP ${ip} (internal subnet, ${variableName}) for ${moduleId}`);
711
729
  return { success: true };
712
730
  } catch (error) {
713
731
  return {
714
732
  success: false,
715
- error: `DNS-ingress IP allocation failed: ${error instanceof Error ? error.message : String(error)}`,
733
+ error: `Ingress IP allocation failed for ${variableName}: ${error instanceof Error ? error.message : String(error)}`,
716
734
  };
717
735
  }
718
736
  }
@@ -879,8 +897,8 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
879
897
  }
880
898
  }
881
899
 
882
- const dnsIngress = await ensureDnsIngressIp(moduleId, manifest, db);
883
- if (!dnsIngress.success) return dnsIngress;
900
+ const ingress = await ensureIngressIps(moduleId, manifest, db);
901
+ if (!ingress.success) return ingress;
884
902
 
885
903
  // Infrastructure Properties Resolution (Proxmox provider config)
886
904
  // For Proxmox services, extract provider config and store as temporary values
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The DNS-ingress allocate-and-reserve guard (ISS-0156).
2
+ * The ingress-IP allocate-and-reserve guard (ISS-0156, celilo#879).
3
3
  *
4
4
  * This invariant had NO test. Losing it is not a crash: `module generate`
5
5
  * re-allocates a different address on every run, silently moving the address
6
- * internal clients use to reach DNS, while every command still reports success.
7
- * `module generate` runs repeatedly over a module's life, so "on the second
8
- * run" is the normal case, not an edge one.
6
+ * internal clients use to reach the service, while every command still reports
7
+ * success. `module generate` runs repeatedly over a module's life, so "on the
8
+ * second run" is the normal case, not an edge one.
9
9
  */
10
10
 
11
11
  import { beforeEach, describe, expect, test } from 'bun:test';
@@ -13,7 +13,7 @@ import type { DbClient } from '../db/client';
13
13
  import type { ModuleManifest } from '../manifest/schema';
14
14
  import { getModuleConfigValue } from '../services/module-config';
15
15
  import { setupTestDatabase } from '../test-utils/database';
16
- import { ensureDnsIngressIp } from './generator';
16
+ import { ensureIngressIps } from './generator';
17
17
 
18
18
  let db: DbClient;
19
19
 
@@ -27,12 +27,17 @@ const operatorSupplied = {
27
27
  variables: { owns: [{ name: 'dns_ingress_ip', source: 'user_input' }] },
28
28
  } as unknown as ModuleManifest;
29
29
 
30
+ /** How `caddy-internal` opts in — a web ingress rather than a DNS one. */
31
+ const wantsWebIngress = {
32
+ variables: { owns: [{ name: 'ingress_ip', source: 'infrastructure' }] },
33
+ } as unknown as ModuleManifest;
34
+
30
35
  /**
31
36
  * Typed as `string | undefined` rather than `unknown`: every assertion here is
32
37
  * about an address, and an untyped read pushes a cast onto each one.
33
38
  */
34
- const storedIp = (moduleId: string): string | undefined => {
35
- const value = getModuleConfigValue(moduleId, 'dns_ingress_ip', db)?.value;
39
+ const storedIp = (moduleId: string, variable = 'dns_ingress_ip'): string | undefined => {
40
+ const value = getModuleConfigValue(moduleId, variable, db)?.value;
36
41
  return typeof value === 'string' ? value : undefined;
37
42
  };
38
43
 
@@ -43,9 +48,9 @@ beforeEach(async () => {
43
48
  .run('network.internal.subnet', '10.226.1.0/24');
44
49
  });
45
50
 
46
- describe('ensureDnsIngressIp', () => {
51
+ describe('ensureIngressIps', () => {
47
52
  test('allocates an address from the internal subnet on first generate', async () => {
48
- const result = await ensureDnsIngressIp('technitium', wantsIngress, db);
53
+ const result = await ensureIngressIps('technitium', wantsIngress, db);
49
54
 
50
55
  expect(result.success).toBe(true);
51
56
  expect(storedIp('technitium')).toMatch(/^10\.226\.1\.\d+$/);
@@ -54,21 +59,21 @@ describe('ensureDnsIngressIp', () => {
54
59
  test('REUSES the same address on a second generate', async () => {
55
60
  // The guard itself. Re-allocating here moves the resolver's DNAT ingress
56
61
  // every time the module is regenerated, and nothing reports a problem.
57
- await ensureDnsIngressIp('technitium', wantsIngress, db);
62
+ await ensureIngressIps('technitium', wantsIngress, db);
58
63
  const first = storedIp('technitium');
59
64
 
60
- await ensureDnsIngressIp('technitium', wantsIngress, db);
65
+ await ensureIngressIps('technitium', wantsIngress, db);
61
66
  const second = storedIp('technitium');
62
67
 
63
68
  expect(second).toBe(first);
64
69
  });
65
70
 
66
71
  test('stays stable across many generates, not just two', async () => {
67
- await ensureDnsIngressIp('technitium', wantsIngress, db);
72
+ await ensureIngressIps('technitium', wantsIngress, db);
68
73
  const first = storedIp('technitium');
69
74
 
70
75
  for (let i = 0; i < 5; i++) {
71
- await ensureDnsIngressIp('technitium', wantsIngress, db);
76
+ await ensureIngressIps('technitium', wantsIngress, db);
72
77
  }
73
78
 
74
79
  expect(storedIp('technitium')).toBe(first);
@@ -77,7 +82,7 @@ describe('ensureDnsIngressIp', () => {
77
82
  test('RESERVES the address, so it is never handed out to something else', async () => {
78
83
  // Allocation without reservation is the same bug one step later: a
79
84
  // container gets the resolver's ingress address and DNS goes dark.
80
- await ensureDnsIngressIp('technitium', wantsIngress, db);
85
+ await ensureIngressIps('technitium', wantsIngress, db);
81
86
  const ip = storedIp('technitium');
82
87
 
83
88
  const reserved = db.$client
@@ -85,20 +90,20 @@ describe('ensureDnsIngressIp', () => {
85
90
  .get(ip ?? '') as { ip_start: string; reason: string } | undefined;
86
91
 
87
92
  expect(reserved?.ip_start).toBe(ip);
88
- // The reason names the owner, so an operator reading the table can tell
89
- // what an otherwise anonymous held address is for.
90
- expect(reserved?.reason).toBe('dns-ingress:technitium');
93
+ // The reason names the owner AND the variable, so an operator reading the
94
+ // table can tell what an otherwise anonymous held address is for.
95
+ expect(reserved?.reason).toBe('ingress:technitium:dns_ingress_ip');
91
96
  });
92
97
 
93
98
  test('two modules get two different addresses', async () => {
94
- await ensureDnsIngressIp('technitium', wantsIngress, db);
95
- await ensureDnsIngressIp('knot-unbound-internal', wantsIngress, db);
99
+ await ensureIngressIps('technitium', wantsIngress, db);
100
+ await ensureIngressIps('knot-unbound-internal', wantsIngress, db);
96
101
 
97
102
  expect(storedIp('knot-unbound-internal')).not.toBe(storedIp('technitium'));
98
103
  });
99
104
 
100
105
  test('does nothing for a module that never asked for one', async () => {
101
- const result = await ensureDnsIngressIp('caddy', {} as ModuleManifest, db);
106
+ const result = await ensureIngressIps('caddy', {} as ModuleManifest, db);
102
107
 
103
108
  expect(result.success).toBe(true);
104
109
  expect(storedIp('caddy')).toBeUndefined();
@@ -107,7 +112,7 @@ describe('ensureDnsIngressIp', () => {
107
112
  test('only `source: infrastructure` opts in', async () => {
108
113
  // A same-named variable the operator supplies is theirs to set; allocating
109
114
  // over it would overwrite an operator's deliberate choice.
110
- await ensureDnsIngressIp('technitium', operatorSupplied, db);
115
+ await ensureIngressIps('technitium', operatorSupplied, db);
111
116
 
112
117
  expect(storedIp('technitium')).toBeUndefined();
113
118
  });
@@ -115,9 +120,20 @@ describe('ensureDnsIngressIp', () => {
115
120
  test('fails with an actionable message when the internal subnet is unset', async () => {
116
121
  db.$client.prepare('DELETE FROM system_config WHERE key = ?').run('network.internal.subnet');
117
122
 
118
- const result = await ensureDnsIngressIp('technitium', wantsIngress, db);
123
+ const result = await ensureIngressIps('technitium', wantsIngress, db);
119
124
 
120
125
  expect(result.success).toBe(false);
121
126
  expect(result.success === false && result.error).toContain('network.internal.subnet');
122
127
  });
128
+
129
+ // celilo#879. The opt-in used to be the literal name `dns_ingress_ip`, so a
130
+ // dmz-resident WEB ingress had no way to ask for the same treatment — which
131
+ // is how `caddy-internal` came to be pinned into the `internal` zone with a
132
+ // manifest comment claiming a dmz ingress could not be reached from a LAN.
133
+ test('a `ingress_ip` variable opts in the same way, for a non-DNS ingress', async () => {
134
+ const result = await ensureIngressIps('caddy-internal', wantsWebIngress, db);
135
+
136
+ expect(result.success).toBe(true);
137
+ expect(storedIp('caddy-internal', 'ingress_ip')).toMatch(/^10\.226\.1\.\d+$/);
138
+ });
123
139
  });