@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.
Files changed (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +21 -2
  3. package/package.json +3 -3
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/alerts-list.ts +16 -1
  8. package/src/cli/commands/backup-list.test.ts +82 -1
  9. package/src/cli/commands/backup-list.ts +113 -4
  10. package/src/cli/commands/console-get-chain.test.ts +96 -0
  11. package/src/cli/commands/console.ts +130 -0
  12. package/src/cli/commands/module-list.ts +3 -41
  13. package/src/cli/commands/notify-config.test.ts +79 -0
  14. package/src/cli/commands/notify-config.ts +13 -2
  15. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  16. package/src/cli/completion.ts +6 -0
  17. package/src/cli/index.ts +31 -1
  18. package/src/console/closure.test.ts +322 -0
  19. package/src/console/closure.ts +294 -0
  20. package/src/console/control-plane-boundary.test.ts +75 -0
  21. package/src/console/projection.test.ts +293 -0
  22. package/src/console/projection.ts +364 -0
  23. package/src/db/schema.ts +19 -14
  24. package/src/hooks/broker.test.ts +4 -6
  25. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  26. package/src/hooks/capability-loader.ts +67 -10
  27. package/src/hooks/executor.test.ts +85 -4
  28. package/src/hooks/executor.ts +164 -9
  29. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  30. package/src/hooks/hook-state-dir.test.ts +14 -2
  31. package/src/hooks/hook-timeout.test.ts +2 -4
  32. package/src/hooks/hook-trespass.test.ts +50 -5
  33. package/src/hooks/jail.test.ts +370 -0
  34. package/src/hooks/jail.ts +491 -0
  35. package/src/hooks/mount-set.ts +24 -0
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  37. package/src/manifest/contracts/v1.ts +22 -1
  38. package/src/manifest/schema.ts +35 -0
  39. package/src/manifest/validate.test.ts +142 -0
  40. package/src/manifest/validate.ts +126 -4
  41. package/src/module/import.test.ts +116 -0
  42. package/src/module/import.ts +73 -1
  43. package/src/module/packaging/audit.ts +103 -1
  44. package/src/module/packaging/classify-module-path.test.ts +36 -0
  45. package/src/module/packaging/package-rules.ts +18 -0
  46. package/src/module/web-root.ts +35 -0
  47. package/src/policy/capability-shape-baseline.ts +8 -0
  48. package/src/policy/module-business-baseline.ts +26 -2
  49. package/src/policy/module-script-scan.test.ts +22 -0
  50. package/src/policy/module-script-scan.ts +32 -0
  51. package/src/services/alerting/observed-health.ts +71 -0
  52. package/src/services/api-principal-enrolment.test.ts +252 -0
  53. package/src/services/api-principal-enrolment.ts +158 -0
  54. package/src/services/audit/backups.ts +10 -1
  55. package/src/services/backup-create.ts +33 -7
  56. package/src/services/backup-metadata.ts +19 -11
  57. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  58. package/src/services/consumer-cleanup.ts +31 -5
  59. package/src/services/fleet-key.test.ts +47 -0
  60. package/src/services/fleet-key.ts +75 -0
  61. package/src/services/instance-ops.test.ts +302 -0
  62. package/src/services/instance-ops.ts +292 -0
  63. package/src/services/module-instances.test.ts +428 -42
  64. package/src/services/module-instances.ts +219 -26
  65. package/src/services/restore-from-file.ts +6 -5
  66. package/src/services/system-state-stage.test.ts +165 -0
  67. 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
+ }