@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
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { randomUUID } from 'node:crypto';
7
- import { and, desc, eq, gt, isNotNull, like } from 'drizzle-orm';
7
+ import { and, desc, eq, gt, gte, isNotNull, like } from 'drizzle-orm';
8
8
  import { getDb } from '../db/client';
9
9
  import { type Backup, type BackupStatus, type BackupType, backups } from '../db/schema';
10
10
  import type { BackupHistory } from './backup-schedule';
@@ -140,21 +140,29 @@ export function getBackup(id: string): Backup | null {
140
140
  export function listBackups(options?: {
141
141
  moduleId?: string;
142
142
  limit?: number;
143
+ /**
144
+ * Only attempts started at or after this instant (epoch ms).
145
+ *
146
+ * A WINDOW rather than a bigger `limit`, because the two answer different
147
+ * questions and only one of them is stable. "The last 500 attempts" spans a
148
+ * week for a module failing hourly and a year for one succeeding daily, so a
149
+ * caller asking for a fixed period cannot express it as a count. The console
150
+ * needs a period: it draws days, and a row missing from its answer renders as
151
+ * a day on which nothing ran.
152
+ */
153
+ since?: number;
143
154
  }): Backup[] {
144
155
  const db = getDb();
145
156
  const limit = options?.limit ?? 20;
146
157
 
147
- if (options?.moduleId) {
148
- return db
149
- .select()
150
- .from(backups)
151
- .where(eq(backups.moduleId, options.moduleId))
152
- .orderBy(desc(backups.startedAt))
153
- .limit(limit)
154
- .all();
155
- }
158
+ const filters = [
159
+ options?.moduleId ? eq(backups.moduleId, options.moduleId) : undefined,
160
+ options?.since !== undefined ? gte(backups.startedAt, new Date(options.since)) : undefined,
161
+ ].filter((clause) => clause !== undefined);
156
162
 
157
- return db.select().from(backups).orderBy(desc(backups.startedAt)).limit(limit).all();
163
+ const query = db.select().from(backups);
164
+ const filtered = filters.length > 0 ? query.where(and(...filters)) : query;
165
+ return filtered.orderBy(desc(backups.startedAt)).limit(limit).all();
158
166
  }
159
167
 
160
168
  /**
@@ -48,6 +48,21 @@ export interface CleanupTarget {
48
48
  skip?: CleanupSkipReason;
49
49
  }
50
50
 
51
+ /**
52
+ * The only part of a manifest this plan reads.
53
+ *
54
+ * Narrower than `ModuleManifest` on purpose. A full manifest is a large,
55
+ * strictly-validated shape, and the console reads manifests straight out of the
56
+ * `modules` table where they are stored as opaque JSON. Asking for the whole
57
+ * shape would force either a full re-validation on every poll or a cast, and a
58
+ * cast at a trust boundary is the thing that eventually bites. `ModuleManifest`
59
+ * satisfies this structurally, so existing callers are unaffected.
60
+ */
61
+ export interface ConsumedCapabilities {
62
+ requires?: { capabilities?: readonly { name: string }[] };
63
+ optional?: { capabilities?: readonly { name: string }[] };
64
+ }
65
+
51
66
  export interface ProviderRow {
52
67
  moduleId: string;
53
68
  capabilityName: string;
@@ -76,7 +91,7 @@ export interface ProviderState {
76
91
  */
77
92
  export function planConsumerCleanup(
78
93
  consumer: string,
79
- manifest: ModuleManifest,
94
+ manifest: ConsumedCapabilities,
80
95
  providerRows: ProviderRow[],
81
96
  providerStates: ProviderState[],
82
97
  ): CleanupTarget[] {
@@ -112,16 +127,27 @@ export function planConsumerCleanup(
112
127
  .sort((a, b) => a.providerId.localeCompare(b.providerId));
113
128
  }
114
129
 
130
+ /**
131
+ * Every (module, capability) provider row.
132
+ *
133
+ * Exported because the console's closure walk needs the same edge this plan
134
+ * does, and reads it for many modules rather than one. Two copies of this query
135
+ * would be two definitions of what a provider is.
136
+ */
137
+ export function loadCapabilityProviderRows(db: DbClient): ProviderRow[] {
138
+ return db
139
+ .select({ moduleId: capabilities.moduleId, capabilityName: capabilities.capabilityName })
140
+ .from(capabilities)
141
+ .all();
142
+ }
143
+
115
144
  /** Read the plan's inputs out of the DB. */
116
145
  export function loadConsumerCleanupPlan(
117
146
  consumer: string,
118
147
  manifest: ModuleManifest,
119
148
  db: DbClient,
120
149
  ): CleanupTarget[] {
121
- const providerRows = db
122
- .select({ moduleId: capabilities.moduleId, capabilityName: capabilities.capabilityName })
123
- .from(capabilities)
124
- .all();
150
+ const providerRows = loadCapabilityProviderRows(db);
125
151
  const providerStates = db
126
152
  .select({ moduleId: modules.id, state: modules.state })
127
153
  .from(modules)
@@ -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
+ });