@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,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narrow reads the web console polls.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the CLI's own reads for a measured reason: `celilo module list
|
|
5
|
+
* --json` returns 156 KB for 23 modules because it embeds every module's
|
|
6
|
+
* `manifestData` blob. That is the right payload for a human debugging one
|
|
7
|
+
* module and the wrong one for a loop that runs every few seconds, so the
|
|
8
|
+
* console gets a projection with the manifest left out and the two derived
|
|
9
|
+
* facts it actually renders (observed health, backup freshness) already
|
|
10
|
+
* computed.
|
|
11
|
+
*
|
|
12
|
+
* Reads only. Nothing here writes, and the console's API principal is granted
|
|
13
|
+
* only read ops, so a write added here would fail at the boundary rather than
|
|
14
|
+
* succeed quietly.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { FIREWALL_CAPABILITY_NAME, orderFirewallChain } from '@celilo/capabilities';
|
|
18
|
+
import { desc, eq } from 'drizzle-orm';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import type { DbClient } from '../db/client';
|
|
21
|
+
import {
|
|
22
|
+
NETWORK_ZONES,
|
|
23
|
+
backups,
|
|
24
|
+
capabilities,
|
|
25
|
+
moduleSystems,
|
|
26
|
+
modules,
|
|
27
|
+
monitors,
|
|
28
|
+
} from '../db/schema';
|
|
29
|
+
import { loadObservedHealthDetail } from '../services/alerting/observed-health';
|
|
30
|
+
import { loadBackupAuditInfo } from '../services/audit/backup-source';
|
|
31
|
+
import { backupStaleThresholdMs, moduleHasBackupHook } from '../services/audit/backups';
|
|
32
|
+
import { effectiveBackupSchedule } from '../services/backup-schedule';
|
|
33
|
+
import { listCapabilityBindings } from '../services/capability-bindings';
|
|
34
|
+
import type { ConsumedCapabilities } from '../services/consumer-cleanup';
|
|
35
|
+
import type { DelegationChain } from './closure';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Just the capability lists, validated at the boundary.
|
|
39
|
+
*
|
|
40
|
+
* `modules.manifestData` is opaque JSON in the database, which is a trust
|
|
41
|
+
* boundary even though celilo wrote it: rows outlive the code that wrote them,
|
|
42
|
+
* and an older celilo's row is exactly the case a clean-database test can never
|
|
43
|
+
* produce. `.catch` rather than `.parse` because one unreadable manifest must
|
|
44
|
+
* end that branch of the walk, not blank the whole topology.
|
|
45
|
+
*/
|
|
46
|
+
const CONSUMED_CAPABILITIES = z
|
|
47
|
+
.object({
|
|
48
|
+
requires: z
|
|
49
|
+
.object({ capabilities: z.array(z.object({ name: z.string() })).optional() })
|
|
50
|
+
.optional(),
|
|
51
|
+
optional: z
|
|
52
|
+
.object({ capabilities: z.array(z.object({ name: z.string() })).optional() })
|
|
53
|
+
.optional(),
|
|
54
|
+
})
|
|
55
|
+
.catch({});
|
|
56
|
+
|
|
57
|
+
export interface ConsoleSystem {
|
|
58
|
+
hostname: string;
|
|
59
|
+
address: string;
|
|
60
|
+
zone: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ConsoleModule {
|
|
64
|
+
id: string;
|
|
65
|
+
version: string;
|
|
66
|
+
state: string;
|
|
67
|
+
health: { cell: string; monitored: boolean; firingCount: number; suppressed: boolean };
|
|
68
|
+
systems: ConsoleSystem[];
|
|
69
|
+
/**
|
|
70
|
+
* Epoch MILLISECONDS of the newest COMPLETED backup, or null if never.
|
|
71
|
+
*
|
|
72
|
+
* Milliseconds because every other instant that crosses this boundary is in
|
|
73
|
+
* milliseconds, and a single field in seconds is a factor of a thousand that
|
|
74
|
+
* no type catches. The console renders ages, so the failure mode is a
|
|
75
|
+
* plausible wrong number rather than a crash: the alerts route computed one
|
|
76
|
+
* age in the wrong unit and rendered every alert as `0m`, a fleet where
|
|
77
|
+
* nothing had been wrong for over a minute.
|
|
78
|
+
*/
|
|
79
|
+
lastBackupAt: number | null;
|
|
80
|
+
lastBackupFailed: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Nothing is checking this module.
|
|
83
|
+
*
|
|
84
|
+
* No enabled monitor, or one whose interval is manual, or one that has never
|
|
85
|
+
* run. Distinct from healthy: an unwatched module's silence is not evidence
|
|
86
|
+
* that it is fine, and `ok` and `unwatched` look identical on a dashboard
|
|
87
|
+
* that only tracks health.
|
|
88
|
+
*/
|
|
89
|
+
unwatched: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* It IS checked, on a schedule, and its monitor has no escalation policy.
|
|
92
|
+
*
|
|
93
|
+
* The alert is raised and reaches nobody. Worse than unwatched in one
|
|
94
|
+
* respect, because it looks monitored, so it is reported separately rather
|
|
95
|
+
* than folded into the health cell.
|
|
96
|
+
*/
|
|
97
|
+
pagesNobody: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* The newest successful backup is older than this module's cadence allows.
|
|
100
|
+
*
|
|
101
|
+
* Resolved HERE, through the same accessor the backup sweep and the drift
|
|
102
|
+
* audit use, so the three cannot disagree about what a module's cadence is.
|
|
103
|
+
* False for a module with no `on_backup` hook: there is nothing to run, so it
|
|
104
|
+
* is not overdue.
|
|
105
|
+
*/
|
|
106
|
+
backupStale: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ConsoleStatus {
|
|
110
|
+
/**
|
|
111
|
+
* The canonical zone order, most exposed first. Served rather than compiled
|
|
112
|
+
* into the console so a zone added to `NETWORK_ZONES` gets a band without a
|
|
113
|
+
* console release.
|
|
114
|
+
*/
|
|
115
|
+
zones: string[];
|
|
116
|
+
modules: ConsoleModule[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The dashboard's single poll: the zone order, and every module with the two
|
|
121
|
+
* derived columns the roster shows.
|
|
122
|
+
*
|
|
123
|
+
* One call rather than three because a poll that changes nothing should cost
|
|
124
|
+
* one round trip, and because the topology, the roster and the backup column
|
|
125
|
+
* all read the same rows.
|
|
126
|
+
*/
|
|
127
|
+
export function consoleStatus(db: DbClient): ConsoleStatus {
|
|
128
|
+
const health = loadObservedHealthDetail(db);
|
|
129
|
+
const systemsByModule = new Map<string, ConsoleSystem[]>();
|
|
130
|
+
|
|
131
|
+
for (const row of db.select().from(moduleSystems).all()) {
|
|
132
|
+
const list = systemsByModule.get(row.moduleId) ?? [];
|
|
133
|
+
list.push({ hostname: row.hostname, address: row.ipv4Address, zone: row.zone });
|
|
134
|
+
systemsByModule.set(row.moduleId, list);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const backupByModule = latestBackupByModule(db);
|
|
138
|
+
const watch = watchFactsByModule(db);
|
|
139
|
+
const overdue = staleBackupModules(db);
|
|
140
|
+
|
|
141
|
+
const rows = db
|
|
142
|
+
.select({ id: modules.id, version: modules.version, state: modules.state })
|
|
143
|
+
.from(modules)
|
|
144
|
+
.all();
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
zones: [...NETWORK_ZONES],
|
|
148
|
+
modules: rows
|
|
149
|
+
.map((row): ConsoleModule => {
|
|
150
|
+
const backup = backupByModule.get(row.id);
|
|
151
|
+
return {
|
|
152
|
+
id: row.id,
|
|
153
|
+
version: row.version,
|
|
154
|
+
state: row.state,
|
|
155
|
+
health: health.get(row.id) ?? {
|
|
156
|
+
// A module that is not deployed has nothing to observe. Saying
|
|
157
|
+
// "not observed" here would report undeployed modules as a finding.
|
|
158
|
+
cell: 'not deployed',
|
|
159
|
+
monitored: false,
|
|
160
|
+
firingCount: 0,
|
|
161
|
+
suppressed: false,
|
|
162
|
+
},
|
|
163
|
+
systems: systemsByModule.get(row.id) ?? [],
|
|
164
|
+
lastBackupAt: backup?.lastSuccessAt ?? null,
|
|
165
|
+
lastBackupFailed: backup?.lastFailed ?? false,
|
|
166
|
+
// Absent from the map means no enabled monitor at all, which is the
|
|
167
|
+
// loudest form of unwatched rather than the quietest.
|
|
168
|
+
unwatched: watch.get(row.id)?.unwatched ?? true,
|
|
169
|
+
pagesNobody: watch.get(row.id)?.pagesNobody ?? false,
|
|
170
|
+
backupStale: overdue.has(row.id),
|
|
171
|
+
};
|
|
172
|
+
})
|
|
173
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* What celilo is doing to watch each module, if anything.
|
|
179
|
+
*
|
|
180
|
+
* Two facts, kept apart because an operator acts differently on each. A module
|
|
181
|
+
* nothing checks is invisible. A module that IS checked on a schedule and whose
|
|
182
|
+
* monitor routes to no escalation policy is worse in one respect: it looks
|
|
183
|
+
* monitored, the alert gets raised on time, and it reaches nobody.
|
|
184
|
+
*
|
|
185
|
+
* `interval manual` and `never run` both count as unwatched. A monitor that
|
|
186
|
+
* exists and has not executed produces exactly as much evidence as one that
|
|
187
|
+
* does not exist, and the console must not report the first as coverage.
|
|
188
|
+
*/
|
|
189
|
+
interface WatchFacts {
|
|
190
|
+
unwatched: boolean;
|
|
191
|
+
pagesNobody: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function watchFactsByModule(db: DbClient): Map<string, WatchFacts> {
|
|
195
|
+
const result = new Map<string, WatchFacts>();
|
|
196
|
+
for (const monitor of db.select().from(monitors).where(eq(monitors.enabled, true)).all()) {
|
|
197
|
+
const running = monitor.intervalMinutes > 0 && monitor.lastRunAt !== null;
|
|
198
|
+
const existing = result.get(monitor.target);
|
|
199
|
+
// A module can carry more than one monitor. It is watched if ANY of them
|
|
200
|
+
// runs, and pages nobody only if every running one lacks a policy.
|
|
201
|
+
result.set(monitor.target, {
|
|
202
|
+
unwatched: (existing?.unwatched ?? true) && !running,
|
|
203
|
+
pagesNobody:
|
|
204
|
+
(existing?.pagesNobody ?? false) || (running && monitor.escalationPolicyId === null),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Modules whose newest successful backup is older than their cadence allows.
|
|
212
|
+
*
|
|
213
|
+
* Resolved through `effectiveBackupSchedule` and `backupStaleThresholdMs`, the
|
|
214
|
+
* same two functions the drift audit uses. The cadence in force comes from the
|
|
215
|
+
* operator's override and then the manifest and defaults to daily when neither
|
|
216
|
+
* says anything, so a console that worked it out again would be a second
|
|
217
|
+
* definition of "overdue" and would disagree the first time an override was
|
|
218
|
+
* set.
|
|
219
|
+
*
|
|
220
|
+
* A module with no `on_backup` hook is never stale. There is nothing to run.
|
|
221
|
+
*/
|
|
222
|
+
function staleBackupModules(db: DbClient): Set<string> {
|
|
223
|
+
const stale = new Set<string>();
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
for (const info of loadBackupAuditInfo(db)) {
|
|
226
|
+
if (!moduleHasBackupHook(info.manifest)) continue;
|
|
227
|
+
const cadence = effectiveBackupSchedule(info.manifest, info.scheduleOverride);
|
|
228
|
+
const threshold = backupStaleThresholdMs(cadence);
|
|
229
|
+
// `manual` has no threshold. Opting out is a decision, not a fault.
|
|
230
|
+
if (threshold === null) continue;
|
|
231
|
+
const last = info.lastSuccessfulBackupAt;
|
|
232
|
+
if (last === null || now - last > threshold) stale.add(info.id);
|
|
233
|
+
}
|
|
234
|
+
return stale;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface BackupFacts {
|
|
238
|
+
lastSuccessAt: number | null;
|
|
239
|
+
lastFailed: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Newest completed backup per module, and whether the most recent ATTEMPT
|
|
244
|
+
* failed.
|
|
245
|
+
*
|
|
246
|
+
* Both, because they answer different questions and the console shows both. A
|
|
247
|
+
* module can hold a fresh successful backup and still have failed last night,
|
|
248
|
+
* and a roster that showed only the success date would call that healthy.
|
|
249
|
+
*/
|
|
250
|
+
function latestBackupByModule(db: DbClient): Map<string, BackupFacts> {
|
|
251
|
+
const result = new Map<string, BackupFacts>();
|
|
252
|
+
const rows = db
|
|
253
|
+
.select({
|
|
254
|
+
moduleId: backups.moduleId,
|
|
255
|
+
status: backups.status,
|
|
256
|
+
startedAt: backups.startedAt,
|
|
257
|
+
completedAt: backups.completedAt,
|
|
258
|
+
})
|
|
259
|
+
.from(backups)
|
|
260
|
+
.orderBy(desc(backups.startedAt))
|
|
261
|
+
.all();
|
|
262
|
+
|
|
263
|
+
for (const row of rows) {
|
|
264
|
+
if (!row.moduleId) continue; // a system-state backup belongs to no module
|
|
265
|
+
const existing = result.get(row.moduleId);
|
|
266
|
+
if (!existing) {
|
|
267
|
+
result.set(row.moduleId, {
|
|
268
|
+
lastSuccessAt:
|
|
269
|
+
row.status === 'completed' && row.completedAt ? epochMillis(row.completedAt) : null,
|
|
270
|
+
lastFailed: row.status === 'failed',
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (existing.lastSuccessAt === null && row.status === 'completed' && row.completedAt) {
|
|
275
|
+
existing.lastSuccessAt = epochMillis(row.completedAt);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Milliseconds, deliberately, and named so nobody has to guess.
|
|
283
|
+
*
|
|
284
|
+
* This was `epochSeconds`, and the protocol on the other side of the boundary
|
|
285
|
+
* documents the same field as milliseconds. The two never met because the
|
|
286
|
+
* console server does not exist yet, so the factor of a thousand sat there
|
|
287
|
+
* looking like working code. It is the exact shape of the bug that rendered
|
|
288
|
+
* every alert as `0m`.
|
|
289
|
+
*/
|
|
290
|
+
function epochMillis(value: Date): number {
|
|
291
|
+
return value.getTime();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Which providers a module has actually called into, by capability.
|
|
296
|
+
*
|
|
297
|
+
* The difference between a manifest and a fleet. A manifest says what a module
|
|
298
|
+
* CAN consume; this says what it resolved to and reached for. `tango-nexus`
|
|
299
|
+
* declares four optional capabilities and is bound to one, and until
|
|
300
|
+
* celilo#1072 landed there was no way to tell those apart.
|
|
301
|
+
*
|
|
302
|
+
* The console previously inferred this: a REQUIRED capability of a deployed
|
|
303
|
+
* module must have bound, because the deploy would have failed otherwise. That
|
|
304
|
+
* was the only sound inference available and it is now an approximation, so it
|
|
305
|
+
* is gone rather than kept as a fallback (Rule 3.9). A module with no rows here
|
|
306
|
+
* has called into nothing, which is a real answer and not a missing one.
|
|
307
|
+
*/
|
|
308
|
+
export function loadBindings(db: DbClient, moduleId: string): Map<string, string> {
|
|
309
|
+
return new Map(
|
|
310
|
+
listCapabilityBindings(db, moduleId).map((b) => [b.capabilityName, b.providerModuleId]),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Everything the closure walk needs, read in ONE place.
|
|
316
|
+
*
|
|
317
|
+
* The chains are in here rather than beside it deliberately. A separate loader
|
|
318
|
+
* a caller has to remember is a caller that forgets, and the answer when it
|
|
319
|
+
* forgets is an empty chain list that reads exactly like a fleet with no
|
|
320
|
+
* delegation. There is one call site, and it cannot get a partial set.
|
|
321
|
+
*/
|
|
322
|
+
export function loadClosureInputs(db: DbClient): {
|
|
323
|
+
manifests: Map<string, ConsumedCapabilities>;
|
|
324
|
+
providerStates: { moduleId: string; state: string }[];
|
|
325
|
+
chains: DelegationChain[];
|
|
326
|
+
} {
|
|
327
|
+
return {
|
|
328
|
+
manifests: new Map(
|
|
329
|
+
db
|
|
330
|
+
.select({ id: modules.id, manifestData: modules.manifestData })
|
|
331
|
+
.from(modules)
|
|
332
|
+
.all()
|
|
333
|
+
.map((row) => [row.id, CONSUMED_CAPABILITIES.parse(row.manifestData)] as const),
|
|
334
|
+
),
|
|
335
|
+
providerStates: db.select({ moduleId: modules.id, state: modules.state }).from(modules).all(),
|
|
336
|
+
chains: loadFirewallChain(db),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The `firewall` providers in delegation order, as at most one chain.
|
|
342
|
+
*
|
|
343
|
+
* At most one because `firewall` is the only capability celilo chains, and
|
|
344
|
+
* `orderFirewallChain` — the SAME function the hook loader wires the live
|
|
345
|
+
* providers with — is what decides the order. A fleet with one firewall, or
|
|
346
|
+
* with none that reaches the internet, yields no chain at all.
|
|
347
|
+
*/
|
|
348
|
+
export function loadFirewallChain(db: DbClient): DelegationChain[] {
|
|
349
|
+
const providers = db
|
|
350
|
+
.select({ moduleId: capabilities.moduleId, data: capabilities.data })
|
|
351
|
+
.from(capabilities)
|
|
352
|
+
.where(eq(capabilities.capabilityName, FIREWALL_CAPABILITY_NAME))
|
|
353
|
+
.all();
|
|
354
|
+
|
|
355
|
+
const ordered = orderFirewallChain(providers).map((provider) => provider.moduleId);
|
|
356
|
+
return ordered.length === 0 ? [] : [{ capability: FIREWALL_CAPABILITY_NAME, moduleIds: ordered }];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** A module's row, or undefined. Used to reject a closure request for a module that is gone. */
|
|
360
|
+
export function moduleExists(db: DbClient, moduleId: string): boolean {
|
|
361
|
+
return (
|
|
362
|
+
db.select({ id: modules.id }).from(modules).where(eq(modules.id, moduleId)).all().length > 0
|
|
363
|
+
);
|
|
364
|
+
}
|
package/src/db/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { INSTANCE_STATES, type InstanceState } from '@celilo/capabilities';
|
|
1
2
|
import { sql } from 'drizzle-orm';
|
|
2
3
|
import {
|
|
3
4
|
index,
|
|
@@ -300,22 +301,14 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
|
|
|
300
301
|
});
|
|
301
302
|
|
|
302
303
|
/**
|
|
303
|
-
* Lifecycle states an instance moves through
|
|
304
|
-
* `list` so a parent can compare desired against observed (design D6).
|
|
304
|
+
* Lifecycle states an instance moves through.
|
|
305
305
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
306
|
+
* Re-exported from `@celilo/capabilities` rather than redefined here. `list`
|
|
307
|
+
* PROMISES these to a caller's reconcile loop, so the vocabulary belongs to the
|
|
308
|
+
* contract; a second copy in the schema is how a column and an interface come
|
|
309
|
+
* to disagree about what `failed` means.
|
|
309
310
|
*/
|
|
310
|
-
export
|
|
311
|
-
'pending',
|
|
312
|
-
'provisioning',
|
|
313
|
-
'ready',
|
|
314
|
-
'failed',
|
|
315
|
-
'destroying',
|
|
316
|
-
] as const;
|
|
317
|
-
|
|
318
|
-
export type InstanceState = (typeof INSTANCE_STATES)[number];
|
|
311
|
+
export { INSTANCE_STATES, type InstanceState };
|
|
319
312
|
|
|
320
313
|
/**
|
|
321
314
|
* Instances of a submodule (openspec/changes/submodules, D2 and D4).
|
|
@@ -492,6 +485,18 @@ export const moduleBuilds = sqliteTable('module_builds', {
|
|
|
492
485
|
* that is someone else's cloud, this is a network the fleet's own firewall
|
|
493
486
|
* holds a leg on and must translate for.
|
|
494
487
|
*/
|
|
488
|
+
/**
|
|
489
|
+
* ⚠️ ADDING A ZONE ALSO MEANS TOUCHING THE WEB CONSOLE.
|
|
490
|
+
*
|
|
491
|
+
* A zone comes into being here and nowhere else. How it is DRAWN — its accent
|
|
492
|
+
* colour and the one-line description a person reads under its name — lives in
|
|
493
|
+
* `apps/console/src/zones.ts`, because that is presentation and this is not.
|
|
494
|
+
*
|
|
495
|
+
* A zone added here and missed there draws grey with "no description yet",
|
|
496
|
+
* which is legible and wrong. `apps/console/tests/zones.test.ts` fails until
|
|
497
|
+
* the entry exists, so the pull request that adds the zone is the one that
|
|
498
|
+
* finds out, rather than the release that ships it.
|
|
499
|
+
*/
|
|
495
500
|
export const NETWORK_ZONES = [
|
|
496
501
|
'isp-transit',
|
|
497
502
|
'internal',
|
package/src/hooks/broker.test.ts
CHANGED
|
@@ -58,12 +58,10 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
|
|
|
58
58
|
stateDir: dir,
|
|
59
59
|
capabilities: demoCapabilities(),
|
|
60
60
|
};
|
|
61
|
-
return await executeHookScript(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
30_000,
|
|
66
|
-
);
|
|
61
|
+
return await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, {
|
|
62
|
+
timeoutMs: 30_000,
|
|
63
|
+
idleTimeoutMs: 30_000,
|
|
64
|
+
});
|
|
67
65
|
} finally {
|
|
68
66
|
rmSync(dir, { recursive: true, force: true });
|
|
69
67
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `control_plane_api` reaches a consuming module, and only a consuming module.
|
|
3
|
+
*
|
|
4
|
+
* The rest of `loadCapabilityFunctions` hands over every capability it can
|
|
5
|
+
* build, declared or not — its own comment says "not just required ones", and
|
|
6
|
+
* `capability-loader-bindings.test.ts` asserts that shape deliberately. That is
|
|
7
|
+
* fine for a capability whose worst outcome is an unused OIDC client.
|
|
8
|
+
*
|
|
9
|
+
* This one mints an SSH principal into celilo's own control plane, so it is
|
|
10
|
+
* gated on the `requires` line instead. These pin both halves, because the
|
|
11
|
+
* failure that matters is silent in both directions: a console that declared it
|
|
12
|
+
* and got nothing fails at install with "no provider is loaded" for a capability
|
|
13
|
+
* no module ever provides, and a module that declared nothing and got it anyway
|
|
14
|
+
* holds control-plane read that no manifest review would have shown.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import type { HookLogger } from '@celilo/capabilities';
|
|
19
|
+
import type { DbClient } from '../db/client';
|
|
20
|
+
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
21
|
+
import { loadCapabilityFunctions } from './capability-loader';
|
|
22
|
+
|
|
23
|
+
const noopLogger: HookLogger = {
|
|
24
|
+
info() {},
|
|
25
|
+
warn() {},
|
|
26
|
+
error() {},
|
|
27
|
+
success() {},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function installModule(db: DbClient, moduleId: string, manifest: Record<string, unknown>): void {
|
|
31
|
+
db.$client.run(
|
|
32
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES (?, ?, '1.0.0', '/tmp/${moduleId}', ?)`,
|
|
33
|
+
[moduleId, moduleId, JSON.stringify(manifest)],
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DECLARES = {
|
|
38
|
+
requires: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('control_plane_api injection', () => {
|
|
42
|
+
let db: DbClient;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
db = await setupTestDatabase();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await cleanupTestDatabase(db);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a module that requires it gets it, with no provider module deployed', async () => {
|
|
53
|
+
// The point of the whole decision: nothing provides this capability, and it
|
|
54
|
+
// is still there. A test that first deployed a provider would be measuring
|
|
55
|
+
// the ordinary path.
|
|
56
|
+
installModule(db, 'celilo-web-console', DECLARES);
|
|
57
|
+
|
|
58
|
+
const capabilities = await loadCapabilityFunctions('celilo-web-console', db, noopLogger);
|
|
59
|
+
|
|
60
|
+
expect(capabilities.control_plane_api).toBeTruthy();
|
|
61
|
+
const api = capabilities.control_plane_api as Record<string, unknown>;
|
|
62
|
+
expect(typeof api.enrol_principal).toBe('function');
|
|
63
|
+
expect(typeof api.revoke_principal).toBe('function');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a module that declares it as optional gets it too', async () => {
|
|
67
|
+
// `optional` is a declaration a reviewer reads on the same line of the same
|
|
68
|
+
// file. Treating it as no declaration would fail the module at runtime for a
|
|
69
|
+
// capability its manifest names.
|
|
70
|
+
installModule(db, 'someday-console', {
|
|
71
|
+
optional: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const capabilities = await loadCapabilityFunctions('someday-console', db, noopLogger);
|
|
75
|
+
|
|
76
|
+
expect(capabilities.control_plane_api).toBeTruthy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a module that declares nothing does NOT get it', async () => {
|
|
80
|
+
installModule(db, 'hello-foo', { requires: { capabilities: [] } });
|
|
81
|
+
|
|
82
|
+
const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
|
|
83
|
+
|
|
84
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a module that requires something ELSE does NOT get it', async () => {
|
|
88
|
+
// The near miss: a manifest with a populated `requires` block is the shape a
|
|
89
|
+
// too-loose check ("does this module require anything?") would wave through.
|
|
90
|
+
installModule(db, 'hello-bar', {
|
|
91
|
+
requires: { capabilities: [{ name: 'idp', version: '1.0.0' }] },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const capabilities = await loadCapabilityFunctions('hello-bar', db, noopLogger);
|
|
95
|
+
|
|
96
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a module celilo has no record of does NOT get it', async () => {
|
|
100
|
+
// A hook can run for a module id with no row (a removal path, a typo). The
|
|
101
|
+
// manifest lookup returns nothing, and "no manifest" must read as "declared
|
|
102
|
+
// nothing" rather than throwing or defaulting open.
|
|
103
|
+
const capabilities = await loadCapabilityFunctions('never-imported', db, noopLogger);
|
|
104
|
+
|
|
105
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the injected object is scoped to the module it was built for', async () => {
|
|
109
|
+
// Scoping is what stops a module rotating a neighbour's key. It is asserted
|
|
110
|
+
// through the loader rather than only on `buildControlPlaneApi`, because the
|
|
111
|
+
// loader is where the wrong argument (a provider id, a hook name) would be
|
|
112
|
+
// passed.
|
|
113
|
+
installModule(db, 'hello-foo', DECLARES);
|
|
114
|
+
|
|
115
|
+
const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
|
|
116
|
+
const api = capabilities.control_plane_api as {
|
|
117
|
+
revoke_principal(request: { name: string }): Promise<unknown>;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
|
|
121
|
+
/only revoke its own principal/,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|