@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.
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/schemas/system_config.json +7 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +158 -8
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +22 -2
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +87 -4
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +6 -4
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +160 -0
- package/src/config/paths.test.ts +3 -3
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +62 -135
- package/src/db/migrate.ts +17 -12
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +52 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +106 -19
- package/src/hooks/executor.ts +120 -13
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +168 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +39 -5
- package/src/hooks/jail.test.ts +62 -3
- package/src/hooks/jail.ts +67 -8
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +130 -2
- package/src/module/packaging/extract.ts +1 -5
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +235 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +264 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +47 -3
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-interview.ts +37 -14
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/config-provenance.ts +4 -0
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +123 -2
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +25 -2
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +3 -2
- package/src/services/fleet-key.test.ts +68 -3
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +188 -0
- package/src/services/module-deploy.ts +199 -21
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/restore-from-file.test.ts +23 -2
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- package/src/services/static-content-converge.test.ts +144 -5
- package/src/services/static-content-converge.ts +87 -17
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +33 -2
- package/src/services/trusted-sources.ts +47 -10
- package/src/services/zone-detector.test.ts +2 -1
- package/src/templates/generator.ts +9 -2
- package/src/test-utils/bus-responder.ts +5 -3
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/variables/context.ts +16 -5
- package/src/module/packaging/generated-plane.test.ts +0 -79
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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: {
|
|
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
|
+
});
|