@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.
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- 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 +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- 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.ts +15 -1
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +82 -11
- 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 +162 -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 +29 -5
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +14 -7
- 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/build.ts +70 -2
- 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 +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -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/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +121 -1
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- 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/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +163 -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/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- 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/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- 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({
|
|
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,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, {
|
|
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();
|