@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.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +17 -2
- package/package.json +3 -3
- 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.ts +122 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/module-publish.ts +2 -0
- package/src/cli/completion.ts +5 -0
- package/src/cli/index.ts +25 -1
- package/src/console/closure.test.ts +246 -0
- package/src/console/closure.ts +208 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +231 -0
- package/src/console/projection.ts +327 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- 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/icon-schema.test.ts +48 -0
- package/src/manifest/schema.ts +92 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +101 -0
- 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/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +12 -0
- package/src/registry/client.ts +9 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +179 -0
- package/src/services/api-principal-enrolment.ts +103 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/consumer-cleanup.ts +31 -5
- 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
|
@@ -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
|
+
}
|