@celilo/cli 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
@@ -5,10 +5,12 @@
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 { resetTestDbPath } from '../test-utils/db-path';
13
+ import { ensureFleetKey, findFleetPrivateKey, getFleetSshDir } from './fleet-key';
12
14
 
13
15
  describe('ensureFleetKey', () => {
14
16
  let dataDir: string;
@@ -19,7 +21,7 @@ describe('ensureFleetKey', () => {
19
21
  });
20
22
 
21
23
  afterEach(() => {
22
- process.env.CELILO_DB_PATH = undefined;
24
+ resetTestDbPath();
23
25
  rmSync(dataDir, { recursive: true, force: true });
24
26
  });
25
27
 
@@ -45,3 +47,66 @@ describe('ensureFleetKey', () => {
45
47
  expect(second.publicKey).toBe(first.publicKey);
46
48
  });
47
49
  });
50
+
51
+ describe('findFleetPrivateKey', () => {
52
+ let dataDir: string;
53
+ let homeDir: string;
54
+ let realHome: string | undefined;
55
+
56
+ beforeEach(() => {
57
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-find-'));
58
+ homeDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-home-'));
59
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
60
+ realHome = process.env.HOME;
61
+ process.env.HOME = homeDir;
62
+ });
63
+
64
+ afterEach(() => {
65
+ resetTestDbPath();
66
+ process.env.HOME = realHome;
67
+ rmSync(dataDir, { recursive: true, force: true });
68
+ rmSync(homeDir, { recursive: true, force: true });
69
+ });
70
+
71
+ /** A second, unrelated keypair in $HOME/.ssh, as every real box has. */
72
+ function plantHomeKey(): string {
73
+ const dir = join(homeDir, '.ssh');
74
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
75
+ const path = join(dir, 'id_ed25519');
76
+ execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', path, '-C', 'someone-else'], {
77
+ stdio: 'pipe',
78
+ });
79
+ return readFileSync(`${path}.pub`, 'utf-8').trim();
80
+ }
81
+
82
+ it('finds the key celilo minted, even when $HOME holds a different one', () => {
83
+ // This is celilo#1240. `ensureFleetKey` mints into the data dir and
84
+ // `machine add` searched $HOME only, so a fleet whose key celilo minted
85
+ // could not add a machine without --ssh-key-file. The e2e management
86
+ // container has both directories populated with DIFFERENT keys, which is
87
+ // why it failed there and not on a developer's box.
88
+ const homePublicKey = plantHomeKey();
89
+ const fleet = ensureFleetKey();
90
+ expect(fleet.publicKey).not.toBe(homePublicKey);
91
+
92
+ const found = findFleetPrivateKey(fleet.publicKey);
93
+
94
+ expect(found).toBe(join(dataDir, '.ssh', 'id_ed25519'));
95
+ });
96
+
97
+ it('still finds a key that only lives in $HOME', () => {
98
+ // The auto-detected case: `initializeSystem` reads $HOME/.ssh when
99
+ // ssh.public_key is unset, and that fleet must keep working.
100
+ const homePublicKey = plantHomeKey();
101
+
102
+ expect(findFleetPrivateKey(homePublicKey)).toBe(join(homeDir, '.ssh', 'id_ed25519'));
103
+ });
104
+
105
+ it('returns null when no private key matches the recorded public half', () => {
106
+ plantHomeKey();
107
+ const orphan =
108
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA nobody';
109
+
110
+ expect(findFleetPrivateKey(orphan)).toBeNull();
111
+ });
112
+ });
@@ -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);
@@ -10,6 +10,7 @@ import { closeDb, createDbClient } from '../db/client';
10
10
  import { runMigrations } from '../db/migrate';
11
11
  import { modules } from '../db/schema';
12
12
  import type { Module } from '../db/schema';
13
+ import { resetTestDbPath } from '../test-utils/db-path';
13
14
  import { addContainerService } from './container-service';
14
15
  import { InfrastructureError, selectInfrastructure } from './infrastructure-selector';
15
16
  import { addMachine } from './machine-pool';
@@ -46,7 +47,7 @@ describe('infrastructure-selector', () => {
46
47
  }
47
48
 
48
49
  // Clear environment variables
49
- process.env.CELILO_DB_PATH = undefined;
50
+ resetTestDbPath();
50
51
  process.env.CELILO_MASTER_KEY_PATH = undefined;
51
52
  });
52
53
 
@@ -11,6 +11,7 @@ import {
11
11
  } from '@/db/schema';
12
12
  import type { ModuleManifest } from '@/manifest/schema';
13
13
  import { and, eq } from 'drizzle-orm';
14
+ import { resetTestDbPath } from '../test-utils/db-path';
14
15
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
15
16
  import { upsertModuleConfig } from './module-config';
16
17
 
@@ -38,7 +39,7 @@ beforeEach(() => {
38
39
 
39
40
  afterEach(async () => {
40
41
  db.$client.close();
41
- process.env.CELILO_DB_PATH = undefined;
42
+ resetTestDbPath();
42
43
 
43
44
  // Clean up database files
44
45
  if (existsSync(TEST_DB_PATH)) {
@@ -10,6 +10,7 @@ import { eq } from 'drizzle-orm';
10
10
  import { closeDb, createDbClient, getDb } from '../db/client';
11
11
  import { runMigrations } from '../db/migrate';
12
12
  import { machines, moduleInfrastructure, modules } from '../db/schema';
13
+ import { resetTestDbPath } from '../test-utils/db-path';
13
14
  import {
14
15
  addMachine,
15
16
  getMachine,
@@ -52,7 +53,7 @@ describe('machine-pool', () => {
52
53
  }
53
54
 
54
55
  // Clear environment variables
55
- process.env.CELILO_DB_PATH = undefined;
56
+ resetTestDbPath();
56
57
  process.env.CELILO_MASTER_KEY_PATH = undefined;
57
58
  });
58
59
 
@@ -9,6 +9,7 @@ import { join } from 'node:path';
9
9
  import { closeDb, createDbClient } from '../db/client';
10
10
  import { runMigrations } from '../db/migrate';
11
11
  import { modules } from '../db/schema';
12
+ import { resetTestDbPath } from '../test-utils/db-path';
12
13
  import {
13
14
  formatConfigValue,
14
15
  getAllModuleConfigValues,
@@ -59,7 +60,7 @@ describe('module-config service', () => {
59
60
  afterEach(() => {
60
61
  closeDb();
61
62
  // Clean up environment variable
62
- process.env.CELILO_DB_PATH = undefined;
63
+ resetTestDbPath();
63
64
  // Clean up test database
64
65
  rmSync(testDir, { recursive: true, force: true });
65
66
  });
@@ -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,188 @@
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 { resetTestDbPath } from '../test-utils/db-path';
13
+ import { InterviewUnansweredError } from './interview-errors';
14
+ import { planDnsRepoint, repointDnsPrimaryForProvider } from './module-deploy';
15
+
16
+ const MODULE_ID = 'knot-unbound-internal';
17
+ const SERVER_IP = '10.226.10.53';
18
+
19
+ type AskOpts = { scope: string; key: string; message: string };
20
+
21
+ function makeAsk(answer: boolean): ((opts: AskOpts) => Promise<boolean>) & { calls: AskOpts[] } {
22
+ const calls: AskOpts[] = [];
23
+ const ask = async (opts: AskOpts) => {
24
+ calls.push(opts);
25
+ return answer;
26
+ };
27
+ return Object.assign(ask, { calls });
28
+ }
29
+
30
+ function makeAskThrowing(err: Error): ((opts: AskOpts) => Promise<boolean>) & { calls: AskOpts[] } {
31
+ const calls: AskOpts[] = [];
32
+ const ask = async (opts: AskOpts) => {
33
+ calls.push(opts);
34
+ throw err;
35
+ };
36
+ return Object.assign(ask, { calls });
37
+ }
38
+
39
+ describe('repointDnsPrimaryForProvider (module-orchestrator-primitives slice 3)', () => {
40
+ let dir: string;
41
+ let db: DbClient;
42
+
43
+ beforeEach(async () => {
44
+ dir = mkdtempSync(join(tmpdir(), 'celilo-dns-repoint-'));
45
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
46
+ await runMigrations(process.env.CELILO_DB_PATH);
47
+ db = getDb();
48
+ seedProvider();
49
+ });
50
+
51
+ afterEach(() => {
52
+ closeDb();
53
+ resetTestDbPath();
54
+ try {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ } catch {
57
+ /* temp dir cleanup is best-effort */
58
+ }
59
+ });
60
+
61
+ function seedProvider() {
62
+ db.insert(modules)
63
+ .values({
64
+ id: MODULE_ID,
65
+ name: MODULE_ID,
66
+ version: '1.3.4',
67
+ manifestData: { id: MODULE_ID, name: MODULE_ID, version: '1.3.4', celilo_contract: '1.0' },
68
+ sourcePath: `/tmp/${MODULE_ID}`,
69
+ })
70
+ .run();
71
+ db.insert(capabilities)
72
+ .values({ moduleId: MODULE_ID, capabilityName: 'dns_internal', version: '1.0.0', data: {} })
73
+ .run();
74
+ db.insert(moduleSystems)
75
+ .values({
76
+ moduleId: MODULE_ID,
77
+ name: 'main',
78
+ hostname: 'dns',
79
+ ipv4Address: SERVER_IP,
80
+ zone: 'dmz',
81
+ infraType: 'container_service',
82
+ })
83
+ .run();
84
+ }
85
+
86
+ function setConfig(key: string, value: string) {
87
+ db.insert(systemConfig)
88
+ .values({ key, value })
89
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
90
+ .run();
91
+ }
92
+
93
+ function configValue(key: string): string | undefined {
94
+ return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value;
95
+ }
96
+
97
+ test('3.1: repoints dns.primary at the resolver and demotes the prior primary WITHOUT losing the existing fallback', async () => {
98
+ setConfig('dns.primary', '1.1.1.1');
99
+ setConfig('dns.fallback', '8.8.8.8');
100
+ const ask = makeAsk(true);
101
+
102
+ await repointDnsPrimaryForProvider(MODULE_ID, db, ask);
103
+
104
+ expect(configValue('dns.primary')).toBe(SERVER_IP);
105
+ // The WHOLE resulting value: the existing fallback must survive beside the
106
+ // demoted prior primary (D5's secondary observation — knot's hook lost it).
107
+ expect(configValue('dns.fallback')).toBe('1.1.1.1,8.8.8.8');
108
+ expect(ask.calls).toHaveLength(1);
109
+ expect(ask.calls[0].message).toContain(SERVER_IP);
110
+ expect(ask.calls[0].message).toContain('1.1.1.1');
111
+ });
112
+
113
+ test('3.1: demotes the prior primary to dns.fallback when no fallback existed', async () => {
114
+ setConfig('dns.primary', '1.1.1.1');
115
+ const ask = makeAsk(true);
116
+
117
+ await repointDnsPrimaryForProvider(MODULE_ID, db, ask);
118
+
119
+ expect(configValue('dns.primary')).toBe(SERVER_IP);
120
+ expect(configValue('dns.fallback')).toBe('1.1.1.1');
121
+ });
122
+
123
+ test('3.1: sets dns.primary without touching dns.fallback when no primary was set', async () => {
124
+ const ask = makeAsk(true);
125
+
126
+ await repointDnsPrimaryForProvider(MODULE_ID, db, ask);
127
+
128
+ expect(configValue('dns.primary')).toBe(SERVER_IP);
129
+ expect(configValue('dns.fallback')).toBeUndefined();
130
+ });
131
+
132
+ test('does not ask when dns.primary already names this resolver (re-deploy)', async () => {
133
+ setConfig('dns.primary', SERVER_IP);
134
+ setConfig('dns.fallback', '8.8.8.8');
135
+ const ask = makeAsk(true);
136
+
137
+ await repointDnsPrimaryForProvider(MODULE_ID, db, ask);
138
+
139
+ expect(ask.calls).toHaveLength(0);
140
+ expect(configValue('dns.primary')).toBe(SERVER_IP);
141
+ expect(configValue('dns.fallback')).toBe('8.8.8.8');
142
+ });
143
+
144
+ test('3.2: a negative answer leaves both keys unchanged and does not fail the deploy', async () => {
145
+ setConfig('dns.primary', '1.1.1.1');
146
+ setConfig('dns.fallback', '8.8.8.8');
147
+ const ask = makeAsk(false);
148
+
149
+ await expect(repointDnsPrimaryForProvider(MODULE_ID, db, ask)).resolves.toBeUndefined();
150
+
151
+ expect(configValue('dns.primary')).toBe('1.1.1.1');
152
+ expect(configValue('dns.fallback')).toBe('8.8.8.8');
153
+ expect(ask.calls).toHaveLength(1);
154
+ });
155
+
156
+ test('no responder able to answer (headless) is a defined skip, never a hang', async () => {
157
+ setConfig('dns.primary', '1.1.1.1');
158
+ const ask = makeAskThrowing(
159
+ new InterviewUnansweredError('interview.required.deploy:knot.repoint_dns_primary', 'nobody'),
160
+ );
161
+
162
+ await expect(repointDnsPrimaryForProvider(MODULE_ID, db, ask)).resolves.toBeUndefined();
163
+
164
+ expect(configValue('dns.primary')).toBe('1.1.1.1');
165
+ });
166
+ });
167
+
168
+ describe('planDnsRepoint', () => {
169
+ test('merges the existing fallback without duplicating the demoted primary', () => {
170
+ const plan = planDnsRepoint({
171
+ serverIp: SERVER_IP,
172
+ priorPrimary: '1.1.1.1',
173
+ existingFallback: '1.1.1.1,9.9.9.9',
174
+ });
175
+ expect(plan.needsRepoint).toBe(true);
176
+ expect(plan.newFallback).toBe('1.1.1.1,9.9.9.9');
177
+ });
178
+
179
+ test('already-primary is a no-op plan', () => {
180
+ const plan = planDnsRepoint({
181
+ serverIp: SERVER_IP,
182
+ priorPrimary: SERVER_IP,
183
+ existingFallback: '8.8.8.8',
184
+ });
185
+ expect(plan.needsRepoint).toBe(false);
186
+ expect(plan.newFallback).toBeUndefined();
187
+ });
188
+ });