@celilo/cli 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- 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 +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- 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 +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- 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 +27 -8
- 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/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- 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 +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- 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/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- 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 +214 -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/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- 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
|
@@ -11,13 +11,15 @@ import type { DbClient } from '../db/client';
|
|
|
11
11
|
import { type ModuleState, modules } from '../db/schema';
|
|
12
12
|
import { loadCapabilityFunctions } from '../hooks/capability-loader';
|
|
13
13
|
import { invokeHook } from '../hooks/executor';
|
|
14
|
+
import { createHookStores } from '../hooks/hook-store';
|
|
14
15
|
import { loadHookConfigMap } from '../hooks/load-hook-config';
|
|
15
16
|
import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger';
|
|
16
17
|
import type { HookLogger, HookResult } from '../hooks/types';
|
|
17
18
|
import type { ModuleManifest } from '../manifest/schema';
|
|
18
19
|
import { decryptSecret } from '../secrets/encryption';
|
|
19
20
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
20
|
-
import {
|
|
21
|
+
import { controlPlaneHealthChecks } from './control-plane-health';
|
|
22
|
+
import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from './deployed-systems';
|
|
21
23
|
import { remoteAccessPolicy } from './remote-access';
|
|
22
24
|
|
|
23
25
|
export interface HealthCheckItem {
|
|
@@ -152,6 +154,19 @@ export async function runModuleHealthCheck(
|
|
|
152
154
|
return { moduleId, status: 'error', checks: [], error: 'Module not found' };
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
// The control plane checks itself, in celilo's own process (celilo#1225).
|
|
158
|
+
//
|
|
159
|
+
// This was a health_check hook and it failed under the jail in the worst
|
|
160
|
+
// possible way: it asked `existsSync(db_path)`, the jail deliberately does
|
|
161
|
+
// not bind celilo's data directory, and so it reported a healthy fleet's
|
|
162
|
+
// database MISSING and failed the deploy over a file that was right there.
|
|
163
|
+
// `runFleetChecks` is what `celilo system doctor` already runs and asks
|
|
164
|
+
// eight questions where the hook asked two.
|
|
165
|
+
if (moduleId === CONTROL_PLANE_MODULE_ID) {
|
|
166
|
+
const checks = await controlPlaneHealthChecks(db);
|
|
167
|
+
return finishHealthCheck(moduleId, module.state, checks, db, options);
|
|
168
|
+
}
|
|
169
|
+
|
|
155
170
|
const manifest = module.manifestData as ModuleManifest;
|
|
156
171
|
const hookDef = manifest.hooks?.health_check;
|
|
157
172
|
|
|
@@ -198,6 +213,7 @@ export async function runModuleHealthCheck(
|
|
|
198
213
|
requiredCapabilities,
|
|
199
214
|
systems: getModuleSystems(moduleId, db),
|
|
200
215
|
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
216
|
+
hookStores: () => createHookStores(db, moduleId),
|
|
201
217
|
},
|
|
202
218
|
);
|
|
203
219
|
presentation.finish(hookResult.success);
|
|
@@ -222,6 +238,23 @@ export async function runModuleHealthCheck(
|
|
|
222
238
|
};
|
|
223
239
|
|
|
224
240
|
const checks = outputs.checks || [];
|
|
241
|
+
return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Derive the verdict from the named checks and move the module's state.
|
|
246
|
+
*
|
|
247
|
+
* Shared by the hook path and the control plane's own path so the two cannot
|
|
248
|
+
* drift on what `degraded` means or on when a state transition is suppressed.
|
|
249
|
+
*/
|
|
250
|
+
function finishHealthCheck(
|
|
251
|
+
moduleId: string,
|
|
252
|
+
moduleState: ModuleState,
|
|
253
|
+
checks: HealthCheckItem[],
|
|
254
|
+
db: DbClient,
|
|
255
|
+
options: HealthCheckOptions,
|
|
256
|
+
artifactPaths?: string[],
|
|
257
|
+
): HealthCheckResult {
|
|
225
258
|
const hasFail = checks.some((c) => c.status === 'fail');
|
|
226
259
|
const hasWarn = checks.some((c) => c.status === 'warn');
|
|
227
260
|
|
|
@@ -231,12 +264,12 @@ export async function runModuleHealthCheck(
|
|
|
231
264
|
? 'degraded'
|
|
232
265
|
: 'healthy';
|
|
233
266
|
|
|
234
|
-
const nextState = nextModuleState(
|
|
267
|
+
const nextState = nextModuleState(moduleState, status, options.unattended ?? false);
|
|
235
268
|
if (nextState) {
|
|
236
269
|
db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
|
|
237
270
|
}
|
|
238
271
|
|
|
239
|
-
return { moduleId, status, checks, artifactPaths
|
|
272
|
+
return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
|
|
240
273
|
}
|
|
241
274
|
|
|
242
275
|
/**
|
|
@@ -90,6 +90,7 @@ export function upsertModuleConfig(
|
|
|
90
90
|
moduleId: string,
|
|
91
91
|
key: string,
|
|
92
92
|
value: string | number | boolean | unknown[] | Record<string, unknown> | null,
|
|
93
|
+
source?: 'hook',
|
|
93
94
|
): void {
|
|
94
95
|
if (value === null || value === undefined) {
|
|
95
96
|
throw new Error(`upsertModuleConfig(${moduleId}, ${key}): value must not be null/undefined`);
|
|
@@ -97,11 +98,28 @@ export function upsertModuleConfig(
|
|
|
97
98
|
const valueJson = JSON.stringify(value);
|
|
98
99
|
const displayValue = isComplexValue(value) ? valueJson : String(value);
|
|
99
100
|
|
|
101
|
+
// `source` records who owns the row (hook-owned-state design D7 — the same
|
|
102
|
+
// column `derived-value-recomputation` reads). Absent keeps the legacy
|
|
103
|
+
// pre-column shape for every existing caller: operator and interview writes
|
|
104
|
+
// stay sourceless until that change gives them their own value to write.
|
|
105
|
+
// Only the hook config store passes 'hook', and only a hook may: the
|
|
106
|
+
// manifest refuses `module config set` on any source: hook variable.
|
|
100
107
|
db.insert(moduleConfigs)
|
|
101
|
-
.values({
|
|
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,
|
|
@@ -30,14 +32,17 @@ import {
|
|
|
30
32
|
interviewForMissingSecrets,
|
|
31
33
|
} from './config-interview';
|
|
32
34
|
import { getContainerService } from './container-service';
|
|
35
|
+
import { bootstrapControlPlane } from './control-plane-bootstrap';
|
|
33
36
|
import { executeAnsible } from './deploy-ansible';
|
|
34
37
|
import { planDeployment } from './deploy-planner';
|
|
35
38
|
import { waitForSSH } from './deploy-ssh';
|
|
36
39
|
import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
|
|
37
40
|
import { validateAndPrepareDeployment } from './deploy-validation';
|
|
41
|
+
import { CONTROL_PLANE_MODULE_ID } from './deployed-systems';
|
|
38
42
|
import { getModuleSystems } from './deployed-systems';
|
|
39
43
|
import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
|
|
40
44
|
import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
|
|
45
|
+
import { InterviewAbandonedError, InterviewUnansweredError } from './interview-errors';
|
|
41
46
|
import { findMachineForModule } from './machine-pool';
|
|
42
47
|
import { ensureRequiredNetworks } from './network-ensure';
|
|
43
48
|
import {
|
|
@@ -386,6 +391,154 @@ export async function deployModule(
|
|
|
386
391
|
return result;
|
|
387
392
|
}
|
|
388
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Headless bound for the repoint confirm. A staged responder replies in
|
|
396
|
+
* milliseconds; this exists so an attached-but-unstaged responder produces a
|
|
397
|
+
* defined skip instead of hanging until the suite budget kills the deploy
|
|
398
|
+
* (module-orchestrator-primitives design.md D5, "never a hang"). On a TTY
|
|
399
|
+
* there is no bound: the operator IS the responder and may take minutes.
|
|
400
|
+
*/
|
|
401
|
+
const DNS_REPOINT_HEADLESS_TIMEOUT_MS = 60_000;
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The deploy-time DNS repoint for a `dns_internal` provider
|
|
405
|
+
* (module-orchestrator-primitives slice 3, design D5). celilo owns the whole
|
|
406
|
+
* operation because every input is already celilo's: the resolver address is
|
|
407
|
+
* the system celilo just placed, and the prior primary/fallback are celilo's
|
|
408
|
+
* own rows. knot's on_install used to do this through the CLI, one key at a
|
|
409
|
+
* time, under a catch that reported success over a repoint that never
|
|
410
|
+
* happened.
|
|
411
|
+
*
|
|
412
|
+
* D5 is a recorded DELIBERATE EXCEPTION to the framework-does-not-infer-intent
|
|
413
|
+
* rule. What makes it acceptable is the interview: the deploy ASKS before it
|
|
414
|
+
* touches the fleet resolver, and a declined repoint is an operator decision,
|
|
415
|
+
* not an error. Do not cite this as precedent (design.md D5, "So do not cite
|
|
416
|
+
* this as precedent").
|
|
417
|
+
*/
|
|
418
|
+
export interface DnsRepointPlan {
|
|
419
|
+
serverIp: string;
|
|
420
|
+
priorPrimary: string | undefined;
|
|
421
|
+
existingFallback: string | undefined;
|
|
422
|
+
/** Merged dns.fallback when the repoint demotes the prior primary; undefined leaves the key untouched. */
|
|
423
|
+
newFallback: string | undefined;
|
|
424
|
+
/** False when dns.primary already names this resolver: the deploy asks nothing. */
|
|
425
|
+
needsRepoint: boolean;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Pure half of the repoint. The demote keeps an existing fallback (D5's
|
|
430
|
+
* secondary observation: knot's one-key-at-a-time hook overwrote it, dropping
|
|
431
|
+
* e.g. 8.8.8.8 from the fleet's resolver list).
|
|
432
|
+
*/
|
|
433
|
+
export function planDnsRepoint(args: {
|
|
434
|
+
serverIp: string;
|
|
435
|
+
priorPrimary: string | undefined;
|
|
436
|
+
existingFallback: string | undefined;
|
|
437
|
+
}): DnsRepointPlan {
|
|
438
|
+
const { serverIp, priorPrimary, existingFallback } = args;
|
|
439
|
+
if (priorPrimary === serverIp) {
|
|
440
|
+
return {
|
|
441
|
+
serverIp,
|
|
442
|
+
priorPrimary,
|
|
443
|
+
existingFallback,
|
|
444
|
+
newFallback: undefined,
|
|
445
|
+
needsRepoint: false,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const newFallback = priorPrimary ? mergeFallback(existingFallback, priorPrimary) : undefined;
|
|
449
|
+
return { serverIp, priorPrimary, existingFallback, newFallback, needsRepoint: true };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Comma-separated, demoted primary first, deduped (public-dns-probe.ts parses commas). */
|
|
453
|
+
function mergeFallback(existing: string | undefined, demoted: string): string {
|
|
454
|
+
const entries = [demoted];
|
|
455
|
+
for (const part of (existing ?? '').split(',')) {
|
|
456
|
+
const ip = part.trim();
|
|
457
|
+
if (ip && !entries.includes(ip)) entries.push(ip);
|
|
458
|
+
}
|
|
459
|
+
return entries.join(',');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function readSystemConfigValue(db: DbClient, key: string): string | undefined {
|
|
463
|
+
const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
|
|
464
|
+
return row?.value && row.value.length > 0 ? row.value : undefined;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function writeSystemConfigValue(db: DbClient, key: string, value: string): void {
|
|
468
|
+
db.insert(systemConfig)
|
|
469
|
+
.values({ key, value })
|
|
470
|
+
.onConflictDoUpdate({ target: systemConfig.key, set: { value } })
|
|
471
|
+
.run();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Ask, then perform (or skip) the repoint for a just-deployed `dns_internal`
|
|
476
|
+
* provider. Exported for tests; `deployModuleImpl` calls this after the DNS
|
|
477
|
+
* backfill when the deployed module provides `dns_internal`.
|
|
478
|
+
*/
|
|
479
|
+
export async function repointDnsPrimaryForProvider(
|
|
480
|
+
moduleId: string,
|
|
481
|
+
db: DbClient,
|
|
482
|
+
ask: typeof askConfirm = askConfirm,
|
|
483
|
+
): Promise<void> {
|
|
484
|
+
const serverIp = getModuleSystems(moduleId, db)[0]?.ipv4_address;
|
|
485
|
+
if (!serverIp) {
|
|
486
|
+
log.warn(
|
|
487
|
+
`dns_internal provider '${moduleId}' has no deployed system — skipping dns.primary repoint`,
|
|
488
|
+
);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const plan = planDnsRepoint({
|
|
492
|
+
serverIp,
|
|
493
|
+
priorPrimary: readSystemConfigValue(db, 'dns.primary'),
|
|
494
|
+
existingFallback: readSystemConfigValue(db, 'dns.fallback'),
|
|
495
|
+
});
|
|
496
|
+
if (!plan.needsRepoint) {
|
|
497
|
+
log.info(`dns.primary already points at ${serverIp} — no repoint needed`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const demoteNote = plan.newFallback
|
|
502
|
+
? ` The old primary ${plan.priorPrimary} moves to dns.fallback (${plan.newFallback}).`
|
|
503
|
+
: '';
|
|
504
|
+
const opts = {
|
|
505
|
+
scope: `deploy:${moduleId}`,
|
|
506
|
+
key: 'repoint_dns_primary',
|
|
507
|
+
message: `Deploying '${moduleId}' made it the fleet's internal DNS resolver. Point dns.primary at ${serverIp}?${demoteNote}`,
|
|
508
|
+
defaultValue: false,
|
|
509
|
+
};
|
|
510
|
+
// Called DIRECTLY, not inside withInterviewSession: deployModuleImpl already
|
|
511
|
+
// opened the terminal responder above and closes it in its finally. A second
|
|
512
|
+
// responder instance would race the first on stdin (design.md D5).
|
|
513
|
+
let confirmed: boolean;
|
|
514
|
+
try {
|
|
515
|
+
confirmed = await ask(
|
|
516
|
+
process.stdin.isTTY ? opts : { ...opts, timeoutMs: DNS_REPOINT_HEADLESS_TIMEOUT_MS },
|
|
517
|
+
);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
if (error instanceof InterviewUnansweredError || error instanceof InterviewAbandonedError) {
|
|
520
|
+
log.warn(
|
|
521
|
+
`dns.primary repoint skipped — nobody answered the confirm (${opts.scope}:${opts.key}). Stage the answer (celilo events respond --values) or set dns.primary manually later.`,
|
|
522
|
+
);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!confirmed) {
|
|
529
|
+
log.info('dns.primary repoint declined — fleet resolver left unchanged');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
writeSystemConfigValue(db, 'dns.primary', serverIp);
|
|
534
|
+
if (plan.newFallback !== undefined) {
|
|
535
|
+
writeSystemConfigValue(db, 'dns.fallback', plan.newFallback);
|
|
536
|
+
}
|
|
537
|
+
log.success(
|
|
538
|
+
`dns.primary repointed at internal resolver ${serverIp}${plan.newFallback ? ` (dns.fallback: ${plan.newFallback})` : ''}`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
389
542
|
async function deployModuleImpl(
|
|
390
543
|
moduleId: string,
|
|
391
544
|
db: DbClient,
|
|
@@ -722,6 +875,7 @@ async function deployModuleImpl(
|
|
|
722
875
|
requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
|
|
723
876
|
systems: getModuleSystems(moduleId, db),
|
|
724
877
|
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
878
|
+
hookStores: () => createHookStores(db, moduleId),
|
|
725
879
|
},
|
|
726
880
|
);
|
|
727
881
|
|
|
@@ -861,6 +1015,7 @@ async function deployModuleImpl(
|
|
|
861
1015
|
requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
|
|
862
1016
|
systems: getModuleSystems(moduleId, db),
|
|
863
1017
|
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
1018
|
+
hookStores: () => createHookStores(db, moduleId),
|
|
864
1019
|
},
|
|
865
1020
|
};
|
|
866
1021
|
},
|
|
@@ -1163,6 +1318,7 @@ async function deployModuleImpl(
|
|
|
1163
1318
|
requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
|
|
1164
1319
|
systems: getModuleSystems(capRecord.moduleId, db),
|
|
1165
1320
|
remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
|
|
1321
|
+
hookStores: () => createHookStores(db, capRecord.moduleId),
|
|
1166
1322
|
},
|
|
1167
1323
|
);
|
|
1168
1324
|
|
|
@@ -1351,6 +1507,55 @@ async function deployModuleImpl(
|
|
|
1351
1507
|
// container ID/IP got assigned per module in the DB.
|
|
1352
1508
|
}
|
|
1353
1509
|
|
|
1510
|
+
// celilo initialises its own box, in its own process.
|
|
1511
|
+
//
|
|
1512
|
+
// This was `celilo-mgmt`'s `on_install` hook until celilo#1225. The hook
|
|
1513
|
+
// reached every one of these operations by spawning the `celilo` CLI,
|
|
1514
|
+
// which a jailed hook cannot do — the mount set binds no `/usr/bin`, no
|
|
1515
|
+
// `/usr/local/bin` and no shell — so the deploy ran Ansible to completion
|
|
1516
|
+
// and then died in its own install hook on any host with a jail backend.
|
|
1517
|
+
//
|
|
1518
|
+
// It runs at the point the hook used to, for the reason the hook ran
|
|
1519
|
+
// there: the Ansible role has just installed celilo and started the
|
|
1520
|
+
// dispatcher, and none of this can be read before that.
|
|
1521
|
+
if (moduleId === CONTROL_PLANE_MODULE_ID) {
|
|
1522
|
+
log.success('Initializing celilo management state');
|
|
1523
|
+
try {
|
|
1524
|
+
const bootstrap = await bootstrapControlPlane({ db });
|
|
1525
|
+
|
|
1526
|
+
log.info(` DNS: primary ${bootstrap.dns.primary}, fallback ${bootstrap.dns.fallback}`);
|
|
1527
|
+
for (const applied of bootstrap.network.applied) {
|
|
1528
|
+
log.info(` Network: ${applied}`);
|
|
1529
|
+
}
|
|
1530
|
+
if (bootstrap.network.skipped) {
|
|
1531
|
+
log.warn(` Network not recorded: ${bootstrap.network.skipped}`);
|
|
1532
|
+
}
|
|
1533
|
+
// The operator authorises this key on every machine celilo manages,
|
|
1534
|
+
// so it is printed rather than merely stored. It was the loudest line
|
|
1535
|
+
// the hook produced and it stays the loudest line here.
|
|
1536
|
+
log.success(
|
|
1537
|
+
`celilo fleet SSH key — add this public key to machines celilo will manage:\n${bootstrap.fleetKey.publicKey}`,
|
|
1538
|
+
);
|
|
1539
|
+
|
|
1540
|
+
if (bootstrap.dispatcher.status === 'fail') {
|
|
1541
|
+
// A management plane with no dispatcher is broken: event-driven
|
|
1542
|
+
// reconciles never deliver. Fail the install, do not whisper.
|
|
1543
|
+
log.error(`Dispatcher check failed: ${bootstrap.dispatcher.summary}`);
|
|
1544
|
+
return {
|
|
1545
|
+
success: false,
|
|
1546
|
+
phases,
|
|
1547
|
+
error:
|
|
1548
|
+
`celilo-mgmt: ${bootstrap.dispatcher.summary}. ${bootstrap.dispatcher.remediation ?? ''}`.trim(),
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
log.success(`Event dispatcher is live (${bootstrap.dispatcher.summary})`);
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1554
|
+
log.error(`Initializing celilo management state failed: ${detail}`);
|
|
1555
|
+
return { success: false, phases, error: detail };
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1354
1559
|
// Run on_install hook (post-deploy actions like port forwarding, DNS registration)
|
|
1355
1560
|
if (manifest.hooks?.on_install) {
|
|
1356
1561
|
const onInstallDef = manifest.hooks.on_install;
|
|
@@ -1432,6 +1637,7 @@ async function deployModuleImpl(
|
|
|
1432
1637
|
requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
|
|
1433
1638
|
systems: getModuleSystems(moduleId, db),
|
|
1434
1639
|
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
1640
|
+
hookStores: () => createHookStores(db, moduleId),
|
|
1435
1641
|
},
|
|
1436
1642
|
};
|
|
1437
1643
|
},
|
|
@@ -1557,6 +1763,13 @@ async function deployModuleImpl(
|
|
|
1557
1763
|
`DNS backfill failed for '${moduleId}': ${msg}. Some internal DNS records may need manual setup.`,
|
|
1558
1764
|
);
|
|
1559
1765
|
}
|
|
1766
|
+
|
|
1767
|
+
// The dns_internal handoff (module-orchestrator-primitives slice 3,
|
|
1768
|
+
// design D5): celilo repoints dns.primary at the resolver it just
|
|
1769
|
+
// deployed, gated on an interview. knot's on_install no longer does
|
|
1770
|
+
// this. Runs after the backfill so a failed deploy never repoints the
|
|
1771
|
+
// fleet at a resolver that never came up.
|
|
1772
|
+
await repointDnsPrimaryForProvider(moduleId, db);
|
|
1560
1773
|
}
|
|
1561
1774
|
|
|
1562
1775
|
// Announce each deployed system on the bus (D5/D6) — one
|