@celilo/cli 2.1.0 → 2.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 (93) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +2 -2
  4. package/schemas/system_config.json +2 -1
  5. package/src/capabilities/public-web-publish.test.ts +61 -0
  6. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  7. package/src/cli/commands/firewall-interface-list.ts +73 -7
  8. package/src/cli/commands/machine-add.ts +12 -55
  9. package/src/cli/commands/module-config.test.ts +20 -1
  10. package/src/cli/commands/module-import.ts +1 -1
  11. package/src/cli/commands/module-update.test.ts +82 -0
  12. package/src/cli/commands/module-update.ts +14 -4
  13. package/src/cli/commands/monitor.ts +2 -10
  14. package/src/cli/commands/restore.ts +16 -6
  15. package/src/cli/generate-zsh-completion.ts +1 -1
  16. package/src/cli/index.ts +4 -3
  17. package/src/cli/restore-migration-failure.test.ts +159 -0
  18. package/src/db/client.ts +5 -0
  19. package/src/db/migrate.test.ts +61 -135
  20. package/src/db/migrate.ts +7 -2
  21. package/src/db/schema.ts +10 -0
  22. package/src/hooks/broker.test.ts +106 -2
  23. package/src/hooks/broker.ts +91 -1
  24. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  25. package/src/hooks/capability-loader.ts +15 -1
  26. package/src/hooks/define-hook.test.ts +4 -3
  27. package/src/hooks/executor.test.ts +19 -18
  28. package/src/hooks/executor.ts +82 -11
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  30. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  31. package/src/hooks/hook-protocol.ts +46 -1
  32. package/src/hooks/hook-runner.ts +36 -0
  33. package/src/hooks/hook-store-proxy.test.ts +109 -0
  34. package/src/hooks/hook-store-proxy.ts +85 -0
  35. package/src/hooks/hook-store.test.ts +162 -0
  36. package/src/hooks/hook-store.ts +290 -0
  37. package/src/hooks/hook-timeout.test.ts +3 -2
  38. package/src/hooks/hook-trespass.test.ts +29 -5
  39. package/src/hooks/jail.test.ts +1 -1
  40. package/src/hooks/jail.ts +14 -7
  41. package/src/hooks/mount-set.test.ts +208 -0
  42. package/src/hooks/mount-set.ts +62 -14
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  45. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  46. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  47. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  48. package/src/hooks/unjailed-lint.test.ts +22 -6
  49. package/src/manifest/schema.ts +1 -0
  50. package/src/module/packaging/build.ts +70 -2
  51. package/src/module/web-root.ts +17 -1
  52. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  53. package/src/policy/module-script-scan.test.ts +42 -1
  54. package/src/policy/module-script-scan.ts +275 -5
  55. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  56. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  57. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  58. package/src/registry/client.test.ts +149 -0
  59. package/src/registry/client.ts +203 -11
  60. package/src/services/alerting/format.test.ts +57 -0
  61. package/src/services/alerting/format.ts +24 -0
  62. package/src/services/alerting/run-monitor.ts +2 -2
  63. package/src/services/backup-create.ts +7 -7
  64. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  65. package/src/services/backup-restore.ts +8 -4
  66. package/src/services/bus-interview.ts +37 -14
  67. package/src/services/config-provenance.ts +4 -0
  68. package/src/services/control-plane-bootstrap.test.ts +121 -1
  69. package/src/services/control-plane-bootstrap.ts +51 -4
  70. package/src/services/deploy-preflight.ts +8 -2
  71. package/src/services/deploy-validation.test.ts +22 -0
  72. package/src/services/deploy-validation.ts +8 -0
  73. package/src/services/dns-discovery.test.ts +54 -0
  74. package/src/services/dns-discovery.ts +47 -5
  75. package/src/services/fleet-key.test.ts +66 -2
  76. package/src/services/fleet-key.ts +54 -0
  77. package/src/services/health-runner.ts +2 -0
  78. package/src/services/module-config.ts +20 -2
  79. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  80. package/src/services/module-deploy.ts +163 -1
  81. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  82. package/src/services/module-validator/git-hygiene.ts +83 -14
  83. package/src/services/restore-from-file.test.ts +20 -0
  84. package/src/services/restore-from-file.ts +21 -6
  85. package/src/services/static-content-converge.test.ts +140 -2
  86. package/src/services/static-content-converge.ts +55 -8
  87. package/src/services/system-config-schema-types.ts +1 -1
  88. package/src/services/system-config-validator.test.ts +36 -0
  89. package/src/services/system-config-validator.ts +11 -0
  90. package/src/services/trusted-sources.test.ts +30 -0
  91. package/src/services/trusted-sources.ts +47 -10
  92. package/src/templates/generator.ts +9 -2
  93. package/src/variables/context.ts +16 -5
@@ -4,21 +4,21 @@
4
4
  * For system state backups, restores the Celilo database.
5
5
  */
6
6
 
7
- import { execSync } from 'node:child_process';
8
7
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
9
8
  import { tmpdir } from 'node:os';
10
9
  import { join } from 'node:path';
11
10
  import { eq } from 'drizzle-orm';
11
+ import { extract as tarExtract } from 'tar';
12
12
  import { getDbPath } from '../config/paths';
13
13
  import { closeDb, getDb } from '../db/client';
14
14
  import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
15
15
  import type { Backup } from '../db/schema';
16
16
  import { invokeHook } from '../hooks/executor';
17
+ import { createHookStores } from '../hooks/hook-store';
17
18
  import { createConsoleLogger } from '../hooks/logger';
18
19
  import type { ModuleManifest } from '../manifest/schema';
19
20
  import { decryptSecret } from '../secrets/encryption';
20
21
  import { getOrCreateMasterKey } from '../secrets/master-key';
21
- import { shellEscape } from '../utils/shell';
22
22
  import { decryptFileToFile } from './backup-cipher';
23
23
  import { assertCompatibleSchema, parseManifest } from './backup-manifest';
24
24
  import { createStorageProvider } from './backup-storage';
@@ -70,7 +70,7 @@ export async function restoreSystemStateBackup(backup: Backup): Promise<RestoreR
70
70
  mkdirSync(envelopeDir, { recursive: true });
71
71
  const tarPath = join(tempDir, 'envelope.tar');
72
72
  await decryptFileToFile(encryptedPath, tarPath, masterKey);
73
- execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
73
+ await tarExtract({ file: tarPath, cwd: envelopeDir });
74
74
 
75
75
  // Read + validate manifest BEFORE touching the live DB. An
76
76
  // incompatible artifact must not get past this point.
@@ -180,6 +180,9 @@ export async function restoreModuleBackup(
180
180
  if (!backup.moduleId) {
181
181
  return { success: false, error: 'Backup has no associated module' };
182
182
  }
183
+ // Captured because the narrowing above does not survive into the hookStores
184
+ // callback below, where TypeScript widens the parameter back to string|null.
185
+ const moduleId = backup.moduleId;
183
186
 
184
187
  const db = getDb();
185
188
  const mod = db.select().from(modules).where(eq(modules.id, backup.moduleId)).get();
@@ -221,7 +224,7 @@ export async function restoreModuleBackup(
221
224
  const masterKey = await getOrCreateMasterKey();
222
225
  const tarPath = join(tempDir, 'envelope.tar');
223
226
  await decryptFileToFile(encryptedPath, tarPath, masterKey);
224
- execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
227
+ await tarExtract({ file: tarPath, cwd: envelopeDir });
225
228
 
226
229
  // Read + validate envelope manifest BEFORE invoking the hook.
227
230
  const manifestPath = join(envelopeDir, 'manifest.json');
@@ -290,6 +293,7 @@ export async function restoreModuleBackup(
290
293
  debug: false,
291
294
  systems: getModuleSystems(backup.moduleId, db),
292
295
  remoteAccess: remoteAccessPolicy(backup.moduleId, db),
296
+ hookStores: () => createHookStores(db, moduleId),
293
297
  },
294
298
  );
295
299
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
18
18
  import { getEventBusPath } from '../config/paths';
19
- import { InterviewAbandonedError } from './interview-errors';
19
+ import { InterviewAbandonedError, InterviewUnansweredError } from './interview-errors';
20
20
  import { ensureResponderForInterview } from './responder-probe';
21
21
 
22
22
  const NO_SCHEMAS = defineEvents({});
@@ -271,17 +271,26 @@ export async function busInterview<TReply>(
271
271
  type: string,
272
272
  payload: object,
273
273
  ownerBus?: Bus,
274
+ timeoutMs = 0,
274
275
  ): Promise<TReply> {
275
276
  const ownsBus = !ownerBus;
276
277
  const bus: Bus = ownerBus ?? openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
277
278
  try {
278
279
  const replies = await bus.query(type as never, payload as never, {
279
- timeoutMs: 0, // wait forever — race semantics, no timeout
280
+ timeoutMs, // 0 = wait forever — race semantics, no timeout
280
281
  pollIntervalMs: 250,
281
282
  expect: 'first',
282
283
  });
283
284
  if (replies.length === 0) {
284
- throw new Error(`bus-interview: ${type} returned no reply`);
285
+ // Typed so a bounded caller can distinguish "nobody answered in time"
286
+ // from a decline. With the default 0 this is unreachable (the query
287
+ // waits forever), but keeping it typed costs nothing.
288
+ throw new InterviewUnansweredError(
289
+ type,
290
+ timeoutMs > 0
291
+ ? `No reply within ${timeoutMs}ms (${type})`
292
+ : `bus-interview: ${type} returned no reply`,
293
+ );
285
294
  }
286
295
  return replies[0].payload as unknown as TReply;
287
296
  } finally {
@@ -305,9 +314,10 @@ export async function busInterviewGuarded<TReply>(
305
314
  type: string,
306
315
  payload: object,
307
316
  ownerBus?: Bus,
317
+ timeoutMs = 0,
308
318
  ): Promise<TReply> {
309
319
  await ensureResponderForInterview(type);
310
- return busInterview<TReply>(type, payload, ownerBus);
320
+ return busInterview<TReply>(type, payload, ownerBus, timeoutMs);
311
321
  }
312
322
 
313
323
  /**
@@ -326,9 +336,10 @@ export async function busInterviewGuarded<TReply>(
326
336
  export async function askInterview(
327
337
  payload: InterviewRequiredPayload,
328
338
  ownerBus?: Bus,
339
+ timeoutMs = 0,
329
340
  ): Promise<unknown> {
330
341
  const type = EVENT_TYPES.interviewRequired(payload.scope, payload.key);
331
- const reply = await busInterviewGuarded<InterviewReply>(type, payload, ownerBus);
342
+ const reply = await busInterviewGuarded<InterviewReply>(type, payload, ownerBus, timeoutMs);
332
343
  if (reply.abandoned) throw new InterviewAbandonedError(type, reply.abandoned.reason);
333
344
  return reply.value;
334
345
  }
@@ -463,16 +474,28 @@ export async function askConfirm(opts: {
463
474
  message: string;
464
475
  defaultValue?: boolean;
465
476
  description?: string;
477
+ /**
478
+ * Bound the wait for a reply. 0 (the default) waits forever, which is
479
+ * right when the operator IS the responder (TTY). A headless caller whose
480
+ * responder may be attached but not staged for this question passes a
481
+ * finite bound so it skips instead of hanging until the suite budget
482
+ * kills it (module-orchestrator-primitives design.md D5).
483
+ */
484
+ timeoutMs?: number;
466
485
  }): Promise<boolean> {
467
- const value = await askInterview({
468
- scope: opts.scope,
469
- key: opts.key,
470
- kind: 'confirm',
471
- message: opts.message,
472
- required: true,
473
- defaultValue: opts.defaultValue === undefined ? undefined : String(opts.defaultValue),
474
- description: opts.description,
475
- });
486
+ const value = await askInterview(
487
+ {
488
+ scope: opts.scope,
489
+ key: opts.key,
490
+ kind: 'confirm',
491
+ message: opts.message,
492
+ required: true,
493
+ defaultValue: opts.defaultValue === undefined ? undefined : String(opts.defaultValue),
494
+ description: opts.description,
495
+ },
496
+ undefined,
497
+ opts.timeoutMs,
498
+ );
476
499
  if (typeof value !== 'boolean') {
477
500
  throw new Error(
478
501
  `askConfirm(${opts.scope}.${opts.key}): expected a boolean reply, got ${typeof value}`,
@@ -74,6 +74,8 @@ export function describeDerivedSource(variable: VariableDeclare): string {
74
74
  return 'from the infrastructure celilo selected for this module';
75
75
  case 'terraform':
76
76
  return 'from a Terraform output, at deploy time';
77
+ case 'hook':
78
+ return `by the module's own hooks at runtime (via context.config.set), and never by an operator`;
77
79
  default:
78
80
  return `computed by celilo (source: ${variable.source})`;
79
81
  }
@@ -98,6 +100,8 @@ export function explainNotSettable(moduleId: string, variable: VariableDeclare):
98
100
  return `${header}\n${origin}\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate'.\n • vmid / IP: auto-allocated by IPAM.`;
99
101
  case 'terraform':
100
102
  return `${header}\n${origin}\n • It is read back from Terraform outputs after the deploy creates the resource.`;
103
+ case 'hook':
104
+ return `${header}\n${origin}\n • It is written by the module's own hook via context.config.set, not by any operator path.`;
101
105
  default:
102
106
  return `${header}\n${origin}`;
103
107
  }
@@ -15,7 +15,7 @@ import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { eq } from 'drizzle-orm';
17
17
  import type { DbClient } from '../db/client';
18
- import { systemConfig } from '../db/schema';
18
+ import { capabilities, modules, systemConfig } from '../db/schema';
19
19
  import { setupTestDatabaseAt } from '../test-utils/database';
20
20
  import {
21
21
  type ControlPlaneBootstrapOptions,
@@ -88,6 +88,126 @@ describe('bootstrapControlPlane', () => {
88
88
  expect(configValue(db, 'ssh.public_key')).toBe(PUBLIC_KEY);
89
89
  });
90
90
 
91
+ test('hands discovery every address a deployed resolver advertises (celilo#1239)', async () => {
92
+ // The recurrence gate's second half: the refusal only works if bootstrap
93
+ // actually tells discovery what to refuse. Seed capability rows the way a
94
+ // deployed resolver provider (technitium) and its secondary
95
+ // (knot-unbound-secondary) write them, including the CIDR suffix IPAM
96
+ // resolves target_ip to. Selection is by the declared `fleet_resolver`
97
+ // marker in the data, never the capability name.
98
+ const [module] = db
99
+ .insert(modules)
100
+ .values({
101
+ id: 'technitium',
102
+ name: 'technitium',
103
+ version: '1.0.0',
104
+ manifestData: {},
105
+ sourcePath: '/test/technitium',
106
+ })
107
+ .returning()
108
+ .all();
109
+ db.insert(capabilities)
110
+ .values([
111
+ {
112
+ moduleId: module.id,
113
+ capabilityName: 'dns_internal',
114
+ version: '1.0.0',
115
+ data: {
116
+ fleet_resolver: true,
117
+ server: { ip: '10.0.10.13', internal_ip: '192.168.0.151/24' },
118
+ },
119
+ },
120
+ {
121
+ moduleId: module.id,
122
+ capabilityName: 'dns_internal_secondary',
123
+ version: '1.0.0',
124
+ data: { fleet_resolver: true, server: { ip: '10.0.20.14' } },
125
+ },
126
+ ])
127
+ .run();
128
+
129
+ const seen: string[][] = [];
130
+ await run({
131
+ discoverDnsImpl: (fleetResolverIps) => {
132
+ seen.push([...fleetResolverIps]);
133
+ return DNS;
134
+ },
135
+ });
136
+
137
+ expect(seen).toHaveLength(1);
138
+ expect(seen[0].sort()).toEqual(['10.0.10.13', '192.168.0.151/24', '10.0.20.14'].sort());
139
+ });
140
+
141
+ test('a resolver capability without the declared marker is not a fleet resolver', async () => {
142
+ // Selection is by the DECLARED marker, not the capability name. A row
143
+ // named like a resolver but not declaring fleet_resolver must be ignored,
144
+ // otherwise renaming this to a name check would pass every other test
145
+ // here and silently re-open celilo#1239 to name-shaped drift.
146
+ const [module] = db
147
+ .insert(modules)
148
+ .values({
149
+ id: 'technitium',
150
+ name: 'technitium',
151
+ version: '1.0.0',
152
+ manifestData: {},
153
+ sourcePath: '/test/technitium',
154
+ })
155
+ .returning()
156
+ .all();
157
+ db.insert(capabilities)
158
+ .values({
159
+ moduleId: module.id,
160
+ capabilityName: 'dns_internal',
161
+ version: '1.0.0',
162
+ data: { server: { ip: '10.0.10.13', internal_ip: '192.168.0.151/24' } },
163
+ })
164
+ .run();
165
+
166
+ const seen: string[][] = [];
167
+ await run({
168
+ discoverDnsImpl: (fleetResolverIps) => {
169
+ seen.push([...fleetResolverIps]);
170
+ return DNS;
171
+ },
172
+ });
173
+
174
+ expect(seen).toEqual([[]]);
175
+ });
176
+
177
+ test('a capability of any name declaring the marker is a fleet resolver', async () => {
178
+ // The other half of the same proof: a NEW resolver provider needs no core
179
+ // change. Its capability name is irrelevant; the declaration is what counts.
180
+ const [module] = db
181
+ .insert(modules)
182
+ .values({
183
+ id: 'technitium',
184
+ name: 'technitium',
185
+ version: '1.0.0',
186
+ manifestData: {},
187
+ sourcePath: '/test/technitium',
188
+ })
189
+ .returning()
190
+ .all();
191
+ db.insert(capabilities)
192
+ .values({
193
+ moduleId: module.id,
194
+ capabilityName: 'some_future_resolver',
195
+ version: '1.0.0',
196
+ data: { fleet_resolver: true, server: { ip: '10.0.30.15' } },
197
+ })
198
+ .run();
199
+
200
+ const seen: string[][] = [];
201
+ await run({
202
+ discoverDnsImpl: (fleetResolverIps) => {
203
+ seen.push([...fleetResolverIps]);
204
+ return DNS;
205
+ },
206
+ });
207
+
208
+ expect(seen).toEqual([['10.0.30.15']]);
209
+ });
210
+
91
211
  test('returns what each step found, so the caller renders and this does not', async () => {
92
212
  const result = await run();
93
213
 
@@ -34,6 +34,7 @@
34
34
  import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
35
35
  import { getEventBusPath } from '../config/paths';
36
36
  import type { DbClient } from '../db/client';
37
+ import { capabilities } from '../db/schema';
37
38
  import { type DnsServers, discoverDns } from './dns-discovery';
38
39
  import { type FleetFinding, checkDispatcher } from './fleet-checks';
39
40
  import { type FleetKey, ensureFleetKey } from './fleet-key';
@@ -50,10 +51,55 @@ import { initializeSystem } from './system-init';
50
51
  const DISPATCHER_ATTEMPTS = 5;
51
52
  const DISPATCHER_INTERVAL_MS = 1_000;
52
53
 
54
+ /**
55
+ * The marker a resolver provider declares in its capability data to say "the
56
+ * addresses I advertise are a resolver celilo itself deployed". Core reads
57
+ * this declaration and names no capability (module-business-barrier Scan B).
58
+ * Declared today by knot-unbound-internal, knot-unbound-secondary and
59
+ * technitium (celilo#1239).
60
+ */
61
+ const FLEET_RESOLVER_MARKER = 'fleet_resolver';
62
+
63
+ /** The declared shape of a resolver capability's data. Values checked before use. */
64
+ type ResolverCapabilityData = {
65
+ fleet_resolver?: unknown;
66
+ server?: { ip?: unknown; internal_ip?: unknown };
67
+ };
68
+
69
+ /**
70
+ * Every address a deployed resolver provider advertises for itself.
71
+ *
72
+ * A capability row counts when its data declares {@link FLEET_RESOLVER_MARKER};
73
+ * the capability NAME is irrelevant to this selection, so a new resolver
74
+ * provider needs no core change. Discovery hands the addresses to
75
+ * {@link discoverDns} so a resolv.conf the provider's own aspect wrote is
76
+ * refused rather than adopted as the fleet's upstream (celilo#1239). Addresses
77
+ * come from the declared data only (`server.ip`, `server.internal_ip`). Values
78
+ * may carry a CIDR suffix; discovery normalises both sides before comparing.
79
+ */
80
+ export function fleetResolverAddresses(db: DbClient): string[] {
81
+ const addresses: string[] = [];
82
+ const rows = db.select({ data: capabilities.data }).from(capabilities).all();
83
+ for (const row of rows) {
84
+ const data = row.data as ResolverCapabilityData | undefined;
85
+ if (data?.[FLEET_RESOLVER_MARKER] !== true) continue;
86
+ for (const value of [data.server?.ip, data.server?.internal_ip]) {
87
+ if (typeof value === 'string' && value.length > 0) {
88
+ addresses.push(value);
89
+ }
90
+ }
91
+ }
92
+ return addresses;
93
+ }
94
+
53
95
  export interface ControlPlaneBootstrapOptions {
54
96
  db: DbClient;
55
- /** Injectable so tests drive the whole sequence without a host or a bus. */
56
- discoverDnsImpl?: () => DnsServers;
97
+ /**
98
+ * Injectable so tests drive the whole sequence without a host or a bus.
99
+ * Receives the addresses of resolvers celilo itself deployed, which the
100
+ * discovery must refuse (celilo#1239).
101
+ */
102
+ discoverDnsImpl?: (fleetResolverIps: readonly string[]) => DnsServers;
57
103
  ensureFleetKeyImpl?: () => FleetKey;
58
104
  discoverNetworkImpl?: (db: DbClient) => NetworkDiscoveryResult;
59
105
  /**
@@ -152,7 +198,8 @@ export async function bootstrapControlPlane(
152
198
  ): Promise<ControlPlaneBootstrapResult> {
153
199
  const {
154
200
  db,
155
- discoverDnsImpl = discoverDns,
201
+ discoverDnsImpl = (fleetResolverIps: readonly string[]): DnsServers =>
202
+ discoverDns(undefined, { fleetResolverIps }),
156
203
  ensureFleetKeyImpl = ensureFleetKey,
157
204
  discoverNetworkImpl = discoverAndRecordNetwork,
158
205
  probeDispatcher = probeOwnDispatcher,
@@ -160,7 +207,7 @@ export async function bootstrapControlPlane(
160
207
  dispatcherAttempts = DISPATCHER_ATTEMPTS,
161
208
  } = options;
162
209
 
163
- const dns = discoverDnsImpl();
210
+ const dns = discoverDnsImpl(fleetResolverAddresses(db));
164
211
  const fleetKey = ensureFleetKeyImpl();
165
212
 
166
213
  initializeSystem(db, {
@@ -174,8 +174,14 @@ export async function runPreflight(
174
174
  for (const variable of manifest.variables.owns) {
175
175
  if (!variable.required) continue;
176
176
 
177
- // Infrastructure/terraform variables are auto-derived during deploy
178
- if (variable.source === 'infrastructure' || variable.source === 'terraform') {
177
+ // Infrastructure/terraform variables are auto-derived during deploy;
178
+ // hook-owned variables are discovered by the module's own hooks at
179
+ // runtime (hook-owned-state D2). None of the three is operator input.
180
+ if (
181
+ variable.source === 'infrastructure' ||
182
+ variable.source === 'terraform' ||
183
+ variable.source === 'hook'
184
+ ) {
179
185
  continue;
180
186
  }
181
187
 
@@ -129,6 +129,28 @@ describe('findMissingRequiredVariables (deploy path)', () => {
129
129
  expect(missing[0].value_label).toBeUndefined();
130
130
  });
131
131
 
132
+ // Hook-owned-state task 2.3 (design D2): a `source: hook` variable is
133
+ // discovered by the module's own hook at runtime. It is never operator
134
+ // input, so an unwritten one must not read as missing configuration — not
135
+ // for the interview, and not as a deploy-blocking error.
136
+ test('a source: hook variable is never reported missing, even required and unwritten', async () => {
137
+ const manifest = {
138
+ celilo_contract: '1.0',
139
+ variables: {
140
+ owns: [
141
+ {
142
+ name: 'public_ip',
143
+ type: 'string',
144
+ required: true,
145
+ source: 'hook',
146
+ },
147
+ ],
148
+ },
149
+ } as unknown as ModuleManifest;
150
+ const missing = await findMissingRequiredVariables('testmod', manifest, db);
151
+ expect(missing).toEqual([]);
152
+ });
153
+
132
154
  test('configured secrets are not reported as missing', async () => {
133
155
  db.insert(secrets)
134
156
  .values({
@@ -430,6 +430,14 @@ export async function findMissingRequiredVariables(
430
430
  continue; // These are auto-populated from Terraform outputs
431
431
  }
432
432
 
433
+ // Hook-owned variables are discovered by the module's own hooks at
434
+ // runtime (hook-owned-state D2). An unwritten one is not a missing
435
+ // configuration value — prompting for it or failing validation would
436
+ // ask the operator to guess what a hook has not learned yet.
437
+ if (variable.source === 'hook') {
438
+ continue;
439
+ }
440
+
433
441
  // Check if variable is configured
434
442
  let isConfigured = false;
435
443
 
@@ -90,4 +90,58 @@ describe('discoverDns', () => {
90
90
  test('returns the public fallback on a host with no resolver files at all', () => {
91
91
  expect(discoverDns(host({}))).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
92
92
  });
93
+
94
+ // The recurrence gate for celilo#1239 (bead ce-dxr8). The dns-client-config
95
+ // aspect of a deployed dns_internal provider rewrites /etc/resolv.conf to
96
+ // name that provider. Discovery reading the file back adopts the fleet's own
97
+ // resolver as the fleet's upstream and writes it into dns.primary /
98
+ // dns.fallback, where it reaches every LXC's permanent birth nameserver
99
+ // line. Proven to fail against the accepting implementation first.
100
+ describe('refusing resolvers celilo itself installed', () => {
101
+ // technitium's advertised dns_internal addresses: its dmz zone address and
102
+ // (once advertised) the firewall ingress `internal`-zone systems use.
103
+ const FLEET = ['10.0.10.13', '192.168.0.151'];
104
+
105
+ test('refuses a resolv.conf naming a deployed dns_internal provider address', () => {
106
+ const servers = discoverDns(host({ [ETC]: `nameserver ${FLEET[0]}\n` }), {
107
+ fleetResolverIps: FLEET,
108
+ });
109
+ expect(servers).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
110
+ });
111
+
112
+ test('keeps the real upstreams beside a refused fleet resolver', () => {
113
+ const servers = discoverDns(host({ [ETC]: `nameserver ${FLEET[0]}\nnameserver 9.9.9.9\n` }), {
114
+ fleetResolverIps: FLEET,
115
+ });
116
+ expect(servers).toEqual({ primary: '9.9.9.9', fallback: '1.1.1.1' });
117
+ });
118
+
119
+ test('falls through to /etc/resolv.conf when the systemd file holds only fleet resolvers', () => {
120
+ const servers = discoverDns(
121
+ host({
122
+ [SYSTEMD]: `nameserver ${FLEET[0]}\n`,
123
+ [ETC]: 'nameserver 9.9.9.9\n',
124
+ }),
125
+ { fleetResolverIps: FLEET },
126
+ );
127
+ expect(servers).toEqual({ primary: '9.9.9.9', fallback: '1.1.1.1' });
128
+ });
129
+
130
+ test('matches a capability address that carries a CIDR suffix', () => {
131
+ // Capability data advertises `192.168.0.151/24` (server.ip resolves from
132
+ // target_ip); the resolv.conf entry is the bare address.
133
+ const servers = discoverDns(host({ [ETC]: `nameserver ${FLEET[1]}\n` }), {
134
+ fleetResolverIps: [`${FLEET[1]}/24`],
135
+ });
136
+ expect(servers).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
137
+ });
138
+
139
+ test('accepts a private upstream that is not a fleet resolver', () => {
140
+ // A home-lab upstream on the operator's own network is legitimate. The
141
+ // refusal is scoped to resolvers celilo deployed, not to privateness.
142
+ expect(
143
+ discoverDns(host({ [ETC]: 'nameserver 192.168.0.1\n' }), { fleetResolverIps: FLEET }),
144
+ ).toEqual({ primary: '192.168.0.1', fallback: '1.1.1.1' });
145
+ });
146
+ });
93
147
  });
@@ -72,18 +72,60 @@ export type ResolverFileReader = (path: string) => string | null;
72
72
  const readResolverFile: ResolverFileReader = (path) =>
73
73
  existsSync(path) ? readFileSync(path, 'utf-8') : null;
74
74
 
75
+ /** Options for {@link discoverDns}. */
76
+ export interface DiscoverDnsOptions {
77
+ /**
78
+ * Addresses of resolvers celilo itself deployed, as advertised in the
79
+ * capability data of providers that declare the `fleet_resolver` marker
80
+ * (`server.ip`, `server.internal_ip`). Entries may carry a CIDR suffix.
81
+ */
82
+ fleetResolverIps?: readonly string[];
83
+ }
84
+
85
+ /** Strip a CIDR suffix: `192.168.0.151/24` → `192.168.0.151`. */
86
+ export function bareIp(address: string): string {
87
+ return address.split('/')[0];
88
+ }
89
+
90
+ /**
91
+ * Drop nameservers that are resolvers celilo itself installed.
92
+ *
93
+ * A deployed dns_internal provider's base-module aspect rewrites this box's
94
+ * `/etc/resolv.conf` to name that provider. Reading the file back afterwards
95
+ * adopts the fleet's own resolver as the fleet's upstream and writes it into
96
+ * `dns.primary` / `dns.fallback` (celilo#1239), where it reaches every LXC's
97
+ * permanent birth nameserver line. A resolver answering the fleet is not an
98
+ * upstream for the fleet; refusing it here is what keeps discovery honest
99
+ * once the aspect has run.
100
+ */
101
+ export function excludeFleetResolvers(
102
+ nameservers: string[],
103
+ fleetResolverIps: readonly string[] = [],
104
+ ): string[] {
105
+ if (fleetResolverIps.length === 0) return nameservers;
106
+ const fleet = new Set(fleetResolverIps.map(bareIp));
107
+ return nameservers.filter((ns) => !fleet.has(ns));
108
+ }
109
+
75
110
  /**
76
111
  * Read this box's upstream resolvers.
77
112
  *
78
- * Takes the systemd file only when it yielded a real server. `/etc/resolv.conf`
79
- * is the last resort and whatever it gives is accepted, including the fallback,
80
- * because there is nothing further to consult.
113
+ * Loopback stubs and resolvers celilo itself installed ({@link DiscoverDnsOptions.fleetResolverIps})
114
+ * are refused, so a file holding only refused entries falls through to the
115
+ * next one. `/etc/resolv.conf` is the last resort and whatever survives the
116
+ * refusal is accepted, including the public fallback, because there is
117
+ * nothing further to consult.
81
118
  */
82
- export function discoverDns(read: ResolverFileReader = readResolverFile): DnsServers {
119
+ export function discoverDns(
120
+ read: ResolverFileReader = readResolverFile,
121
+ options: DiscoverDnsOptions = {},
122
+ ): DnsServers {
83
123
  for (const path of RESOLVER_FILES) {
84
124
  const contents = read(path);
85
125
  if (contents === null) continue;
86
- const servers = chooseDns(parseNameservers(contents));
126
+ const servers = chooseDns(
127
+ excludeFleetResolvers(parseNameservers(contents), options.fleetResolverIps),
128
+ );
87
129
  if (servers.primary !== PUBLIC_FALLBACK || path === '/etc/resolv.conf') {
88
130
  return servers;
89
131
  }
@@ -5,10 +5,11 @@
5
5
  */
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
8
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
9
10
  import { tmpdir } from 'node:os';
10
11
  import { join } from 'node:path';
11
- import { ensureFleetKey, getFleetSshDir } from './fleet-key';
12
+ import { ensureFleetKey, findFleetPrivateKey, getFleetSshDir } from './fleet-key';
12
13
 
13
14
  describe('ensureFleetKey', () => {
14
15
  let dataDir: string;
@@ -45,3 +46,66 @@ describe('ensureFleetKey', () => {
45
46
  expect(second.publicKey).toBe(first.publicKey);
46
47
  });
47
48
  });
49
+
50
+ describe('findFleetPrivateKey', () => {
51
+ let dataDir: string;
52
+ let homeDir: string;
53
+ let realHome: string | undefined;
54
+
55
+ beforeEach(() => {
56
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-find-'));
57
+ homeDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-home-'));
58
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
59
+ realHome = process.env.HOME;
60
+ process.env.HOME = homeDir;
61
+ });
62
+
63
+ afterEach(() => {
64
+ process.env.CELILO_DB_PATH = undefined;
65
+ process.env.HOME = realHome;
66
+ rmSync(dataDir, { recursive: true, force: true });
67
+ rmSync(homeDir, { recursive: true, force: true });
68
+ });
69
+
70
+ /** A second, unrelated keypair in $HOME/.ssh, as every real box has. */
71
+ function plantHomeKey(): string {
72
+ const dir = join(homeDir, '.ssh');
73
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ const path = join(dir, 'id_ed25519');
75
+ execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', path, '-C', 'someone-else'], {
76
+ stdio: 'pipe',
77
+ });
78
+ return readFileSync(`${path}.pub`, 'utf-8').trim();
79
+ }
80
+
81
+ it('finds the key celilo minted, even when $HOME holds a different one', () => {
82
+ // This is celilo#1240. `ensureFleetKey` mints into the data dir and
83
+ // `machine add` searched $HOME only, so a fleet whose key celilo minted
84
+ // could not add a machine without --ssh-key-file. The e2e management
85
+ // container has both directories populated with DIFFERENT keys, which is
86
+ // why it failed there and not on a developer's box.
87
+ const homePublicKey = plantHomeKey();
88
+ const fleet = ensureFleetKey();
89
+ expect(fleet.publicKey).not.toBe(homePublicKey);
90
+
91
+ const found = findFleetPrivateKey(fleet.publicKey);
92
+
93
+ expect(found).toBe(join(dataDir, '.ssh', 'id_ed25519'));
94
+ });
95
+
96
+ it('still finds a key that only lives in $HOME', () => {
97
+ // The auto-detected case: `initializeSystem` reads $HOME/.ssh when
98
+ // ssh.public_key is unset, and that fleet must keep working.
99
+ const homePublicKey = plantHomeKey();
100
+
101
+ expect(findFleetPrivateKey(homePublicKey)).toBe(join(homeDir, '.ssh', 'id_ed25519'));
102
+ });
103
+
104
+ it('returns null when no private key matches the recorded public half', () => {
105
+ plantHomeKey();
106
+ const orphan =
107
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA nobody';
108
+
109
+ expect(findFleetPrivateKey(orphan)).toBeNull();
110
+ });
111
+ });