@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
@@ -73,3 +73,57 @@ export function ensureFleetKey(): FleetKey {
73
73
 
74
74
  return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: true };
75
75
  }
76
+
77
+ /**
78
+ * Every directory a fleet private key may sit in, in priority order.
79
+ *
80
+ * Exported so an error message names exactly what was searched. The message
81
+ * used to say "~/.ssh/" while the search had already been widened, which is a
82
+ * smaller version of the bug this whole helper exists to prevent.
83
+ */
84
+ export function fleetKeySearchDirs(): string[] {
85
+ return [getFleetSshDir(), join(process.env.HOME ?? '~', '.ssh')];
86
+ }
87
+
88
+ /**
89
+ * The private key whose public half is `publicKey`, or null.
90
+ *
91
+ * Searches celilo's own key directory FIRST and `$HOME/.ssh` second, and the
92
+ * order is the point. Two different keys are normally present: the one
93
+ * `ensureFleetKey` minted, which is what celilo authorizes on managed
94
+ * machines, and whatever keypair the operator or the base image left in the
95
+ * home directory. Only the first can open a machine celilo provisioned.
96
+ *
97
+ * This lives beside `getFleetSshDir` for the reason that helper exists. The
98
+ * caller that searched for the key hand-derived its own `join(HOME, '.ssh')`
99
+ * and therefore could not see a minted key at all (celilo#1240).
100
+ *
101
+ * Matching ignores the trailing comment, because ssh-keygen writes
102
+ * `user@host` there and the recorded public half carries `celilo-fleet`.
103
+ */
104
+ export function findFleetPrivateKey(publicKey: string): string | null {
105
+ const [type, material] = publicKey.trim().split(/\s+/);
106
+ if (!type || !material) return null;
107
+
108
+ const byType: Record<string, string[]> = {
109
+ 'ssh-ed25519': ['id_ed25519'],
110
+ 'ssh-rsa': ['id_rsa'],
111
+ 'ecdsa-sha2-nistp256': ['id_ecdsa'],
112
+ 'ecdsa-sha2-nistp384': ['id_ecdsa'],
113
+ 'ecdsa-sha2-nistp521': ['id_ecdsa'],
114
+ };
115
+ const candidates = byType[type] ?? ['id_ed25519', 'id_rsa', 'id_ecdsa'];
116
+
117
+ for (const dir of fleetKeySearchDirs()) {
118
+ if (!existsSync(dir)) continue;
119
+ for (const name of candidates) {
120
+ const keyPath = join(dir, name);
121
+ const pubPath = `${keyPath}.pub`;
122
+ if (!existsSync(keyPath) || !existsSync(pubPath)) continue;
123
+ const [fileType, fileMaterial] = readFileSync(pubPath, 'utf-8').trim().split(/\s+/);
124
+ if (fileType === type && fileMaterial === material) return keyPath;
125
+ }
126
+ }
127
+
128
+ return null;
129
+ }
@@ -11,6 +11,7 @@ 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';
@@ -212,6 +213,7 @@ export async function runModuleHealthCheck(
212
213
  requiredCapabilities,
213
214
  systems: getModuleSystems(moduleId, db),
214
215
  remoteAccess: remoteAccessPolicy(moduleId, db),
216
+ hookStores: () => createHookStores(db, moduleId),
215
217
  },
216
218
  );
217
219
  presentation.finish(hookResult.success);
@@ -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,
@@ -40,6 +42,7 @@ import { CONTROL_PLANE_MODULE_ID } from './deployed-systems';
40
42
  import { getModuleSystems } from './deployed-systems';
41
43
  import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
42
44
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
45
+ import { InterviewAbandonedError, InterviewUnansweredError } from './interview-errors';
43
46
  import { findMachineForModule } from './machine-pool';
44
47
  import { ensureRequiredNetworks } from './network-ensure';
45
48
  import {
@@ -388,6 +391,154 @@ export async function deployModule(
388
391
  return result;
389
392
  }
390
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
+
391
542
  async function deployModuleImpl(
392
543
  moduleId: string,
393
544
  db: DbClient,
@@ -724,6 +875,7 @@ async function deployModuleImpl(
724
875
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
725
876
  systems: getModuleSystems(moduleId, db),
726
877
  remoteAccess: remoteAccessPolicy(moduleId, db),
878
+ hookStores: () => createHookStores(db, moduleId),
727
879
  },
728
880
  );
729
881
 
@@ -863,6 +1015,7 @@ async function deployModuleImpl(
863
1015
  requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
864
1016
  systems: getModuleSystems(moduleId, db),
865
1017
  remoteAccess: remoteAccessPolicy(moduleId, db),
1018
+ hookStores: () => createHookStores(db, moduleId),
866
1019
  },
867
1020
  };
868
1021
  },
@@ -1165,6 +1318,7 @@ async function deployModuleImpl(
1165
1318
  requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
1166
1319
  systems: getModuleSystems(capRecord.moduleId, db),
1167
1320
  remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
1321
+ hookStores: () => createHookStores(db, capRecord.moduleId),
1168
1322
  },
1169
1323
  );
1170
1324
 
@@ -1483,6 +1637,7 @@ async function deployModuleImpl(
1483
1637
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1484
1638
  systems: getModuleSystems(moduleId, db),
1485
1639
  remoteAccess: remoteAccessPolicy(moduleId, db),
1640
+ hookStores: () => createHookStores(db, moduleId),
1486
1641
  },
1487
1642
  };
1488
1643
  },
@@ -1608,6 +1763,13 @@ async function deployModuleImpl(
1608
1763
  `DNS backfill failed for '${moduleId}': ${msg}. Some internal DNS records may need manual setup.`,
1609
1764
  );
1610
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);
1611
1773
  }
1612
1774
 
1613
1775
  // Announce each deployed system on the bus (D5/D6) — one
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import { execSync } from 'node:child_process';
3
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
+ import { classifyModulePath } from '../../module/packaging/package-rules';
6
7
  import { checkGitHygiene, checkModuleStale } from './git-hygiene';
7
8
 
8
9
  interface TempRepo {
@@ -18,8 +19,19 @@ interface TempRepo {
18
19
  */
19
20
  function makeTempRepo(): TempRepo {
20
21
  const dir = mkdtempSync(join(tmpdir(), 'celilo-git-hygiene-'));
22
+ // Bound every git child. A git call that hangs used to run until bun's
23
+ // per-test timeout killed it, and bun's dangling-process kill left the
24
+ // WHOLE run parked (reproduced for celilo#1282; the park itself is
25
+ // celilo#1246). Erroring the test instead keeps the failure visible and
26
+ // the run exitable. 30s per command stays under the 60s per-test ceiling
27
+ // this suite runs with.
21
28
  const exec = (cmd: string): string =>
22
- execSync(cmd, { cwd: dir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
29
+ execSync(cmd, {
30
+ cwd: dir,
31
+ encoding: 'utf-8',
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ timeout: 30_000,
34
+ });
23
35
  exec('git init -q');
24
36
  exec('git config user.email "test@example.com"');
25
37
  exec('git config user.name "Test"');
@@ -88,6 +100,113 @@ describe('checkModuleStale', () => {
88
100
  });
89
101
  });
90
102
 
103
+ describe('staleness reach vs the packager (celilo#1270)', () => {
104
+ // Paths planted to span the classifications classifyModulePath produces.
105
+ // The expected staleness outcome is NOT hand-written per path: each test
106
+ // asks classifyModulePath what the packager does with the path and holds
107
+ // the staleness gate to the same answer.
108
+ const planted = [
109
+ 'e2e/split-horizon-views.test.ts',
110
+ 'e2e/fixtures/cases.json',
111
+ 'scripts/install.sh',
112
+ 'scripts/tsconfig.json',
113
+ 'ansible/playbook.yml',
114
+ 'terraform/main.tf',
115
+ 'schema/tables.sql',
116
+ 'hooks/run.test.ts',
117
+ 'generated/report.json',
118
+ 'state/last-run.json',
119
+ ];
120
+
121
+ /** Repo with only manifest.yml committed; returns `commit(rel)` helper. */
122
+ function repoWithManifest() {
123
+ const repo = makeTempRepo();
124
+ writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
125
+ repo.exec('git add manifest.yml && git commit -q -m manifest');
126
+ const commit = (rel: string) => {
127
+ const abs = join(repo.dir, rel);
128
+ mkdirSync(dirname(abs), { recursive: true });
129
+ writeFileSync(abs, 'planted\n');
130
+ repo.exec(`git add ${rel} && git commit -q -m ${JSON.stringify(rel)}`);
131
+ };
132
+ return { repo, commit };
133
+ }
134
+
135
+ test(
136
+ 'a commit touching only e2e/ does not mark the manifest stale',
137
+ () => {
138
+ const { repo, commit } = repoWithManifest();
139
+ try {
140
+ commit('e2e/split-horizon-views.test.ts');
141
+ expect(checkModuleStale(repo.dir)).toBeNull();
142
+ } finally {
143
+ repo.cleanup();
144
+ }
145
+ },
146
+ { timeout: 30_000 },
147
+ );
148
+
149
+ test(
150
+ 'a commit touching scripts/, ansible/, terraform/ or schema/ still fires',
151
+ () => {
152
+ for (const rel of [
153
+ 'scripts/install.sh',
154
+ 'ansible/playbook.yml',
155
+ 'terraform/main.tf',
156
+ 'schema/tables.sql',
157
+ ]) {
158
+ const { repo, commit } = repoWithManifest();
159
+ try {
160
+ commit(rel);
161
+ expect(classifyModulePath(rel)).toBe('package');
162
+ expect(checkModuleStale(repo.dir)).not.toBeNull();
163
+ } finally {
164
+ repo.cleanup();
165
+ }
166
+ }
167
+ },
168
+ { timeout: 30_000 },
169
+ );
170
+
171
+ test(
172
+ 'reach parity: the gate fires exactly for paths the packager ships',
173
+ async () => {
174
+ for (const rel of planted) {
175
+ const { repo, commit } = repoWithManifest();
176
+ try {
177
+ commit(rel);
178
+ const stale = checkModuleStale(repo.dir);
179
+ if (classifyModulePath(rel) === 'package') {
180
+ expect(stale).not.toBeNull();
181
+ } else {
182
+ expect(stale).toBeNull();
183
+ }
184
+ } finally {
185
+ repo.cleanup();
186
+ }
187
+ }
188
+ },
189
+ { timeout: 30_000 },
190
+ );
191
+
192
+ test(
193
+ 'non-shipped commits between manifest and a shipped change stay quiet until shipped bytes move',
194
+ () => {
195
+ const { repo, commit } = repoWithManifest();
196
+ try {
197
+ commit('e2e/split-horizon-views.test.ts');
198
+ commit('scripts/tsconfig.json');
199
+ expect(checkModuleStale(repo.dir)).toBeNull();
200
+ commit('scripts/install.sh');
201
+ expect(checkModuleStale(repo.dir)).not.toBeNull();
202
+ } finally {
203
+ repo.cleanup();
204
+ }
205
+ },
206
+ { timeout: 30_000 },
207
+ );
208
+ });
209
+
91
210
  describe('checkGitHygiene', () => {
92
211
  test('all ok on a fresh, clean repo with manifest as latest commit', () => {
93
212
  const repo = makeTempRepo();