@celilo/cli 1.12.0 → 1.14.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 +2 -1
- package/CELILO_SUBSYSTEMS.md +21 -2
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +24 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +130 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +6 -0
- package/src/cli/index.ts +31 -1
- package/src/console/closure.test.ts +322 -0
- package/src/console/closure.ts +294 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +293 -0
- package/src/console/projection.ts +364 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +67 -10
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/schema.ts +35 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +126 -4
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/module/web-root.ts +35 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +26 -2
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +32 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +252 -0
- package/src/services/api-principal-enrolment.ts +158 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-create.ts +33 -7
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
- package/src/services/restore-from-file.ts +6 -5
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { createModuleDeployWorker, isInstanceRequestRefusedError } from '@celilo/capabilities';
|
|
7
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
8
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
9
|
+
import {
|
|
10
|
+
createInstanceOps,
|
|
11
|
+
refuseUndeclaredNetworks,
|
|
12
|
+
validateInstanceConfig,
|
|
13
|
+
} from './instance-ops';
|
|
14
|
+
import { deriveInstanceModuleId } from './module-instances';
|
|
15
|
+
|
|
16
|
+
const manifest = (over: Record<string, unknown> = {}): ModuleManifest =>
|
|
17
|
+
({
|
|
18
|
+
celilo_contract: '1.0',
|
|
19
|
+
id: 'lab',
|
|
20
|
+
name: 'BYOI Lab',
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
requires: { capabilities: [] },
|
|
23
|
+
provides: { capabilities: [] },
|
|
24
|
+
variables: { owns: [], imports: [] },
|
|
25
|
+
...over,
|
|
26
|
+
}) as unknown as ModuleManifest;
|
|
27
|
+
|
|
28
|
+
const ownedVar = (over: Record<string, unknown>) => ({
|
|
29
|
+
name: 'ssh_key',
|
|
30
|
+
type: 'string',
|
|
31
|
+
required: true,
|
|
32
|
+
source: 'user',
|
|
33
|
+
description: '',
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('validateInstanceConfig', () => {
|
|
38
|
+
test('accepts a config supplying every required user variable', () => {
|
|
39
|
+
const m = manifest({ variables: { owns: [ownedVar({})], imports: [] } });
|
|
40
|
+
expect(validateInstanceConfig(m, { ssh_key: 'ssh-ed25519 AAAA' })).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('reports a required variable that was not supplied', () => {
|
|
44
|
+
const m = manifest({ variables: { owns: [ownedVar({})], imports: [] } });
|
|
45
|
+
const problems = validateInstanceConfig(m, {});
|
|
46
|
+
expect(problems).toHaveLength(1);
|
|
47
|
+
expect(problems[0]).toContain("'ssh_key' is required");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a required variable with a default need not be supplied', () => {
|
|
51
|
+
const m = manifest({
|
|
52
|
+
variables: { owns: [ownedVar({ default: 'ssh-ed25519 DEFAULT' })], imports: [] },
|
|
53
|
+
});
|
|
54
|
+
expect(validateInstanceConfig(m, {})).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('refuses a key the submodule does not declare, and lists what it does', () => {
|
|
58
|
+
const m = manifest({ variables: { owns: [ownedVar({})], imports: [] } });
|
|
59
|
+
const problems = validateInstanceConfig(m, { ssh_key: 'k', nonsense: 1 });
|
|
60
|
+
expect(problems).toHaveLength(1);
|
|
61
|
+
expect(problems[0]).toContain("'nonsense' is not declared");
|
|
62
|
+
expect(problems[0]).toContain('ssh_key');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A derived value silently overwritten at generate time looks like celilo
|
|
66
|
+
// ignoring the request, so it is refused rather than accepted and discarded.
|
|
67
|
+
test('refuses a declared variable celilo derives rather than the caller supplying', () => {
|
|
68
|
+
const m = manifest({
|
|
69
|
+
variables: {
|
|
70
|
+
owns: [ownedVar({}), ownedVar({ name: 'container_ip', source: 'infrastructure' })],
|
|
71
|
+
imports: [],
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const problems = validateInstanceConfig(m, { ssh_key: 'k', container_ip: '10.0.0.5' });
|
|
75
|
+
expect(problems).toHaveLength(1);
|
|
76
|
+
expect(problems[0]).toContain('celilo derives it');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Four failed instantiations to learn four missing keys is the experience
|
|
80
|
+
// this avoids.
|
|
81
|
+
test('reports every problem at once', () => {
|
|
82
|
+
const m = manifest({
|
|
83
|
+
variables: { owns: [ownedVar({}), ownedVar({ name: 'owner_sub' })], imports: [] },
|
|
84
|
+
});
|
|
85
|
+
expect(validateInstanceConfig(m, { junk: 1 })).toHaveLength(3);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('refuseUndeclaredNetworks', () => {
|
|
90
|
+
test('passes when every insisted network is declared', () => {
|
|
91
|
+
const m = manifest({ requires: { capabilities: [], networks: [{ name: 'quarantine' }] } });
|
|
92
|
+
expect(refuseUndeclaredNetworks(m, ['dmz', 'quarantine'])).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('refuses and names the missing network', () => {
|
|
96
|
+
const m = manifest({ requires: { capabilities: [], networks: [{ name: 'quarantine' }] } });
|
|
97
|
+
const refusal = refuseUndeclaredNetworks(m, ['dmz']);
|
|
98
|
+
expect(refusal).toContain('quarantine');
|
|
99
|
+
expect(refusal).toContain('never creates a network');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a submodule insisting on nothing passes', () => {
|
|
103
|
+
expect(refuseUndeclaredNetworks(manifest(), [])).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('createInstanceOps against a real database', () => {
|
|
108
|
+
let root: string;
|
|
109
|
+
let db: DbClient;
|
|
110
|
+
let parentPath: string;
|
|
111
|
+
|
|
112
|
+
const SUBMODULE_YAML = `
|
|
113
|
+
celilo_contract: "1.0"
|
|
114
|
+
id: lab
|
|
115
|
+
name: BYOI Lab
|
|
116
|
+
version: 1.0.0
|
|
117
|
+
variables:
|
|
118
|
+
owns:
|
|
119
|
+
- name: ssh_key
|
|
120
|
+
type: string
|
|
121
|
+
required: true
|
|
122
|
+
source: user
|
|
123
|
+
description: Public key baked into authorized_keys
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
async function setup(opts: { submoduleYaml?: string; declaredNetworks?: string[] } = {}) {
|
|
127
|
+
parentPath = join(root, 'modules', 'byoi');
|
|
128
|
+
const submodulePath = join(parentPath, 'submodules', 'lab');
|
|
129
|
+
await mkdir(join(submodulePath, 'ansible'), { recursive: true });
|
|
130
|
+
await writeFile(join(parentPath, 'manifest.yml'), 'id: byoi\n');
|
|
131
|
+
await writeFile(join(submodulePath, 'manifest.yml'), opts.submoduleYaml ?? SUBMODULE_YAML);
|
|
132
|
+
|
|
133
|
+
db.$client.run(
|
|
134
|
+
"INSERT INTO modules (id,name,version,manifest_data,source_path) VALUES ('byoi','byoi','1.0.0',?,?)",
|
|
135
|
+
[JSON.stringify({ id: 'byoi', submodules: ['lab'] }), parentPath],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return createInstanceOps({
|
|
139
|
+
parentId: 'byoi',
|
|
140
|
+
db,
|
|
141
|
+
declaredNetworks: opts.declaredNetworks ?? ['quarantine'],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const worker = (ops: ReturnType<typeof createInstanceOps>) =>
|
|
146
|
+
createModuleDeployWorker({ moduleId: 'byoi', ops });
|
|
147
|
+
|
|
148
|
+
beforeEach(async () => {
|
|
149
|
+
root = await mkdtemp(join(tmpdir(), 'celilo-instance-ops-'));
|
|
150
|
+
db = createDbClient({ path: join(root, 'celilo.db') });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(async () => {
|
|
154
|
+
db.$client.close();
|
|
155
|
+
await rm(root, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('records an instance, its config, and its symlink farm', async () => {
|
|
159
|
+
const ops = await setup();
|
|
160
|
+
const moduleId = await ops.create({
|
|
161
|
+
submodule: 'lab',
|
|
162
|
+
instanceKey: 'sub-abc',
|
|
163
|
+
config: { ssh_key: 'ssh-ed25519 AAAA' },
|
|
164
|
+
label: 'Sally',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(moduleId).toBe(deriveInstanceModuleId('byoi', 'lab', 'sub-abc'));
|
|
168
|
+
|
|
169
|
+
// A flat peer of every other module, never nested under the parent.
|
|
170
|
+
const instancePath = join(dirname(parentPath), moduleId);
|
|
171
|
+
expect(dirname(instancePath)).toBe(dirname(parentPath));
|
|
172
|
+
expect(existsSync(join(instancePath, 'generated'))).toBe(true);
|
|
173
|
+
expect(existsSync(join(instancePath, 'ansible'))).toBe(true);
|
|
174
|
+
|
|
175
|
+
const row = db.$client
|
|
176
|
+
.query('SELECT state, label, parent_id, submodule FROM module_instances')
|
|
177
|
+
.get() as Record<string, unknown>;
|
|
178
|
+
expect(row).toMatchObject({
|
|
179
|
+
state: 'pending',
|
|
180
|
+
label: 'Sally',
|
|
181
|
+
parent_id: 'byoi',
|
|
182
|
+
submodule: 'lab',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const config = db.$client
|
|
186
|
+
.query("SELECT value_json FROM module_configs WHERE module_id = ? AND key = 'ssh_key'")
|
|
187
|
+
.get(moduleId) as { value_json: string };
|
|
188
|
+
expect(JSON.parse(config.value_json)).toBe('ssh-ed25519 AAAA');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// A reconcile loop that fires twice must not produce a second system.
|
|
192
|
+
test('is idempotent on the caller key and repairs the farm on a repeat', async () => {
|
|
193
|
+
const ops = await setup();
|
|
194
|
+
const first = await ops.create({
|
|
195
|
+
submodule: 'lab',
|
|
196
|
+
instanceKey: 'sub-abc',
|
|
197
|
+
config: { ssh_key: 'k1' },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const instancePath = join(dirname(parentPath), first);
|
|
201
|
+
await rm(join(instancePath, 'ansible'), { force: true });
|
|
202
|
+
|
|
203
|
+
const second = await ops.create({
|
|
204
|
+
submodule: 'lab',
|
|
205
|
+
instanceKey: 'sub-abc',
|
|
206
|
+
config: { ssh_key: 'k2' },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(second).toBe(first);
|
|
210
|
+
expect(
|
|
211
|
+
(db.$client.query('SELECT count(*) n FROM module_instances').get() as { n: number }).n,
|
|
212
|
+
).toBe(1);
|
|
213
|
+
// A retry REPAIRS rather than merely not-breaking.
|
|
214
|
+
expect(existsSync(join(instancePath, 'ansible'))).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('refuses invalid config before writing anything', async () => {
|
|
218
|
+
const ops = await setup();
|
|
219
|
+
|
|
220
|
+
let caught: unknown;
|
|
221
|
+
try {
|
|
222
|
+
await ops.create({ submodule: 'lab', instanceKey: 'sub-abc', config: {} });
|
|
223
|
+
} catch (error) {
|
|
224
|
+
caught = error;
|
|
225
|
+
}
|
|
226
|
+
// Duck-typed, because the error crosses the identity boundary a module's
|
|
227
|
+
// bundled copy of @celilo/capabilities creates (celilo#173).
|
|
228
|
+
expect(isInstanceRequestRefusedError(caught)).toBe(true);
|
|
229
|
+
|
|
230
|
+
expect(
|
|
231
|
+
(db.$client.query('SELECT count(*) n FROM module_instances').get() as { n: number }).n,
|
|
232
|
+
).toBe(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// D8, and at the REQUEST rather than three minutes into a terraform apply.
|
|
236
|
+
test('refuses an undeclared network before provisioning', async () => {
|
|
237
|
+
const ops = await setup({
|
|
238
|
+
submoduleYaml: `${SUBMODULE_YAML}
|
|
239
|
+
requires:
|
|
240
|
+
networks:
|
|
241
|
+
- name: quarantine
|
|
242
|
+
`,
|
|
243
|
+
declaredNetworks: ['dmz'],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await expect(
|
|
247
|
+
ops.create({ submodule: 'lab', instanceKey: 'k', config: { ssh_key: 'x' } }),
|
|
248
|
+
).rejects.toThrow(/quarantine/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('a framework refusal reaches the caller as accepted:false, not a throw', async () => {
|
|
252
|
+
const capability = worker(await setup());
|
|
253
|
+
|
|
254
|
+
const result = await capability.instantiate({
|
|
255
|
+
submodule: 'lab',
|
|
256
|
+
instanceKey: 'sub-abc',
|
|
257
|
+
config: {},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(result.accepted).toBe(false);
|
|
261
|
+
if (!result.accepted) expect(result.reason).toContain("'ssh_key' is required");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('destroy marks the instance and reports its id', async () => {
|
|
265
|
+
const ops = await setup();
|
|
266
|
+
const moduleId = await ops.create({
|
|
267
|
+
submodule: 'lab',
|
|
268
|
+
instanceKey: 'sub-abc',
|
|
269
|
+
config: { ssh_key: 'k' },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(await ops.markForDestruction('lab', 'sub-abc')).toBe(moduleId);
|
|
273
|
+
|
|
274
|
+
const row = db.$client.query('SELECT state FROM module_instances').get() as { state: string };
|
|
275
|
+
expect(row.state).toBe('destroying');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('destroying something that was never there reports null', async () => {
|
|
279
|
+
const ops = await setup();
|
|
280
|
+
expect(await ops.markForDestruction('lab', 'never')).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('list reports what was recorded, scoped to the caller', async () => {
|
|
284
|
+
const ops = await setup();
|
|
285
|
+
await ops.create({ submodule: 'lab', instanceKey: 'a', config: { ssh_key: 'k' } });
|
|
286
|
+
await ops.create({ submodule: 'lab', instanceKey: 'b', config: { ssh_key: 'k' } });
|
|
287
|
+
|
|
288
|
+
const listed = await ops.list();
|
|
289
|
+
|
|
290
|
+
expect(listed.map((i) => i.instanceKey).sort()).toEqual(['a', 'b']);
|
|
291
|
+
expect(listed.every((i) => i.state === 'pending')).toBe(true);
|
|
292
|
+
expect(listed.every((i) => i.failureReason === null)).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('declaredSubmodules reads the parent manifest, so authority is not supplied', async () => {
|
|
296
|
+
const ops = await setup();
|
|
297
|
+
expect(ops.declaredSubmodules()).toEqual(['lab']);
|
|
298
|
+
|
|
299
|
+
const result = await worker(ops).instantiate({ submodule: 'not-mine', instanceKey: 'k' });
|
|
300
|
+
expect(result.accepted).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side-effecting half of `celilo_module_deploy_worker`
|
|
3
|
+
* (openspec/changes/submodules, D3, D6, D8).
|
|
4
|
+
*
|
|
5
|
+
* `createModuleDeployWorker` in `@celilo/capabilities` owns the refusals a
|
|
6
|
+
* caller can reason about without a database: does this module declare that
|
|
7
|
+
* submodule, is the key usable. This owns everything that touches celilo:
|
|
8
|
+
* validating config against the submodule's declared variables, refusing a
|
|
9
|
+
* network nobody declared, deriving the instance's `modules.id`, writing the
|
|
10
|
+
* rows, and building its symlink farm.
|
|
11
|
+
*
|
|
12
|
+
* Split that way so the capability's contract is testable with no database at
|
|
13
|
+
* all (Rule 2.3), and so every refusal here happens BEFORE anything is
|
|
14
|
+
* provisioned. An instance whose config is wrong is refused at the request, not
|
|
15
|
+
* discovered at deploy time when there is no operator to tell.
|
|
16
|
+
*
|
|
17
|
+
* ── What this does NOT do ──
|
|
18
|
+
*
|
|
19
|
+
* It records intent. It does not provision. `instantiate` returns as soon as
|
|
20
|
+
* the rows and the farm exist, because provisioning takes minutes and the
|
|
21
|
+
* caller is a hook on a timer (D6). What picks a `pending` instance up and
|
|
22
|
+
* deploys it is a separate decision and is not made here.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { readFile } from 'node:fs/promises';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import {
|
|
29
|
+
type InstanceOps,
|
|
30
|
+
InstanceRequestRefusedError,
|
|
31
|
+
type InstantiateRequest,
|
|
32
|
+
type ModuleInstance,
|
|
33
|
+
} from '@celilo/capabilities';
|
|
34
|
+
import { eq } from 'drizzle-orm';
|
|
35
|
+
import type { DbClient } from '../db/client';
|
|
36
|
+
import { moduleInstances, modules } from '../db/schema';
|
|
37
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
38
|
+
import { SUBMODULES_DIR, validateManifest } from '../manifest/validate';
|
|
39
|
+
import { writeModuleConfigKey } from './config-interview';
|
|
40
|
+
import {
|
|
41
|
+
buildInstanceLinkFarm,
|
|
42
|
+
deriveInstanceModuleId,
|
|
43
|
+
submoduleSourcePath,
|
|
44
|
+
} from './module-instances';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Config keys a caller may not set, because celilo derives them.
|
|
48
|
+
*
|
|
49
|
+
* A variable whose `source` is anything but `user` is celilo's answer, not the
|
|
50
|
+
* parent's: `infrastructure` comes from the selected host, `capability` from a
|
|
51
|
+
* provider, `system` from system config, `terraform` from an output. Letting a
|
|
52
|
+
* caller supply one would have it silently overwritten at generate time, which
|
|
53
|
+
* looks like celilo ignoring the request.
|
|
54
|
+
*/
|
|
55
|
+
function callerSettableVariables(manifest: ModuleManifest): Map<string, { required: boolean }> {
|
|
56
|
+
const settable = new Map<string, { required: boolean }>();
|
|
57
|
+
for (const variable of manifest.variables?.owns ?? []) {
|
|
58
|
+
if (variable.source !== 'user') continue;
|
|
59
|
+
settable.set(variable.name, { required: variable.required && variable.default === undefined });
|
|
60
|
+
}
|
|
61
|
+
return settable;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate an instance's config against the submodule's declared variables.
|
|
66
|
+
*
|
|
67
|
+
* Pure, so the rules are testable without a filesystem or a database. Reports
|
|
68
|
+
* EVERY problem rather than the first: a caller fixing one key at a time across
|
|
69
|
+
* four round trips, each costing a failed instantiation, is the experience this
|
|
70
|
+
* avoids.
|
|
71
|
+
*/
|
|
72
|
+
export function validateInstanceConfig(
|
|
73
|
+
submoduleManifest: ModuleManifest,
|
|
74
|
+
config: Record<string, unknown>,
|
|
75
|
+
): string[] {
|
|
76
|
+
const settable = callerSettableVariables(submoduleManifest);
|
|
77
|
+
const declaredNames = new Set((submoduleManifest.variables?.owns ?? []).map((v) => v.name));
|
|
78
|
+
const problems: string[] = [];
|
|
79
|
+
|
|
80
|
+
for (const key of Object.keys(config)) {
|
|
81
|
+
if (settable.has(key)) continue;
|
|
82
|
+
problems.push(
|
|
83
|
+
declaredNames.has(key)
|
|
84
|
+
? `'${key}' is not a caller-supplied value — celilo derives it. Remove it from the instance config.`
|
|
85
|
+
: `'${key}' is not declared by submodule '${submoduleManifest.id}'. It declares: ${[...settable.keys()].join(', ') || '(nothing settable)'}.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [name, { required }] of settable) {
|
|
90
|
+
if (required && config[name] === undefined) {
|
|
91
|
+
problems.push(
|
|
92
|
+
`'${name}' is required by submodule '${submoduleManifest.id}' and was not supplied.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return problems;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Refuse a submodule that insists on a network celilo has not been told about
|
|
102
|
+
* (D8), at the REQUEST rather than at provision time.
|
|
103
|
+
*
|
|
104
|
+
* celilo owns the network namespace: a module may insist a network exists and
|
|
105
|
+
* read its value, and may never write one. So an instantiation cannot create
|
|
106
|
+
* the missing network, and discovering it three minutes into a terraform apply
|
|
107
|
+
* gives an operator a failure that names an address instead of a name.
|
|
108
|
+
*
|
|
109
|
+
* Pure over the declared set, because "which networks exist" is a system-config
|
|
110
|
+
* question the caller resolves.
|
|
111
|
+
*/
|
|
112
|
+
export function refuseUndeclaredNetworks(
|
|
113
|
+
submoduleManifest: ModuleManifest,
|
|
114
|
+
declaredNetworks: readonly string[],
|
|
115
|
+
): string | null {
|
|
116
|
+
const insisted = (submoduleManifest.requires?.networks ?? [])
|
|
117
|
+
.map((requirement) => requirement.name)
|
|
118
|
+
.filter((name): name is string => typeof name === 'string');
|
|
119
|
+
|
|
120
|
+
const missing = insisted.filter((name) => !declaredNetworks.includes(name));
|
|
121
|
+
if (missing.length === 0) return null;
|
|
122
|
+
|
|
123
|
+
return `Submodule '${submoduleManifest.id}' requires network(s) ${missing.join(', ')}, which celilo has no definition for. Declare them first — a module never creates a network, and an instance cannot either.`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface InstanceOpsContext {
|
|
127
|
+
/** The calling module, whose authority the capability has already checked. */
|
|
128
|
+
parentId: string;
|
|
129
|
+
db: DbClient;
|
|
130
|
+
/** Networks celilo has definitions for. Supplied so this stays testable. */
|
|
131
|
+
declaredNetworks: readonly string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read and validate a submodule's own manifest from its parent's install.
|
|
136
|
+
*
|
|
137
|
+
* Re-read at request time rather than trusted from import. A parent updated
|
|
138
|
+
* since it was imported ships a different submodule, and the instance about to
|
|
139
|
+
* be created runs the version ON DISK.
|
|
140
|
+
*/
|
|
141
|
+
async function loadSubmoduleManifest(
|
|
142
|
+
parentSourcePath: string,
|
|
143
|
+
submodule: string,
|
|
144
|
+
): Promise<ModuleManifest> {
|
|
145
|
+
const manifestPath = join(submoduleSourcePath(parentSourcePath, submodule), 'manifest.yml');
|
|
146
|
+
if (!existsSync(manifestPath)) {
|
|
147
|
+
throw new InstanceRequestRefusedError(
|
|
148
|
+
`Submodule '${submodule}' has no manifest at ${SUBMODULES_DIR}/${submodule}/manifest.yml. Its parent's install is incomplete; reinstall or update it.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const parsed = validateManifest(await readFile(manifestPath, 'utf-8'));
|
|
153
|
+
if (!parsed.success) {
|
|
154
|
+
throw new InstanceRequestRefusedError(
|
|
155
|
+
`Submodule '${submodule}' has an invalid manifest: ${parsed.errors.map((e) => `${e.path}: ${e.message}`).join(', ')}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return parsed.data;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Build the framework-side ops for one calling module. */
|
|
162
|
+
export function createInstanceOps(context: InstanceOpsContext): InstanceOps {
|
|
163
|
+
const { parentId, db, declaredNetworks } = context;
|
|
164
|
+
|
|
165
|
+
function parentRow() {
|
|
166
|
+
const row = db.select().from(modules).where(eq(modules.id, parentId)).get();
|
|
167
|
+
if (!row) {
|
|
168
|
+
throw new InstanceRequestRefusedError(`Module '${parentId}' is not installed.`);
|
|
169
|
+
}
|
|
170
|
+
return row;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rowToInstance(row: typeof moduleInstances.$inferSelect): ModuleInstance {
|
|
174
|
+
return {
|
|
175
|
+
submodule: row.submodule,
|
|
176
|
+
instanceKey: row.instanceKey,
|
|
177
|
+
moduleId: row.moduleId,
|
|
178
|
+
label: row.label,
|
|
179
|
+
state: row.state,
|
|
180
|
+
failureReason: row.failureReason,
|
|
181
|
+
retryable: row.retryable,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
declaredSubmodules() {
|
|
187
|
+
const manifest = parentRow().manifestData as unknown as ModuleManifest;
|
|
188
|
+
return manifest.submodules ?? [];
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async create(request: InstantiateRequest) {
|
|
192
|
+
const parent = parentRow();
|
|
193
|
+
const submoduleManifest = await loadSubmoduleManifest(parent.sourcePath, request.submodule);
|
|
194
|
+
|
|
195
|
+
const config = request.config ?? {};
|
|
196
|
+
const configProblems = validateInstanceConfig(submoduleManifest, config);
|
|
197
|
+
if (configProblems.length > 0) {
|
|
198
|
+
throw new InstanceRequestRefusedError(
|
|
199
|
+
`Configuration for '${request.submodule}' instance '${request.instanceKey}' is not valid:\n ${configProblems.join('\n ')}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const networkRefusal = refuseUndeclaredNetworks(submoduleManifest, declaredNetworks);
|
|
204
|
+
if (networkRefusal) throw new InstanceRequestRefusedError(networkRefusal);
|
|
205
|
+
|
|
206
|
+
const moduleId = deriveInstanceModuleId(parentId, request.submodule, request.instanceKey);
|
|
207
|
+
|
|
208
|
+
// Idempotent on the caller's key (D6), so a reconcile loop that fires
|
|
209
|
+
// twice is harmless rather than producing a second system. The farm is
|
|
210
|
+
// still rebuilt below: an existing instance whose parent gained a subtree
|
|
211
|
+
// needs its links converged, and doing it here means a retry REPAIRS.
|
|
212
|
+
const existing = db
|
|
213
|
+
.select()
|
|
214
|
+
.from(moduleInstances)
|
|
215
|
+
.where(eq(moduleInstances.moduleId, moduleId))
|
|
216
|
+
.get();
|
|
217
|
+
|
|
218
|
+
// A peer of every other module, never nested under the parent (D4). The
|
|
219
|
+
// flat store is what keeps backup's cross-module walk, `module list`,
|
|
220
|
+
// health and fleet status working on an instance with no change.
|
|
221
|
+
const instancePath = join(dirname(parent.sourcePath), moduleId);
|
|
222
|
+
await buildInstanceLinkFarm({
|
|
223
|
+
instancePath,
|
|
224
|
+
submodulePath: submoduleSourcePath(parent.sourcePath, request.submodule),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (!existing) {
|
|
228
|
+
db.insert(modules)
|
|
229
|
+
.values({
|
|
230
|
+
id: moduleId,
|
|
231
|
+
name: `${submoduleManifest.name} (${request.label ?? request.instanceKey})`,
|
|
232
|
+
version: submoduleManifest.version,
|
|
233
|
+
description: submoduleManifest.description,
|
|
234
|
+
manifestData: submoduleManifest as unknown as Record<string, unknown>,
|
|
235
|
+
sourcePath: instancePath,
|
|
236
|
+
state: 'IMPORTED',
|
|
237
|
+
})
|
|
238
|
+
.run();
|
|
239
|
+
|
|
240
|
+
db.insert(moduleInstances)
|
|
241
|
+
.values({
|
|
242
|
+
moduleId,
|
|
243
|
+
parentId,
|
|
244
|
+
submodule: request.submodule,
|
|
245
|
+
instanceKey: request.instanceKey,
|
|
246
|
+
label: request.label ?? null,
|
|
247
|
+
state: 'pending',
|
|
248
|
+
})
|
|
249
|
+
.run();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// After the rows exist, so a config write cannot land on a module that is
|
|
253
|
+
// not there. Re-applied on a repeat create, which is what makes an
|
|
254
|
+
// instantiate a converge rather than a create-once.
|
|
255
|
+
for (const [key, value] of Object.entries(config)) {
|
|
256
|
+
await writeModuleConfigKey(moduleId, key, value, db);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return moduleId;
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async markForDestruction(submodule: string, instanceKey: string) {
|
|
263
|
+
const moduleId = deriveInstanceModuleId(parentId, submodule, instanceKey);
|
|
264
|
+
const existing = db
|
|
265
|
+
.select()
|
|
266
|
+
.from(moduleInstances)
|
|
267
|
+
.where(eq(moduleInstances.moduleId, moduleId))
|
|
268
|
+
.get();
|
|
269
|
+
|
|
270
|
+
// Nothing to destroy is a success, so a reconcile loop need not check
|
|
271
|
+
// first. Null says so rather than an id that names nothing.
|
|
272
|
+
if (!existing) return null;
|
|
273
|
+
|
|
274
|
+
db.update(moduleInstances)
|
|
275
|
+
.set({ state: 'destroying', updatedAt: new Date() })
|
|
276
|
+
.where(eq(moduleInstances.moduleId, moduleId))
|
|
277
|
+
.run();
|
|
278
|
+
|
|
279
|
+
return moduleId;
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async list() {
|
|
283
|
+
return db
|
|
284
|
+
.select()
|
|
285
|
+
.from(moduleInstances)
|
|
286
|
+
.where(eq(moduleInstances.parentId, parentId))
|
|
287
|
+
.orderBy(moduleInstances.createdAt)
|
|
288
|
+
.all()
|
|
289
|
+
.map(rowToInstance);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|