@celilo/cli 2.1.0 → 2.2.1

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 (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
@@ -6,6 +6,7 @@ import type { DbClient } from '../db/client';
6
6
  import { systemConfig } from '../db/schema';
7
7
  import { loadTrustedSubnets } from '../hooks/capability-loader';
8
8
  import { setupTestDatabaseAt } from '../test-utils/database';
9
+ import { resetTestDbPath } from '../test-utils/db-path';
9
10
  import {
10
11
  buildTrustedSourceStore,
11
12
  composeTrustedSubnets,
@@ -30,7 +31,7 @@ describe('trusted-source store', () => {
30
31
  });
31
32
  afterEach(() => {
32
33
  db.$client.close();
33
- process.env.CELILO_DB_PATH = undefined;
34
+ resetTestDbPath();
34
35
  try {
35
36
  rmSync(dir, { recursive: true, force: true });
36
37
  } catch {
@@ -147,7 +148,7 @@ describe('the render input excludes registrations; the reporting view includes t
147
148
  });
148
149
  afterEach(() => {
149
150
  db.$client.close();
150
- process.env.CELILO_DB_PATH = undefined;
151
+ resetTestDbPath();
151
152
  try {
152
153
  rmSync(dir, { recursive: true, force: true });
153
154
  } catch {
@@ -256,5 +257,35 @@ describe('parsing the operator override', () => {
256
257
  it('an unset key contributes nothing', () => {
257
258
  expect(parseOperatorTrustedSubnets(undefined)).toEqual([]);
258
259
  expect(parseOperatorTrustedSubnets('')).toEqual([]);
260
+ expect(parseOperatorTrustedSubnets(' ')).toEqual([]);
261
+ });
262
+
263
+ it('parses the JSON-array form every sibling config key uses', () => {
264
+ expect(parseOperatorTrustedSubnets('["10.226.120.0/24"]')).toEqual(['10.226.120.0/24']);
265
+ expect(parseOperatorTrustedSubnets('[ "10.1.0.0/24" , "10.2.0.0/24" ]')).toEqual([
266
+ '10.1.0.0/24',
267
+ '10.2.0.0/24',
268
+ ]);
269
+ });
270
+
271
+ it('refuses a JSON-looking value that does not parse to an array of strings', () => {
272
+ // The exact e2e failure (ce-yaqa): a JSON array read as ONE subnet named
273
+ // ["10.226.120.0/24"], rendered verbatim into rules.v4, and
274
+ // iptables-restore failed the converge with "Bad argument `]'".
275
+ expect(() => parseOperatorTrustedSubnets('{"a":1}')).toThrow(/JSON array of CIDR strings/);
276
+ expect(() => parseOperatorTrustedSubnets('["10.0.0.0/24", 5]')).toThrow(
277
+ /JSON array of CIDR strings/,
278
+ );
279
+ expect(() => parseOperatorTrustedSubnets('[10.0.0.0/24')).toThrow(
280
+ /looks like JSON but does not parse/,
281
+ );
282
+ });
283
+
284
+ it('refuses a token that is not an IPv4 CIDR, naming it', () => {
285
+ expect(() => parseOperatorTrustedSubnets('10.0.0.1')).toThrow(/10\.0\.0\.1/);
286
+ expect(() => parseOperatorTrustedSubnets('10.0.0.1')).toThrow(/not an IPv4 CIDR/);
287
+ expect(() => parseOperatorTrustedSubnets('10.0.0.0/24, hostname.local')).toThrow(
288
+ /hostname\.local/,
289
+ );
259
290
  });
260
291
  });
@@ -14,6 +14,7 @@ import type {
14
14
  TrustedSource,
15
15
  TrustedSourceStore,
16
16
  } from '@celilo/capabilities';
17
+ import { isCidr } from '@celilo/capabilities';
17
18
  import { and, eq } from 'drizzle-orm';
18
19
  import type { DbClient } from '../db/client';
19
20
  import { trustedSources } from '../db/schema';
@@ -80,20 +81,56 @@ export interface TrustedSubnetEntry {
80
81
  }
81
82
 
82
83
  /**
83
- * The operator's explicit trusted sources, as a comma-separated CIDR list in
84
- * system config. ADDITIVE rather than replacing the derived control-plane
85
- * subnet: dropping control-plane trust would block the SSH celilo uses to run
86
- * every hook and converge, so an operator cannot strand celilo's own management
87
- * by naming a subnet here.
84
+ * The operator's explicit trusted sources, in system config. Accepts the two
85
+ * forms a config value takes in this area: a comma-separated CIDR list and a
86
+ * JSON array of CIDRs (the form every sibling key uses, e.g. ddns_passwords).
87
+ *
88
+ * Every token must be a CIDR. The parser is the single choke point between the
89
+ * operator's value and the ruleset renderer, which emits each subnet verbatim
90
+ * into rules.v4 — a token that is not a CIDR used to ride through as a literal
91
+ * subnet name and iptables-restore failed the whole converge with
92
+ * "Bad argument `]'". Failing here names the token instead.
93
+ *
94
+ * ADDITIVE rather than replacing the derived control-plane subnet: dropping
95
+ * control-plane trust would block the SSH celilo uses to run every hook and
96
+ * converge, so an operator cannot strand celilo's own management by naming a
97
+ * subnet here.
88
98
  */
89
99
  export const TRUSTED_SUBNETS_CONFIG_KEY = 'firewall.trusted_subnets';
90
100
 
91
101
  export function parseOperatorTrustedSubnets(raw: string | undefined): string[] {
92
- if (!raw) return [];
93
- return raw
94
- .split(',')
95
- .map((s) => s.trim())
96
- .filter((s) => s.length > 0);
102
+ if (!raw || raw.trim().length === 0) return [];
103
+
104
+ let tokens: string[];
105
+ const trimmed = raw.trim();
106
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = JSON.parse(trimmed);
110
+ } catch {
111
+ throw new Error(
112
+ `${TRUSTED_SUBNETS_CONFIG_KEY} looks like JSON but does not parse: ${raw}. Expected a JSON array of CIDRs, e.g. ["10.0.0.0/24"], or a comma-separated CIDR list.`,
113
+ );
114
+ }
115
+ if (!Array.isArray(parsed) || parsed.some((t) => typeof t !== 'string')) {
116
+ throw new Error(
117
+ `${TRUSTED_SUBNETS_CONFIG_KEY} must be a JSON array of CIDR strings, e.g. ["10.0.0.0/24"], or a comma-separated CIDR list.`,
118
+ );
119
+ }
120
+ tokens = parsed;
121
+ } else {
122
+ tokens = trimmed.split(',');
123
+ }
124
+
125
+ const subnets = tokens.map((s) => s.trim()).filter((s) => s.length > 0);
126
+ for (const subnet of subnets) {
127
+ if (!isCidr(subnet)) {
128
+ throw new Error(
129
+ `${TRUSTED_SUBNETS_CONFIG_KEY} contains "${subnet}", which is not an IPv4 CIDR. Expected e.g. 10.0.0.0/24 (comma-separated) or ["10.0.0.0/24"] (JSON array).`,
130
+ );
131
+ }
132
+ }
133
+ return subnets;
97
134
  }
98
135
 
99
136
  /**
@@ -9,6 +9,7 @@ import { join } from 'node:path';
9
9
  import { closeDb, createDbClient } from '../db/client';
10
10
  import { runMigrations } from '../db/migrate';
11
11
  import { systemConfig } from '../db/schema';
12
+ import { resetTestDbPath } from '../test-utils/db-path';
12
13
  import { detectZoneFromIp, isValidIp } from './zone-detector';
13
14
 
14
15
  describe('zone-detector', () => {
@@ -46,7 +47,7 @@ describe('zone-detector', () => {
46
47
  }
47
48
 
48
49
  // Clear environment variables
49
- process.env.CELILO_DB_PATH = undefined;
50
+ resetTestDbPath();
50
51
  });
51
52
 
52
53
  describe('detectZoneFromIp', () => {
@@ -817,9 +817,16 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
817
817
  const configMap = new Map(configs.map((c) => [c.key, c.value || c.valueJson]));
818
818
 
819
819
  // Check for missing required variables
820
- // Skip infrastructure and terraform variables - they're auto-populated during/after generation
820
+ // Skip infrastructure and terraform variables - they're auto-populated during/after generation.
821
+ // Skip hook-owned variables too: they are discovered by the module's own
822
+ // hooks at runtime (hook-owned-state D2), so a hook that has not run yet
823
+ // is not a configuration error.
821
824
  const missingVars = requiredVars.filter(
822
- (v) => v.source !== 'infrastructure' && v.source !== 'terraform' && !configMap.has(v.name),
825
+ (v) =>
826
+ v.source !== 'infrastructure' &&
827
+ v.source !== 'terraform' &&
828
+ v.source !== 'hook' &&
829
+ !configMap.has(v.name),
823
830
  );
824
831
 
825
832
  if (missingVars.length > 0) {
@@ -50,6 +50,7 @@ import {
50
50
  type ResponderValues,
51
51
  startProgrammaticResponder,
52
52
  } from '../services/programmatic-responder';
53
+ import { resetTestDbPath } from './db-path';
53
54
 
54
55
  export interface BusResponderFixtureOptions extends ResponderValues {
55
56
  /** Path to the bus sqlite db (shared with the celilo subprocess). */
@@ -98,7 +99,6 @@ export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusR
98
99
  const prevDataDir = process.env.CELILO_DATA_DIR;
99
100
  process.env.CELILO_DATA_DIR = opts.dataDir;
100
101
 
101
- const prevDbPath = process.env.CELILO_DB_PATH;
102
102
  process.env.CELILO_DB_PATH = opts.celiloDbPath;
103
103
  const db: DbClient = createDbClient();
104
104
 
@@ -132,8 +132,10 @@ export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusR
132
132
  db.$client.close();
133
133
  if (prevDataDir === undefined) process.env.CELILO_DATA_DIR = undefined;
134
134
  else process.env.CELILO_DATA_DIR = prevDataDir;
135
- if (prevDbPath === undefined) process.env.CELILO_DB_PATH = undefined;
136
- else process.env.CELILO_DB_PATH = prevDbPath;
135
+ // Reset to the scratch path, never restore the previous value: it may
136
+ // be another suite's temp database, or unset — which sends the next
137
+ // var-less reader to the operator's real celilo.db (celilo#1315).
138
+ resetTestDbPath();
137
139
  },
138
140
  };
139
141
  }
@@ -0,0 +1,25 @@
1
+ import { tmpdir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The neutral database path the test preload arms `CELILO_DB_PATH` with
6
+ * (celilo#1315). The file does not exist, so a reader falls into whatever
7
+ * "no database" branch its feature has — and no test ever opens the
8
+ * operator's real `~/Library/.../celilo.db`, which is what a suite that
9
+ * reads the variable without setting it would otherwise reach.
10
+ *
11
+ * Why teardowns RESET to this path instead of `delete`: bunfig's `[test]`
12
+ * preload runs ONCE PER PROCESS, not once per test file. A teardown that
13
+ * clears the variable leaves every later var-less reader on the real
14
+ * database for the rest of the run. Resetting to the scratch path keeps the
15
+ * preload's invariant standing from the first test to the last.
16
+ */
17
+ export const SCRATCH_DB_PATH = join(tmpdir(), 'celilo-test-absent.db');
18
+
19
+ /**
20
+ * Point `CELILO_DB_PATH` at the neutral scratch path. Call from a test's
21
+ * afterEach (or any teardown) that pointed the variable somewhere else.
22
+ */
23
+ export function resetTestDbPath(): void {
24
+ process.env.CELILO_DB_PATH = SCRATCH_DB_PATH;
25
+ }
@@ -54,6 +54,13 @@ export interface IntegrationTestContext {
54
54
  * ```
55
55
  */
56
56
  export async function setupIntegrationTest(): Promise<IntegrationTestContext> {
57
+ // celilo#1246: the shell on a dev host carries CELILO_HOME pointing at the
58
+ // live install (DB and master.key included). Every CLI child this harness
59
+ // spawns would inherit it, and isolation would then rest on the per-run
60
+ // CELILO_DB_PATH / CELILO_DATA_DIR overrides below never being missed.
61
+ // Unset it once, here, so no child ever sees the live home.
62
+ delete process.env.CELILO_HOME;
63
+
57
64
  // Setup isolated database
58
65
  const testDb = await setupTestDatabaseFile();
59
66
  const dbPath = testDb.path;
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { CLIContext } from './cli-context';
11
+ import { resetTestDbPath } from './db-path';
11
12
  import { getModuleTestConfig } from './fixtures';
12
13
  import { type IntegrationTestContext, setupIntegrationTest } from './integration';
13
14
 
@@ -50,7 +51,6 @@ interface HomebridgeModuleOptions extends BaseFixtureOptions {
50
51
  async function insertMockContainerService(dbPath: string): Promise<void> {
51
52
  const { getDb } = await import('../db/client');
52
53
  const { containerServices } = await import('../db/schema');
53
- const originalDbPath = process.env.CELILO_DB_PATH;
54
54
  process.env.CELILO_DB_PATH = dbPath;
55
55
  try {
56
56
  const db = getDb();
@@ -77,11 +77,10 @@ async function insertMockContainerService(dbPath: string): Promise<void> {
77
77
  updatedAt: new Date(),
78
78
  });
79
79
  } finally {
80
- if (originalDbPath) {
81
- process.env.CELILO_DB_PATH = originalDbPath;
82
- } else {
83
- process.env.CELILO_DB_PATH = undefined;
84
- }
80
+ // Reset to the scratch path, never restore the previous value: it may
81
+ // be another suite's temp database, or unset — which sends the next
82
+ // var-less reader to the operator's real celilo.db (celilo#1315).
83
+ resetTestDbPath();
85
84
  }
86
85
  }
87
86
 
@@ -577,19 +577,30 @@ async function assembleResolutionContext(
577
577
  // LXC boots with a working resolver — before Ansible's apt update, and
578
578
  // independent of which Proxmox node it lands on (the nodes' DNS defaults
579
579
  // diverge): the internal dns_internal resolver first (it recurses, so it
580
- // also answers external names), then the public resolvers as fallback.
580
+ // also answers external names), then the configured fleet resolvers as
581
+ // fallback.
581
582
  // Bootstrap (no dns_internal provider registered yet — e.g. the DNS module's
582
583
  // own LXC) → public only, so apt still works. Injected as
583
584
  // $self:lxc_nameserver, mirroring how inventory.* are auto-derived; the
584
585
  // proxmox_lxc terraform template drops it straight into `nameserver`.
585
586
  {
586
- const publicDns: string[] = [];
587
+ // Named for what the keys hold, not for what a reader hopes they hold:
588
+ // `dns.primary` / `dns.fallback` mean "the resolvers systems should use",
589
+ // and an operator pointing the fleet at its own resolver (a deployed
590
+ // dns_internal provider's address) is established, deliberate practice
591
+ // (celilo#1239). These entries may therefore be private. The guard against
592
+ // a resolver celilo installed ending up here lives where the keys are
593
+ // WRITTEN from host state — discovery refuses a deployed provider's
594
+ // address (dns-discovery.ts) — not in a privateness check here, which
595
+ // would break that practice while fixing nothing: in an uncovered zone
596
+ // dropping the entries takes DNS away rather than tightening anything.
597
+ const configuredDns: string[] = [];
587
598
  for (const key of ['dns.primary', 'dns.fallback']) {
588
599
  const raw = systemConfigMap[key];
589
600
  if (raw) {
590
601
  for (const part of raw.split(',')) {
591
602
  const ip = part.trim();
592
- if (ip) publicDns.push(ip);
603
+ if (ip) configuredDns.push(ip);
593
604
  }
594
605
  }
595
606
  }
@@ -663,8 +674,8 @@ async function assembleResolutionContext(
663
674
  const nameservers = dropPublicResolvers
664
675
  ? [primaryIp, secondaryIp]
665
676
  : primaryIp
666
- ? [primaryIp, ...publicDns]
667
- : publicDns;
677
+ ? [primaryIp, ...configuredDns]
678
+ : configuredDns;
668
679
  const unique = [...new Set(nameservers)];
669
680
  if (unique.length > 0) {
670
681
  selfConfig.lxc_nameserver = unique.join(' ');
@@ -1,79 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import {
6
- compareVerbatimRoleAssets,
7
- describeVerbatimDifferences,
8
- readVerbatimRoleAssets,
9
- } from './generated-plane';
10
-
11
- describe('compareVerbatimRoleAssets', () => {
12
- const asset = 'ansible/roles/vpn/files/vpn-linux-x86_64';
13
-
14
- test('identical trees produce no differences', () => {
15
- const both = new Map([[asset, 'aaa']]);
16
- expect(compareVerbatimRoleAssets(both, new Map(both))).toEqual([]);
17
- });
18
-
19
- test('the celilo#925 shape: generated holds the previous version bytes', () => {
20
- const differences = compareVerbatimRoleAssets(
21
- new Map([[asset, 'new-digest']]),
22
- new Map([[asset, 'old-digest']]),
23
- );
24
- expect(differences).toEqual([
25
- {
26
- relPath: asset,
27
- reason: 'stale',
28
- installedDigest: 'new-digest',
29
- generatedDigest: 'old-digest',
30
- },
31
- ]);
32
- expect(describeVerbatimDifferences(differences)[0]).toContain(asset);
33
- });
34
-
35
- test('an asset generation never produced is missing, not stale', () => {
36
- expect(compareVerbatimRoleAssets(new Map([[asset, 'aaa']]), new Map())).toEqual([
37
- { relPath: asset, reason: 'missing', installedDigest: 'aaa', generatedDigest: null },
38
- ]);
39
- });
40
-
41
- test('output for a role the module no longer has is not a difference', () => {
42
- // Nothing includes it, so refusing a deploy over it would refuse a correct
43
- // module for a file Ansible never reads.
44
- expect(
45
- compareVerbatimRoleAssets(new Map(), new Map([['ansible/roles/gone/files/x', 'aaa']])),
46
- ).toEqual([]);
47
- });
48
- });
49
-
50
- describe('readVerbatimRoleAssets', () => {
51
- test('digests role files/ assets and nothing else', async () => {
52
- const root = mkdtempSync(join(tmpdir(), 'celilo-plane-'));
53
- try {
54
- mkdirSync(join(root, 'ansible', 'roles', 'vpn', 'files', 'nested'), { recursive: true });
55
- mkdirSync(join(root, 'ansible', 'roles', 'vpn', 'templates'), { recursive: true });
56
- writeFileSync(join(root, 'ansible', 'roles', 'vpn', 'files', 'bin'), 'NEW');
57
- writeFileSync(join(root, 'ansible', 'roles', 'vpn', 'files', 'nested', 'blob'), 'B');
58
- // Templated, and expected to differ once generated. Must not be compared.
59
- writeFileSync(join(root, 'ansible', 'roles', 'vpn', 'templates', 'x.j2'), '{{ v }}');
60
-
61
- const assets = await readVerbatimRoleAssets(root);
62
- expect([...assets.keys()].sort()).toEqual([
63
- 'ansible/roles/vpn/files/bin',
64
- 'ansible/roles/vpn/files/nested/blob',
65
- ]);
66
- } finally {
67
- rmSync(root, { recursive: true, force: true });
68
- }
69
- });
70
-
71
- test('a module with no ansible/ at all yields nothing rather than throwing', async () => {
72
- const root = mkdtempSync(join(tmpdir(), 'celilo-plane-'));
73
- try {
74
- expect((await readVerbatimRoleAssets(root)).size).toBe(0);
75
- } finally {
76
- rmSync(root, { recursive: true, force: true });
77
- }
78
- });
79
- });
@@ -1,134 +0,0 @@
1
- /**
2
- * The second plane: is what we would deploy built from what we installed?
3
- *
4
- * A module exists in four places — the published package, the installed tree,
5
- * the generated project, and the host — and celilo reported a version for the
6
- * first as though it described the last. Between them sit three copies and two
7
- * transformations, and nothing verified any of them against the one before it
8
- * (openspec/changes/module-integrity-rigor, D3).
9
- *
10
- * Only VERBATIM assets have a meaningful expected digest. Ansible templates
11
- * most of what it writes, and a `.j2` in `generated/` is supposed to differ
12
- * from its source. `ansible/roles/<role>/files/` is the exception: it holds
13
- * static assets — built binaries, certs, blobs — copied byte for byte because
14
- * they need no variable resolution and may not survive utf-8 round-tripping.
15
- *
16
- * That is exactly where celilo#925 lived. `copyAnsibleRoleFilesDirs` skipped an
17
- * existing destination on bun, so a module's built binary landed in
18
- * `generated/` once, at first generate, and no later version replaced it.
19
- * `cp` reported no error, Ansible copied that first binary forever and reported
20
- * `ok`, and the module's version field advanced past code that was never
21
- * shipped. This comparison is local, takes milliseconds, needs no SSH, and is
22
- * the check that turns that silence into a refusal.
23
- */
24
-
25
- import { existsSync } from 'node:fs';
26
- import { readdir } from 'node:fs/promises';
27
- import { join, relative } from 'node:path';
28
- import { computeFileChecksum } from './checksum';
29
-
30
- export interface VerbatimAssetDifference {
31
- /** Path relative to the module root, identical in both trees. */
32
- relPath: string;
33
- /** `missing`: generation never produced it. `stale`: it holds other bytes. */
34
- reason: 'missing' | 'stale';
35
- installedDigest: string;
36
- generatedDigest: string | null;
37
- }
38
-
39
- /**
40
- * Pure. Both sides are digest maps keyed by the same module-relative path.
41
- *
42
- * An asset present in `generated` but absent from `installed` is NOT reported.
43
- * That is a role the module no longer has, whose old output nothing includes,
44
- * and failing a deploy for it would refuse a correct module for a stale file
45
- * Ansible never reads.
46
- */
47
- export function compareVerbatimRoleAssets(
48
- installed: ReadonlyMap<string, string>,
49
- generated: ReadonlyMap<string, string>,
50
- ): VerbatimAssetDifference[] {
51
- const differences: VerbatimAssetDifference[] = [];
52
- for (const [relPath, installedDigest] of installed) {
53
- const generatedDigest = generated.get(relPath) ?? null;
54
- if (generatedDigest === null) {
55
- differences.push({ relPath, reason: 'missing', installedDigest, generatedDigest: null });
56
- } else if (generatedDigest !== installedDigest) {
57
- differences.push({ relPath, reason: 'stale', installedDigest, generatedDigest });
58
- }
59
- }
60
- return differences;
61
- }
62
-
63
- /**
64
- * Digest every `ansible/roles/<role>/files/**` asset under `root`, keyed by its
65
- * path relative to `root`. The generated project mirrors that layout, so the
66
- * two maps this produces are directly comparable.
67
- */
68
- export async function readVerbatimRoleAssets(root: string): Promise<Map<string, string>> {
69
- const assets = new Map<string, string>();
70
- const rolesDir = join(root, 'ansible', 'roles');
71
- if (!existsSync(rolesDir)) return assets;
72
-
73
- for (const role of await readdir(rolesDir, { withFileTypes: true })) {
74
- if (!role.isDirectory()) continue;
75
- const filesDir = join(rolesDir, role.name, 'files');
76
- if (!existsSync(filesDir)) continue;
77
- for (const filePath of await listFiles(filesDir)) {
78
- assets.set(relative(root, filePath), await computeFileChecksum(filePath));
79
- }
80
- }
81
- return assets;
82
- }
83
-
84
- async function listFiles(dir: string): Promise<string[]> {
85
- const found: string[] = [];
86
- for (const entry of await readdir(dir, { withFileTypes: true })) {
87
- const full = join(dir, entry.name);
88
- if (entry.isDirectory()) {
89
- found.push(...(await listFiles(full)));
90
- } else if (entry.isFile()) {
91
- found.push(full);
92
- }
93
- }
94
- return found;
95
- }
96
-
97
- /**
98
- * One line per difference, naming the file. celilo#925 took days partly because
99
- * nothing anywhere named the file that had gone stale.
100
- */
101
- export function describeVerbatimDifferences(differences: VerbatimAssetDifference[]): string[] {
102
- return differences.map((d) =>
103
- d.reason === 'missing'
104
- ? `${d.relPath}: generation never produced it (installed ${d.installedDigest})`
105
- : `${d.relPath}: generated holds ${d.generatedDigest}, installed is ${d.installedDigest}`,
106
- );
107
- }
108
-
109
- /**
110
- * The deploy pre-flight (D6). Returns the refusal message, or `null` to proceed.
111
- *
112
- * Local, milliseconds, no SSH, and it runs before anything contacts a system.
113
- * It deliberately does NOT verify after the deploy: Ansible's `changed` is
114
- * truthful about what Ansible did, and the lie in celilo#925 was upstream of
115
- * Ansible.
116
- */
117
- export async function refuseIfGeneratedIsStale(
118
- moduleId: string,
119
- modulePath: string,
120
- generatedPath: string,
121
- ): Promise<string | null> {
122
- const differences = compareVerbatimRoleAssets(
123
- await readVerbatimRoleAssets(modulePath),
124
- await readVerbatimRoleAssets(generatedPath),
125
- );
126
- if (differences.length === 0) return null;
127
- return [
128
- `Refusing to deploy ${moduleId}: the generated project does not match the installed module.`,
129
- ` ${differences.length} verbatim asset(s) differ:`,
130
- ...describeVerbatimDifferences(differences).map((line) => ` ${line}`),
131
- '',
132
- `Deploying would ship these bytes and report success. Run 'celilo module generate ${moduleId}' and try again.`,
133
- ].join('\n');
134
- }