@celilo/cli 1.11.0 → 1.13.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 (50) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +17 -2
  3. package/package.json +3 -3
  4. package/src/cli/commands/alerts-list.ts +16 -1
  5. package/src/cli/commands/backup-list.test.ts +82 -1
  6. package/src/cli/commands/backup-list.ts +113 -4
  7. package/src/cli/commands/console.ts +122 -0
  8. package/src/cli/commands/module-list.ts +3 -41
  9. package/src/cli/commands/module-publish.ts +2 -0
  10. package/src/cli/completion.ts +5 -0
  11. package/src/cli/index.ts +25 -1
  12. package/src/console/closure.test.ts +246 -0
  13. package/src/console/closure.ts +208 -0
  14. package/src/console/control-plane-boundary.test.ts +75 -0
  15. package/src/console/projection.test.ts +231 -0
  16. package/src/console/projection.ts +327 -0
  17. package/src/db/schema.ts +19 -14
  18. package/src/hooks/broker.test.ts +4 -6
  19. package/src/hooks/executor.test.ts +85 -4
  20. package/src/hooks/executor.ts +164 -9
  21. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  22. package/src/hooks/hook-state-dir.test.ts +14 -2
  23. package/src/hooks/hook-timeout.test.ts +2 -4
  24. package/src/hooks/hook-trespass.test.ts +50 -5
  25. package/src/hooks/jail.test.ts +370 -0
  26. package/src/hooks/jail.ts +491 -0
  27. package/src/hooks/mount-set.ts +24 -0
  28. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  29. package/src/manifest/icon-schema.test.ts +48 -0
  30. package/src/manifest/schema.ts +92 -0
  31. package/src/manifest/validate.test.ts +142 -0
  32. package/src/manifest/validate.ts +101 -0
  33. package/src/module/import.test.ts +116 -0
  34. package/src/module/import.ts +73 -1
  35. package/src/module/packaging/audit.ts +103 -1
  36. package/src/module/packaging/classify-module-path.test.ts +36 -0
  37. package/src/module/packaging/package-rules.ts +18 -0
  38. package/src/policy/capability-shape-baseline.ts +8 -0
  39. package/src/policy/module-business-baseline.ts +12 -0
  40. package/src/registry/client.ts +9 -0
  41. package/src/services/alerting/observed-health.ts +71 -0
  42. package/src/services/api-principal-enrolment.test.ts +179 -0
  43. package/src/services/api-principal-enrolment.ts +103 -0
  44. package/src/services/audit/backups.ts +10 -1
  45. package/src/services/backup-metadata.ts +19 -11
  46. package/src/services/consumer-cleanup.ts +31 -5
  47. package/src/services/instance-ops.test.ts +302 -0
  48. package/src/services/instance-ops.ts +292 -0
  49. package/src/services/module-instances.test.ts +428 -42
  50. package/src/services/module-instances.ts +219 -26
@@ -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
+ }