@celilo/cli 2.0.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 (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -11,13 +11,15 @@ import type { DbClient } from '../db/client';
11
11
  import { type ModuleState, modules } from '../db/schema';
12
12
  import { loadCapabilityFunctions } from '../hooks/capability-loader';
13
13
  import { invokeHook } from '../hooks/executor';
14
+ import { createHookStores } from '../hooks/hook-store';
14
15
  import { loadHookConfigMap } from '../hooks/load-hook-config';
15
16
  import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger';
16
17
  import type { HookLogger, HookResult } from '../hooks/types';
17
18
  import type { ModuleManifest } from '../manifest/schema';
18
19
  import { decryptSecret } from '../secrets/encryption';
19
20
  import { getOrCreateMasterKey } from '../secrets/master-key';
20
- import { getModuleSystems } from './deployed-systems';
21
+ import { controlPlaneHealthChecks } from './control-plane-health';
22
+ import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from './deployed-systems';
21
23
  import { remoteAccessPolicy } from './remote-access';
22
24
 
23
25
  export interface HealthCheckItem {
@@ -152,6 +154,19 @@ export async function runModuleHealthCheck(
152
154
  return { moduleId, status: 'error', checks: [], error: 'Module not found' };
153
155
  }
154
156
 
157
+ // The control plane checks itself, in celilo's own process (celilo#1225).
158
+ //
159
+ // This was a health_check hook and it failed under the jail in the worst
160
+ // possible way: it asked `existsSync(db_path)`, the jail deliberately does
161
+ // not bind celilo's data directory, and so it reported a healthy fleet's
162
+ // database MISSING and failed the deploy over a file that was right there.
163
+ // `runFleetChecks` is what `celilo system doctor` already runs and asks
164
+ // eight questions where the hook asked two.
165
+ if (moduleId === CONTROL_PLANE_MODULE_ID) {
166
+ const checks = await controlPlaneHealthChecks(db);
167
+ return finishHealthCheck(moduleId, module.state, checks, db, options);
168
+ }
169
+
155
170
  const manifest = module.manifestData as ModuleManifest;
156
171
  const hookDef = manifest.hooks?.health_check;
157
172
 
@@ -198,6 +213,7 @@ export async function runModuleHealthCheck(
198
213
  requiredCapabilities,
199
214
  systems: getModuleSystems(moduleId, db),
200
215
  remoteAccess: remoteAccessPolicy(moduleId, db),
216
+ hookStores: () => createHookStores(db, moduleId),
201
217
  },
202
218
  );
203
219
  presentation.finish(hookResult.success);
@@ -222,6 +238,23 @@ export async function runModuleHealthCheck(
222
238
  };
223
239
 
224
240
  const checks = outputs.checks || [];
241
+ return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
242
+ }
243
+
244
+ /**
245
+ * Derive the verdict from the named checks and move the module's state.
246
+ *
247
+ * Shared by the hook path and the control plane's own path so the two cannot
248
+ * drift on what `degraded` means or on when a state transition is suppressed.
249
+ */
250
+ function finishHealthCheck(
251
+ moduleId: string,
252
+ moduleState: ModuleState,
253
+ checks: HealthCheckItem[],
254
+ db: DbClient,
255
+ options: HealthCheckOptions,
256
+ artifactPaths?: string[],
257
+ ): HealthCheckResult {
225
258
  const hasFail = checks.some((c) => c.status === 'fail');
226
259
  const hasWarn = checks.some((c) => c.status === 'warn');
227
260
 
@@ -231,12 +264,12 @@ export async function runModuleHealthCheck(
231
264
  ? 'degraded'
232
265
  : 'healthy';
233
266
 
234
- const nextState = nextModuleState(module.state, status, options.unattended ?? false);
267
+ const nextState = nextModuleState(moduleState, status, options.unattended ?? false);
235
268
  if (nextState) {
236
269
  db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
237
270
  }
238
271
 
239
- return { moduleId, status, checks, artifactPaths: hookResult.artifactPaths };
272
+ return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
240
273
  }
241
274
 
242
275
  /**
@@ -90,6 +90,7 @@ export function upsertModuleConfig(
90
90
  moduleId: string,
91
91
  key: string,
92
92
  value: string | number | boolean | unknown[] | Record<string, unknown> | null,
93
+ source?: 'hook',
93
94
  ): void {
94
95
  if (value === null || value === undefined) {
95
96
  throw new Error(`upsertModuleConfig(${moduleId}, ${key}): value must not be null/undefined`);
@@ -97,11 +98,28 @@ export function upsertModuleConfig(
97
98
  const valueJson = JSON.stringify(value);
98
99
  const displayValue = isComplexValue(value) ? valueJson : String(value);
99
100
 
101
+ // `source` records who owns the row (hook-owned-state design D7 — the same
102
+ // column `derived-value-recomputation` reads). Absent keeps the legacy
103
+ // pre-column shape for every existing caller: operator and interview writes
104
+ // stay sourceless until that change gives them their own value to write.
105
+ // Only the hook config store passes 'hook', and only a hook may: the
106
+ // manifest refuses `module config set` on any source: hook variable.
100
107
  db.insert(moduleConfigs)
101
- .values({ moduleId, key, value: displayValue, valueJson })
108
+ .values({
109
+ moduleId,
110
+ key,
111
+ value: displayValue,
112
+ valueJson,
113
+ ...(source !== undefined ? { source } : {}),
114
+ })
102
115
  .onConflictDoUpdate({
103
116
  target: [moduleConfigs.moduleId, moduleConfigs.key],
104
- set: { value: displayValue, valueJson, updatedAt: new Date() },
117
+ set: {
118
+ value: displayValue,
119
+ valueJson,
120
+ updatedAt: new Date(),
121
+ ...(source !== undefined ? { source } : {}),
122
+ },
105
123
  })
106
124
  .run();
107
125
  }
@@ -0,0 +1,187 @@
1
+ // Slice 3 of openspec/changes/module-orchestrator-primitives: celilo performs
2
+ // the dns.primary repoint when a dns_internal provider deploys (D5), gated on
3
+ // an askConfirm interview. knot's on_install no longer touches these keys.
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
+ import { mkdtempSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { eq } from 'drizzle-orm';
9
+ import { type DbClient, closeDb, getDb } from '../db/client';
10
+ import { runMigrations } from '../db/migrate';
11
+ import { capabilities, moduleSystems, modules, systemConfig } from '../db/schema';
12
+ import { InterviewUnansweredError } from './interview-errors';
13
+ import { planDnsRepoint, repointDnsPrimaryForProvider } from './module-deploy';
14
+
15
+ const MODULE_ID = 'knot-unbound-internal';
16
+ const SERVER_IP = '10.226.10.53';
17
+
18
+ type AskOpts = { scope: string; key: string; message: string };
19
+
20
+ function makeAsk(answer: boolean): ((opts: AskOpts) => Promise<boolean>) & { calls: AskOpts[] } {
21
+ const calls: AskOpts[] = [];
22
+ const ask = async (opts: AskOpts) => {
23
+ calls.push(opts);
24
+ return answer;
25
+ };
26
+ return Object.assign(ask, { calls });
27
+ }
28
+
29
+ function makeAskThrowing(err: Error): ((opts: AskOpts) => Promise<boolean>) & { calls: AskOpts[] } {
30
+ const calls: AskOpts[] = [];
31
+ const ask = async (opts: AskOpts) => {
32
+ calls.push(opts);
33
+ throw err;
34
+ };
35
+ return Object.assign(ask, { calls });
36
+ }
37
+
38
+ describe('repointDnsPrimaryForProvider (module-orchestrator-primitives slice 3)', () => {
39
+ let dir: string;
40
+ let db: DbClient;
41
+
42
+ beforeEach(async () => {
43
+ dir = mkdtempSync(join(tmpdir(), 'celilo-dns-repoint-'));
44
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
45
+ await runMigrations(process.env.CELILO_DB_PATH);
46
+ db = getDb();
47
+ seedProvider();
48
+ });
49
+
50
+ afterEach(() => {
51
+ closeDb();
52
+ process.env.CELILO_DB_PATH = undefined;
53
+ try {
54
+ rmSync(dir, { recursive: true, force: true });
55
+ } catch {
56
+ /* temp dir cleanup is best-effort */
57
+ }
58
+ });
59
+
60
+ function seedProvider() {
61
+ db.insert(modules)
62
+ .values({
63
+ id: MODULE_ID,
64
+ name: MODULE_ID,
65
+ version: '1.3.4',
66
+ manifestData: { id: MODULE_ID, name: MODULE_ID, version: '1.3.4', celilo_contract: '1.0' },
67
+ sourcePath: `/tmp/${MODULE_ID}`,
68
+ })
69
+ .run();
70
+ db.insert(capabilities)
71
+ .values({ moduleId: MODULE_ID, capabilityName: 'dns_internal', version: '1.0.0', data: {} })
72
+ .run();
73
+ db.insert(moduleSystems)
74
+ .values({
75
+ moduleId: MODULE_ID,
76
+ name: 'main',
77
+ hostname: 'dns',
78
+ ipv4Address: SERVER_IP,
79
+ zone: 'dmz',
80
+ infraType: 'container_service',
81
+ })
82
+ .run();
83
+ }
84
+
85
+ function setConfig(key: string, value: string) {
86
+ db.insert(systemConfig)
87
+ .values({ key, value })
88
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
89
+ .run();
90
+ }
91
+
92
+ function configValue(key: string): string | undefined {
93
+ return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value;
94
+ }
95
+
96
+ test('3.1: repoints dns.primary at the resolver and demotes the prior primary WITHOUT losing the existing fallback', async () => {
97
+ setConfig('dns.primary', '1.1.1.1');
98
+ setConfig('dns.fallback', '8.8.8.8');
99
+ const ask = makeAsk(true);
100
+
101
+ await repointDnsPrimaryForProvider(MODULE_ID, db, ask);
102
+
103
+ expect(configValue('dns.primary')).toBe(SERVER_IP);
104
+ // The WHOLE resulting value: the existing fallback must survive beside the
105
+ // demoted prior primary (D5's secondary observation — knot's hook lost it).
106
+ expect(configValue('dns.fallback')).toBe('1.1.1.1,8.8.8.8');
107
+ expect(ask.calls).toHaveLength(1);
108
+ expect(ask.calls[0].message).toContain(SERVER_IP);
109
+ expect(ask.calls[0].message).toContain('1.1.1.1');
110
+ });
111
+
112
+ test('3.1: demotes the prior primary to dns.fallback when no fallback existed', async () => {
113
+ setConfig('dns.primary', '1.1.1.1');
114
+ const ask = makeAsk(true);
115
+
116
+ await repointDnsPrimaryForProvider(MODULE_ID, db, ask);
117
+
118
+ expect(configValue('dns.primary')).toBe(SERVER_IP);
119
+ expect(configValue('dns.fallback')).toBe('1.1.1.1');
120
+ });
121
+
122
+ test('3.1: sets dns.primary without touching dns.fallback when no primary was set', async () => {
123
+ const ask = makeAsk(true);
124
+
125
+ await repointDnsPrimaryForProvider(MODULE_ID, db, ask);
126
+
127
+ expect(configValue('dns.primary')).toBe(SERVER_IP);
128
+ expect(configValue('dns.fallback')).toBeUndefined();
129
+ });
130
+
131
+ test('does not ask when dns.primary already names this resolver (re-deploy)', async () => {
132
+ setConfig('dns.primary', SERVER_IP);
133
+ setConfig('dns.fallback', '8.8.8.8');
134
+ const ask = makeAsk(true);
135
+
136
+ await repointDnsPrimaryForProvider(MODULE_ID, db, ask);
137
+
138
+ expect(ask.calls).toHaveLength(0);
139
+ expect(configValue('dns.primary')).toBe(SERVER_IP);
140
+ expect(configValue('dns.fallback')).toBe('8.8.8.8');
141
+ });
142
+
143
+ test('3.2: a negative answer leaves both keys unchanged and does not fail the deploy', async () => {
144
+ setConfig('dns.primary', '1.1.1.1');
145
+ setConfig('dns.fallback', '8.8.8.8');
146
+ const ask = makeAsk(false);
147
+
148
+ await expect(repointDnsPrimaryForProvider(MODULE_ID, db, ask)).resolves.toBeUndefined();
149
+
150
+ expect(configValue('dns.primary')).toBe('1.1.1.1');
151
+ expect(configValue('dns.fallback')).toBe('8.8.8.8');
152
+ expect(ask.calls).toHaveLength(1);
153
+ });
154
+
155
+ test('no responder able to answer (headless) is a defined skip, never a hang', async () => {
156
+ setConfig('dns.primary', '1.1.1.1');
157
+ const ask = makeAskThrowing(
158
+ new InterviewUnansweredError('interview.required.deploy:knot.repoint_dns_primary', 'nobody'),
159
+ );
160
+
161
+ await expect(repointDnsPrimaryForProvider(MODULE_ID, db, ask)).resolves.toBeUndefined();
162
+
163
+ expect(configValue('dns.primary')).toBe('1.1.1.1');
164
+ });
165
+ });
166
+
167
+ describe('planDnsRepoint', () => {
168
+ test('merges the existing fallback without duplicating the demoted primary', () => {
169
+ const plan = planDnsRepoint({
170
+ serverIp: SERVER_IP,
171
+ priorPrimary: '1.1.1.1',
172
+ existingFallback: '1.1.1.1,9.9.9.9',
173
+ });
174
+ expect(plan.needsRepoint).toBe(true);
175
+ expect(plan.newFallback).toBe('1.1.1.1,9.9.9.9');
176
+ });
177
+
178
+ test('already-primary is a no-op plan', () => {
179
+ const plan = planDnsRepoint({
180
+ serverIp: SERVER_IP,
181
+ priorPrimary: SERVER_IP,
182
+ existingFallback: '8.8.8.8',
183
+ });
184
+ expect(plan.needsRepoint).toBe(false);
185
+ expect(plan.newFallback).toBeUndefined();
186
+ });
187
+ });
@@ -11,9 +11,10 @@ import { generateInventory } from '../ansible/inventory';
11
11
  import { FuelGauge } from '../cli/fuel-gauge';
12
12
  import { log, setActiveDisplay } from '../cli/prompts';
13
13
  import type { DbClient } from '../db/client';
14
- import { capabilities, machines, modules } from '../db/schema';
14
+ import { capabilities, machines, modules, systemConfig } from '../db/schema';
15
15
  import { loadCapabilityFunctions } from '../hooks/capability-loader';
16
16
  import { invokeHook } from '../hooks/executor';
17
+ import { createHookStores } from '../hooks/hook-store';
17
18
  import { createGaugeLogger } from '../hooks/logger';
18
19
  import { describeArtifacts } from '../hooks/types';
19
20
  import type { HookDefinition, HookLogger, HookResult } from '../hooks/types';
@@ -22,6 +23,7 @@ import { decryptSecret } from '../secrets/encryption';
22
23
  import { getOrCreateMasterKey } from '../secrets/master-key';
23
24
  import { buildResolutionContext } from '../variables/context';
24
25
  import { maybeRunAspectForTrigger } from './aspect-runner';
26
+ import { askConfirm } from './bus-interview';
25
27
  import {
26
28
  autoDeriveMachineConfig,
27
29
  findEnsureOnProvider,
@@ -30,14 +32,17 @@ import {
30
32
  interviewForMissingSecrets,
31
33
  } from './config-interview';
32
34
  import { getContainerService } from './container-service';
35
+ import { bootstrapControlPlane } from './control-plane-bootstrap';
33
36
  import { executeAnsible } from './deploy-ansible';
34
37
  import { planDeployment } from './deploy-planner';
35
38
  import { waitForSSH } from './deploy-ssh';
36
39
  import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
37
40
  import { validateAndPrepareDeployment } from './deploy-validation';
41
+ import { CONTROL_PLANE_MODULE_ID } from './deployed-systems';
38
42
  import { getModuleSystems } from './deployed-systems';
39
43
  import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
40
44
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
45
+ import { InterviewAbandonedError, InterviewUnansweredError } from './interview-errors';
41
46
  import { findMachineForModule } from './machine-pool';
42
47
  import { ensureRequiredNetworks } from './network-ensure';
43
48
  import {
@@ -386,6 +391,154 @@ export async function deployModule(
386
391
  return result;
387
392
  }
388
393
 
394
+ /**
395
+ * Headless bound for the repoint confirm. A staged responder replies in
396
+ * milliseconds; this exists so an attached-but-unstaged responder produces a
397
+ * defined skip instead of hanging until the suite budget kills the deploy
398
+ * (module-orchestrator-primitives design.md D5, "never a hang"). On a TTY
399
+ * there is no bound: the operator IS the responder and may take minutes.
400
+ */
401
+ const DNS_REPOINT_HEADLESS_TIMEOUT_MS = 60_000;
402
+
403
+ /**
404
+ * The deploy-time DNS repoint for a `dns_internal` provider
405
+ * (module-orchestrator-primitives slice 3, design D5). celilo owns the whole
406
+ * operation because every input is already celilo's: the resolver address is
407
+ * the system celilo just placed, and the prior primary/fallback are celilo's
408
+ * own rows. knot's on_install used to do this through the CLI, one key at a
409
+ * time, under a catch that reported success over a repoint that never
410
+ * happened.
411
+ *
412
+ * D5 is a recorded DELIBERATE EXCEPTION to the framework-does-not-infer-intent
413
+ * rule. What makes it acceptable is the interview: the deploy ASKS before it
414
+ * touches the fleet resolver, and a declined repoint is an operator decision,
415
+ * not an error. Do not cite this as precedent (design.md D5, "So do not cite
416
+ * this as precedent").
417
+ */
418
+ export interface DnsRepointPlan {
419
+ serverIp: string;
420
+ priorPrimary: string | undefined;
421
+ existingFallback: string | undefined;
422
+ /** Merged dns.fallback when the repoint demotes the prior primary; undefined leaves the key untouched. */
423
+ newFallback: string | undefined;
424
+ /** False when dns.primary already names this resolver: the deploy asks nothing. */
425
+ needsRepoint: boolean;
426
+ }
427
+
428
+ /**
429
+ * Pure half of the repoint. The demote keeps an existing fallback (D5's
430
+ * secondary observation: knot's one-key-at-a-time hook overwrote it, dropping
431
+ * e.g. 8.8.8.8 from the fleet's resolver list).
432
+ */
433
+ export function planDnsRepoint(args: {
434
+ serverIp: string;
435
+ priorPrimary: string | undefined;
436
+ existingFallback: string | undefined;
437
+ }): DnsRepointPlan {
438
+ const { serverIp, priorPrimary, existingFallback } = args;
439
+ if (priorPrimary === serverIp) {
440
+ return {
441
+ serverIp,
442
+ priorPrimary,
443
+ existingFallback,
444
+ newFallback: undefined,
445
+ needsRepoint: false,
446
+ };
447
+ }
448
+ const newFallback = priorPrimary ? mergeFallback(existingFallback, priorPrimary) : undefined;
449
+ return { serverIp, priorPrimary, existingFallback, newFallback, needsRepoint: true };
450
+ }
451
+
452
+ /** Comma-separated, demoted primary first, deduped (public-dns-probe.ts parses commas). */
453
+ function mergeFallback(existing: string | undefined, demoted: string): string {
454
+ const entries = [demoted];
455
+ for (const part of (existing ?? '').split(',')) {
456
+ const ip = part.trim();
457
+ if (ip && !entries.includes(ip)) entries.push(ip);
458
+ }
459
+ return entries.join(',');
460
+ }
461
+
462
+ function readSystemConfigValue(db: DbClient, key: string): string | undefined {
463
+ const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
464
+ return row?.value && row.value.length > 0 ? row.value : undefined;
465
+ }
466
+
467
+ function writeSystemConfigValue(db: DbClient, key: string, value: string): void {
468
+ db.insert(systemConfig)
469
+ .values({ key, value })
470
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
471
+ .run();
472
+ }
473
+
474
+ /**
475
+ * Ask, then perform (or skip) the repoint for a just-deployed `dns_internal`
476
+ * provider. Exported for tests; `deployModuleImpl` calls this after the DNS
477
+ * backfill when the deployed module provides `dns_internal`.
478
+ */
479
+ export async function repointDnsPrimaryForProvider(
480
+ moduleId: string,
481
+ db: DbClient,
482
+ ask: typeof askConfirm = askConfirm,
483
+ ): Promise<void> {
484
+ const serverIp = getModuleSystems(moduleId, db)[0]?.ipv4_address;
485
+ if (!serverIp) {
486
+ log.warn(
487
+ `dns_internal provider '${moduleId}' has no deployed system — skipping dns.primary repoint`,
488
+ );
489
+ return;
490
+ }
491
+ const plan = planDnsRepoint({
492
+ serverIp,
493
+ priorPrimary: readSystemConfigValue(db, 'dns.primary'),
494
+ existingFallback: readSystemConfigValue(db, 'dns.fallback'),
495
+ });
496
+ if (!plan.needsRepoint) {
497
+ log.info(`dns.primary already points at ${serverIp} — no repoint needed`);
498
+ return;
499
+ }
500
+
501
+ const demoteNote = plan.newFallback
502
+ ? ` The old primary ${plan.priorPrimary} moves to dns.fallback (${plan.newFallback}).`
503
+ : '';
504
+ const opts = {
505
+ scope: `deploy:${moduleId}`,
506
+ key: 'repoint_dns_primary',
507
+ message: `Deploying '${moduleId}' made it the fleet's internal DNS resolver. Point dns.primary at ${serverIp}?${demoteNote}`,
508
+ defaultValue: false,
509
+ };
510
+ // Called DIRECTLY, not inside withInterviewSession: deployModuleImpl already
511
+ // opened the terminal responder above and closes it in its finally. A second
512
+ // responder instance would race the first on stdin (design.md D5).
513
+ let confirmed: boolean;
514
+ try {
515
+ confirmed = await ask(
516
+ process.stdin.isTTY ? opts : { ...opts, timeoutMs: DNS_REPOINT_HEADLESS_TIMEOUT_MS },
517
+ );
518
+ } catch (error) {
519
+ if (error instanceof InterviewUnansweredError || error instanceof InterviewAbandonedError) {
520
+ log.warn(
521
+ `dns.primary repoint skipped — nobody answered the confirm (${opts.scope}:${opts.key}). Stage the answer (celilo events respond --values) or set dns.primary manually later.`,
522
+ );
523
+ return;
524
+ }
525
+ throw error;
526
+ }
527
+
528
+ if (!confirmed) {
529
+ log.info('dns.primary repoint declined — fleet resolver left unchanged');
530
+ return;
531
+ }
532
+
533
+ writeSystemConfigValue(db, 'dns.primary', serverIp);
534
+ if (plan.newFallback !== undefined) {
535
+ writeSystemConfigValue(db, 'dns.fallback', plan.newFallback);
536
+ }
537
+ log.success(
538
+ `dns.primary repointed at internal resolver ${serverIp}${plan.newFallback ? ` (dns.fallback: ${plan.newFallback})` : ''}`,
539
+ );
540
+ }
541
+
389
542
  async function deployModuleImpl(
390
543
  moduleId: string,
391
544
  db: DbClient,
@@ -722,6 +875,7 @@ async function deployModuleImpl(
722
875
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
723
876
  systems: getModuleSystems(moduleId, db),
724
877
  remoteAccess: remoteAccessPolicy(moduleId, db),
878
+ hookStores: () => createHookStores(db, moduleId),
725
879
  },
726
880
  );
727
881
 
@@ -861,6 +1015,7 @@ async function deployModuleImpl(
861
1015
  requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
862
1016
  systems: getModuleSystems(moduleId, db),
863
1017
  remoteAccess: remoteAccessPolicy(moduleId, db),
1018
+ hookStores: () => createHookStores(db, moduleId),
864
1019
  },
865
1020
  };
866
1021
  },
@@ -1163,6 +1318,7 @@ async function deployModuleImpl(
1163
1318
  requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
1164
1319
  systems: getModuleSystems(capRecord.moduleId, db),
1165
1320
  remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
1321
+ hookStores: () => createHookStores(db, capRecord.moduleId),
1166
1322
  },
1167
1323
  );
1168
1324
 
@@ -1351,6 +1507,55 @@ async function deployModuleImpl(
1351
1507
  // container ID/IP got assigned per module in the DB.
1352
1508
  }
1353
1509
 
1510
+ // celilo initialises its own box, in its own process.
1511
+ //
1512
+ // This was `celilo-mgmt`'s `on_install` hook until celilo#1225. The hook
1513
+ // reached every one of these operations by spawning the `celilo` CLI,
1514
+ // which a jailed hook cannot do — the mount set binds no `/usr/bin`, no
1515
+ // `/usr/local/bin` and no shell — so the deploy ran Ansible to completion
1516
+ // and then died in its own install hook on any host with a jail backend.
1517
+ //
1518
+ // It runs at the point the hook used to, for the reason the hook ran
1519
+ // there: the Ansible role has just installed celilo and started the
1520
+ // dispatcher, and none of this can be read before that.
1521
+ if (moduleId === CONTROL_PLANE_MODULE_ID) {
1522
+ log.success('Initializing celilo management state');
1523
+ try {
1524
+ const bootstrap = await bootstrapControlPlane({ db });
1525
+
1526
+ log.info(` DNS: primary ${bootstrap.dns.primary}, fallback ${bootstrap.dns.fallback}`);
1527
+ for (const applied of bootstrap.network.applied) {
1528
+ log.info(` Network: ${applied}`);
1529
+ }
1530
+ if (bootstrap.network.skipped) {
1531
+ log.warn(` Network not recorded: ${bootstrap.network.skipped}`);
1532
+ }
1533
+ // The operator authorises this key on every machine celilo manages,
1534
+ // so it is printed rather than merely stored. It was the loudest line
1535
+ // the hook produced and it stays the loudest line here.
1536
+ log.success(
1537
+ `celilo fleet SSH key — add this public key to machines celilo will manage:\n${bootstrap.fleetKey.publicKey}`,
1538
+ );
1539
+
1540
+ if (bootstrap.dispatcher.status === 'fail') {
1541
+ // A management plane with no dispatcher is broken: event-driven
1542
+ // reconciles never deliver. Fail the install, do not whisper.
1543
+ log.error(`Dispatcher check failed: ${bootstrap.dispatcher.summary}`);
1544
+ return {
1545
+ success: false,
1546
+ phases,
1547
+ error:
1548
+ `celilo-mgmt: ${bootstrap.dispatcher.summary}. ${bootstrap.dispatcher.remediation ?? ''}`.trim(),
1549
+ };
1550
+ }
1551
+ log.success(`Event dispatcher is live (${bootstrap.dispatcher.summary})`);
1552
+ } catch (err) {
1553
+ const detail = err instanceof Error ? err.message : String(err);
1554
+ log.error(`Initializing celilo management state failed: ${detail}`);
1555
+ return { success: false, phases, error: detail };
1556
+ }
1557
+ }
1558
+
1354
1559
  // Run on_install hook (post-deploy actions like port forwarding, DNS registration)
1355
1560
  if (manifest.hooks?.on_install) {
1356
1561
  const onInstallDef = manifest.hooks.on_install;
@@ -1432,6 +1637,7 @@ async function deployModuleImpl(
1432
1637
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1433
1638
  systems: getModuleSystems(moduleId, db),
1434
1639
  remoteAccess: remoteAccessPolicy(moduleId, db),
1640
+ hookStores: () => createHookStores(db, moduleId),
1435
1641
  },
1436
1642
  };
1437
1643
  },
@@ -1557,6 +1763,13 @@ async function deployModuleImpl(
1557
1763
  `DNS backfill failed for '${moduleId}': ${msg}. Some internal DNS records may need manual setup.`,
1558
1764
  );
1559
1765
  }
1766
+
1767
+ // The dns_internal handoff (module-orchestrator-primitives slice 3,
1768
+ // design D5): celilo repoints dns.primary at the resolver it just
1769
+ // deployed, gated on an interview. knot's on_install no longer does
1770
+ // this. Runs after the backfill so a failed deploy never repoints the
1771
+ // fleet at a resolver that never came up.
1772
+ await repointDnsPrimaryForProvider(moduleId, db);
1560
1773
  }
1561
1774
 
1562
1775
  // Announce each deployed system on the bus (D5/D6) — one