@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
|
@@ -2,9 +2,15 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { eq } from 'drizzle-orm';
|
|
5
|
-
import { getDb } from '../../db/client';
|
|
5
|
+
import { type DbClient, getDb } from '../../db/client';
|
|
6
6
|
import { moduleIntegrity, modules } from '../../db/schema';
|
|
7
7
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
8
|
+
import {
|
|
9
|
+
type InstanceRecord,
|
|
10
|
+
loadInstance,
|
|
11
|
+
submoduleSourcePath,
|
|
12
|
+
verifyInstanceLinks,
|
|
13
|
+
} from '../../services/module-instances';
|
|
8
14
|
import { computeFileChecksum } from './checksum';
|
|
9
15
|
import type { IntegrityViolation } from './extract';
|
|
10
16
|
import { compareVerbatimRoleAssets, readVerbatimRoleAssets } from './generated-plane';
|
|
@@ -49,6 +55,21 @@ async function scanDirectory(dir: string, baseDir: string): Promise<string[]> {
|
|
|
49
55
|
const fullPath = join(dir, entry.name);
|
|
50
56
|
const relativePath = relative(baseDir, fullPath);
|
|
51
57
|
|
|
58
|
+
// A symlink is SKIPPED, deliberately and in writing.
|
|
59
|
+
//
|
|
60
|
+
// An instance's authored source is symlinked in from its parent's
|
|
61
|
+
// `submodules/` tree (openspec/changes/submodules D4), so those bytes are
|
|
62
|
+
// the PARENT's and are covered by the parent's baseline. This module has no
|
|
63
|
+
// claim to make over them, and following the link would have it claim
|
|
64
|
+
// integrity over bytes whose checksums belong to somebody else.
|
|
65
|
+
//
|
|
66
|
+
// This already happened, by accident: `Dirent.isDirectory()` and
|
|
67
|
+
// `isFile()` are BOTH false for a symlink, so links fell through both
|
|
68
|
+
// branches below and vanished. Right answer, no reason. Stating it here
|
|
69
|
+
// means a later change that makes the walk follow links has to decide
|
|
70
|
+
// about instances on purpose rather than reverse this silently.
|
|
71
|
+
if (entry.isSymbolicLink()) continue;
|
|
72
|
+
|
|
52
73
|
if (entry.isDirectory()) {
|
|
53
74
|
// Prune whole derived subtrees rather than walking them. `generated/`
|
|
54
75
|
// alone carries terraform provider binaries.
|
|
@@ -91,6 +112,18 @@ export async function auditModule(
|
|
|
91
112
|
};
|
|
92
113
|
}
|
|
93
114
|
|
|
115
|
+
// An instance has no integrity row of its own and never will
|
|
116
|
+
// (openspec/changes/submodules D4). Its authored bytes are its
|
|
117
|
+
// submodule's, its submodule's are its parent's, and the parent already
|
|
118
|
+
// carries a baseline covering `submodules/**`. So it resolves THROUGH to
|
|
119
|
+
// that rather than being excluded: excluding it would report nothing for
|
|
120
|
+
// forty rows, and erroring would report `No integrity data found` forty
|
|
121
|
+
// times for a fleet that is entirely healthy.
|
|
122
|
+
const instance = loadInstance(moduleId, db);
|
|
123
|
+
if (instance) {
|
|
124
|
+
return await auditInstance(instance, module.sourcePath, db, options);
|
|
125
|
+
}
|
|
126
|
+
|
|
94
127
|
// Get integrity data
|
|
95
128
|
const integrity = db
|
|
96
129
|
.select()
|
|
@@ -242,3 +275,72 @@ export async function auditModule(
|
|
|
242
275
|
};
|
|
243
276
|
}
|
|
244
277
|
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Audit an instance.
|
|
281
|
+
*
|
|
282
|
+
* Two questions, and only the second is the instance's own.
|
|
283
|
+
*
|
|
284
|
+
* Its BYTES belong to its parent, so the integrity verdict is the parent's:
|
|
285
|
+
* `submodules/**` classifies `package`, so the parent's baseline already covers
|
|
286
|
+
* every file an instance runs. Re-checksumming them here would be asking the
|
|
287
|
+
* same question a second time and inventing a second answer that can disagree.
|
|
288
|
+
*
|
|
289
|
+
* Its LINKS are its own, and nothing else in celilo checks them. A link
|
|
290
|
+
* repointed at another module's source is an instance running somebody else's
|
|
291
|
+
* code while every checksum in the fleet still reconciles, which is exactly the
|
|
292
|
+
* kind of quiet wrong answer the integrity plane exists to prevent.
|
|
293
|
+
*/
|
|
294
|
+
async function auditInstance(
|
|
295
|
+
instance: InstanceRecord,
|
|
296
|
+
instancePath: string,
|
|
297
|
+
db: DbClient,
|
|
298
|
+
options: AuditOptions,
|
|
299
|
+
): Promise<AuditResult> {
|
|
300
|
+
const violations: IntegrityViolation[] = [];
|
|
301
|
+
|
|
302
|
+
const parent = db.select().from(modules).where(eq(modules.id, instance.parentId)).get();
|
|
303
|
+
if (!parent) {
|
|
304
|
+
// The FK makes this unreachable through SQL, so reaching it means the row
|
|
305
|
+
// was written around the schema. Reported rather than assumed away.
|
|
306
|
+
return {
|
|
307
|
+
success: false,
|
|
308
|
+
moduleId: instance.moduleId,
|
|
309
|
+
violations,
|
|
310
|
+
error: `Instance '${instance.moduleId}' names parent '${instance.parentId}', which does not exist.`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const submodulePath = submoduleSourcePath(parent.sourcePath, instance.submodule);
|
|
315
|
+
for (const link of await verifyInstanceLinks({ instancePath, submodulePath })) {
|
|
316
|
+
violations.push({
|
|
317
|
+
type: 'modified',
|
|
318
|
+
path: link.entry,
|
|
319
|
+
message:
|
|
320
|
+
link.actual === null
|
|
321
|
+
? `Link '${link.entry}' is missing or is not a link. It should point at ${link.expected}. Redeploy this instance to rebuild its links.`
|
|
322
|
+
: `Link '${link.entry}' points at ${link.actual}, not at ${link.expected}. This instance is running source it does not own.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The parent's verdict, carried rather than recomputed. A parent whose
|
|
327
|
+
// baseline is stale or whose files are modified means every instance under it
|
|
328
|
+
// is running unverified code, and saying so here is what stops an operator
|
|
329
|
+
// reading a clean instance row as evidence.
|
|
330
|
+
const parentResult = await auditModule(instance.parentId, db, options);
|
|
331
|
+
if (!parentResult.success) {
|
|
332
|
+
violations.push({
|
|
333
|
+
type: 'stale-baseline',
|
|
334
|
+
path: `${instance.parentId}`,
|
|
335
|
+
message: `The bytes this instance runs belong to '${instance.parentId}', whose own audit is not clean (${parentResult.violations.length} violation(s)${parentResult.error ? `: ${parentResult.error}` : ''}). Fix the parent; this instance cannot be verified independently.`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
success: violations.length === 0,
|
|
341
|
+
moduleId: instance.moduleId,
|
|
342
|
+
violations,
|
|
343
|
+
moduleVersion: parent.version,
|
|
344
|
+
baselineVersion: parentResult.baselineVersion,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
@@ -102,3 +102,39 @@ describe('classifyModulePath: composes with includeNodeModulesPath', () => {
|
|
|
102
102
|
expect(classifyModulePath('node_modules/@celilo/cli/index.js')).toBe('unknown');
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
describe('a submodule tree obeys the same rules as a module tree', () => {
|
|
107
|
+
// openspec/changes/submodules D1: a submodule declares everything an ordinary
|
|
108
|
+
// module declares, so it must be CLASSIFIED like one too. Every rule keys on
|
|
109
|
+
// segments[0], so without the recursion a nested tree gets different answers
|
|
110
|
+
// than the same file one level up.
|
|
111
|
+
test('authored source under a submodule is package', () => {
|
|
112
|
+
expect(classifyModulePath('submodules/runner/manifest.yml')).toBe('package');
|
|
113
|
+
expect(classifyModulePath('submodules/runner/ansible/site.yml')).toBe('package');
|
|
114
|
+
expect(classifyModulePath('submodules/runner/scripts/hook.ts')).toBe('package');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a submodule's e2e tree is excluded, exactly as a module's is", () => {
|
|
118
|
+
expect(classifyModulePath('e2e/thing.ts')).toBe('unknown');
|
|
119
|
+
expect(classifyModulePath('submodules/runner/e2e/thing.ts')).toBe('unknown');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a submodule's derived paths are derived", () => {
|
|
123
|
+
expect(classifyModulePath('submodules/runner/generated/terraform/x.tfstate')).toBe('derived');
|
|
124
|
+
expect(classifyModulePath('submodules/runner/celilo/types.d.ts')).toBe('derived');
|
|
125
|
+
expect(classifyModulePath('submodules/runner/cookies.json')).toBe('derived');
|
|
126
|
+
expect(classifyModulePath('submodules/runner/screenshots/a.png')).toBe('derived');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('source-tree noise under a submodule is still a finding', () => {
|
|
130
|
+
expect(classifyModulePath('submodules/runner/.DS_Store')).toBe('unknown');
|
|
131
|
+
expect(classifyModulePath('submodules/runner/tsconfig.json')).toBe('unknown');
|
|
132
|
+
expect(classifyModulePath('submodules/runner/a.test.ts')).toBe('unknown');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// The directories themselves are shipped bytes and belong in the baseline.
|
|
136
|
+
test('the submodules directory itself is package', () => {
|
|
137
|
+
expect(classifyModulePath('submodules')).toBe('package');
|
|
138
|
+
expect(classifyModulePath('submodules/runner')).toBe('package');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -69,10 +69,28 @@ export type ModulePathClass = 'package' | 'derived' | 'unknown';
|
|
|
69
69
|
* composes this function with `includeNodeModulesPath` rather than restating
|
|
70
70
|
* either rule.
|
|
71
71
|
*/
|
|
72
|
+
export const SUBMODULES_DIR = 'submodules';
|
|
73
|
+
|
|
72
74
|
export function classifyModulePath(relPath: string): ModulePathClass {
|
|
73
75
|
const segments = relPath.split('/');
|
|
74
76
|
const name = segments[segments.length - 1] ?? '';
|
|
75
77
|
|
|
78
|
+
// A submodule's tree obeys exactly the same rules as a module's tree
|
|
79
|
+
// (openspec/changes/submodules D1: a submodule declares everything an
|
|
80
|
+
// ordinary module declares). Every rule below keys on `segments[0]`, so
|
|
81
|
+
// without this a nested tree silently gets different answers: a submodule's
|
|
82
|
+
// own `e2e/` would be checksummed where a module's is excluded, because
|
|
83
|
+
// `segments[0]` reads `submodules` rather than `e2e`.
|
|
84
|
+
//
|
|
85
|
+
// Recursing on the remainder means one set of rules rather than two that
|
|
86
|
+
// drift, and every rule applies at both levels: a submodule's `e2e/` is
|
|
87
|
+
// excluded, its `celilo/types.d.ts` and `cookies.json` are derived, its
|
|
88
|
+
// `.DS_Store` is a finding. `submodules/` and `submodules/<name>` themselves
|
|
89
|
+
// stay `package`, which is right: the directories ARE shipped bytes.
|
|
90
|
+
if (segments[0] === SUBMODULES_DIR && segments.length > 2) {
|
|
91
|
+
return classifyModulePath(segments.slice(2).join('/'));
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
// The module's own e2e/ tree is tests plus their deps, including a
|
|
77
95
|
// node_modules of its own. Excluded whole, before anything below.
|
|
78
96
|
if (segments[0] === 'e2e') return 'unknown';
|
|
@@ -77,6 +77,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
|
|
|
77
77
|
version: '1.0.0',
|
|
78
78
|
hash: '2f7f81d0b3ecb2ab6822dbc9bf837a933b25e0e157eb94d0b9b8e801417f8f14',
|
|
79
79
|
},
|
|
80
|
+
control_plane_api: {
|
|
81
|
+
version: '1.0.0',
|
|
82
|
+
hash: '2c5d41d37a9a828a8b9a6dc633a2d48f24670d0831d879ec18426f0f9f8e5be5',
|
|
83
|
+
},
|
|
80
84
|
private_web: {
|
|
81
85
|
version: '1.0.0',
|
|
82
86
|
hash: '0eddb82f821fe3c7fbd503d55a24c3d5a22369af8b22de06570a371d5f485c03',
|
|
@@ -85,6 +89,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
|
|
|
85
89
|
version: '1.0.0',
|
|
86
90
|
hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
|
|
87
91
|
},
|
|
92
|
+
celilo_module_deploy_worker: {
|
|
93
|
+
version: '1.0.0',
|
|
94
|
+
hash: '0b0e1f26aab0ebcf5d28283b9c5ba711f6d182f8f7ad474379c4ab02c354a2b8',
|
|
95
|
+
},
|
|
88
96
|
web_routes: {
|
|
89
97
|
version: '1.0.0',
|
|
90
98
|
hash: 'bbf6435528a799e205270681e0ffc34f4007efbf04240138d4b69594a2515dbb',
|
|
@@ -111,6 +111,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
111
111
|
count: 1,
|
|
112
112
|
why: 'PERMANENT — the public_web implementation naming its own table; the capability package doing its job',
|
|
113
113
|
},
|
|
114
|
+
{
|
|
115
|
+
file: 'packages/capabilities/src/capability-contract.ts',
|
|
116
|
+
capability: 'control_plane_api',
|
|
117
|
+
count: 1,
|
|
118
|
+
why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
|
|
119
|
+
},
|
|
114
120
|
{
|
|
115
121
|
file: 'packages/capabilities/src/capability-contract.ts',
|
|
116
122
|
capability: 'web_routes',
|
|
@@ -327,6 +333,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
327
333
|
count: 1,
|
|
328
334
|
why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
|
|
329
335
|
},
|
|
336
|
+
{
|
|
337
|
+
file: 'packages/capabilities/src/capability-contract.ts',
|
|
338
|
+
capability: 'celilo_module_deploy_worker',
|
|
339
|
+
count: 1,
|
|
340
|
+
why: 'PERMANENT — X10, a contract declaration with no implementation; this is the package doing its job',
|
|
341
|
+
},
|
|
330
342
|
{
|
|
331
343
|
file: 'packages/capabilities/src/capability-contract.ts',
|
|
332
344
|
capability: 'control_plane_vpn',
|
package/src/registry/client.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface SearchResult {
|
|
|
28
28
|
name: string;
|
|
29
29
|
max_version: string;
|
|
30
30
|
description: string;
|
|
31
|
+
/** The module's declared glyph, absent when it declared none. */
|
|
32
|
+
icon?: string;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export interface SearchResponse {
|
|
@@ -130,6 +132,12 @@ export class RegistryClient {
|
|
|
130
132
|
* apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
|
|
131
133
|
*/
|
|
132
134
|
description?: string;
|
|
135
|
+
/**
|
|
136
|
+
* The module's `manifest.yml#icon`, when it declares one. Optional and
|
|
137
|
+
* server-tolerated in its absence, exactly like `description`
|
|
138
|
+
* (openspec/changes/module-icons, D4).
|
|
139
|
+
*/
|
|
140
|
+
icon?: string;
|
|
133
141
|
}): Promise<{ ok: boolean; name: string; vers: string }> {
|
|
134
142
|
const fileData = await readFile(opts.netappPath);
|
|
135
143
|
const cksum = `sha256:${createHash('sha256').update(fileData).digest('hex')}`;
|
|
@@ -140,6 +148,7 @@ export class RegistryClient {
|
|
|
140
148
|
deps: [],
|
|
141
149
|
cksum,
|
|
142
150
|
...(opts.description ? { description: opts.description } : {}),
|
|
151
|
+
...(opts.icon ? { icon: opts.icon } : {}),
|
|
143
152
|
});
|
|
144
153
|
const metaBuf = Buffer.from(meta, 'utf-8');
|
|
145
154
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observed health per module, derived from live alerts and enabled monitors.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `module list` because it now has a second consumer. The web
|
|
5
|
+
* console shows the same column, and celilo has already decided how this is
|
|
6
|
+
* computed: `nextModuleState()` documents why observation must NOT drive a
|
|
7
|
+
* module's lifecycle state, since letting a recurring check write it would
|
|
8
|
+
* redefine VERIFIED from "someone verified this" to "it was up recently" and
|
|
9
|
+
* churn it on every transient failure.
|
|
10
|
+
*
|
|
11
|
+
* So this stays a derivation and never a stored field, and both consumers read
|
|
12
|
+
* it here rather than each growing a copy.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { eq } from 'drizzle-orm';
|
|
16
|
+
import type { DbClient } from '../../db/client';
|
|
17
|
+
import { modules as modulesTable, monitors } from '../../db/schema';
|
|
18
|
+
import { moduleHealthCell } from './format';
|
|
19
|
+
import { loadAllLiveAlerts, summariseByModule } from './store';
|
|
20
|
+
|
|
21
|
+
/** The structured form. `cell` is the display string the CLI column prints. */
|
|
22
|
+
export interface ObservedHealth {
|
|
23
|
+
cell: string;
|
|
24
|
+
monitored: boolean;
|
|
25
|
+
firingCount: number;
|
|
26
|
+
suppressed: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Observed health per module: `ok`, `N firing`, `suppressed`, or
|
|
31
|
+
* `not observed`.
|
|
32
|
+
*
|
|
33
|
+
* Only computed for DEPLOYED modules — a module still being imported has
|
|
34
|
+
* nothing to observe, and reporting it as unwatched would be noise rather
|
|
35
|
+
* than a finding.
|
|
36
|
+
*/
|
|
37
|
+
export function loadObservedHealth(db: DbClient): Map<string, string> {
|
|
38
|
+
return new Map([...loadObservedHealthDetail(db)].map(([id, h]) => [id, h.cell]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The same derivation, with the inputs kept rather than collapsed into a
|
|
43
|
+
* string. The console needs the firing COUNT and the not-observed case as
|
|
44
|
+
* separate facts: it renders them differently, and parsing them back out of a
|
|
45
|
+
* terminal column would be a second, worse implementation.
|
|
46
|
+
*/
|
|
47
|
+
export function loadObservedHealthDetail(db: DbClient): Map<string, ObservedHealth> {
|
|
48
|
+
const monitored = new Set(
|
|
49
|
+
db
|
|
50
|
+
.select({ target: monitors.target })
|
|
51
|
+
.from(monitors)
|
|
52
|
+
.where(eq(monitors.enabled, true))
|
|
53
|
+
.all()
|
|
54
|
+
.map((m) => m.target),
|
|
55
|
+
);
|
|
56
|
+
const byModule = summariseByModule(loadAllLiveAlerts(db));
|
|
57
|
+
|
|
58
|
+
const result = new Map<string, ObservedHealth>();
|
|
59
|
+
for (const module of db.select().from(modulesTable).all()) {
|
|
60
|
+
if (module.state !== 'INSTALLED' && module.state !== 'VERIFIED') continue;
|
|
61
|
+
|
|
62
|
+
const summary = byModule.get(module.id) ?? [];
|
|
63
|
+
const input = {
|
|
64
|
+
monitored: monitored.has(module.id),
|
|
65
|
+
firingCount: summary.filter((s) => !s.suppressed).length,
|
|
66
|
+
suppressed: summary.length > 0 && summary.every((s) => s.suppressed),
|
|
67
|
+
};
|
|
68
|
+
result.set(module.id, { ...input, cell: moduleHealthCell(input) });
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `control_plane_api`, and the one property that has to hold forever.
|
|
3
|
+
*
|
|
4
|
+
* The console reads celilo's own state. What it must never acquire is the
|
|
5
|
+
* ability to change it — and the way that would happen is not somebody adding
|
|
6
|
+
* `module:deploy` on purpose. It is a new verb appearing in the registry and
|
|
7
|
+
* quietly falling on the wrong side of a grant list nobody re-reads.
|
|
8
|
+
*
|
|
9
|
+
* So the grants are DERIVED, and these assert the derivation rather than a
|
|
10
|
+
* snapshot of its output. A test that pinned today's list would pass forever
|
|
11
|
+
* while the thing it protects rotted.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { COMMANDS, readOnlyGrants } from '@celilo/core';
|
|
18
|
+
import { closeDb } from '../db/client';
|
|
19
|
+
import { getPrincipalByName, isAuthorized } from './api-access';
|
|
20
|
+
import {
|
|
21
|
+
controlPlaneReadGrants,
|
|
22
|
+
enrolControlPlanePrincipal,
|
|
23
|
+
revokeControlPlanePrincipal,
|
|
24
|
+
} from './api-principal-enrolment';
|
|
25
|
+
|
|
26
|
+
/** A syntactically real ed25519 public key. Nothing here ever sees a private one. */
|
|
27
|
+
const PUBLIC_KEY =
|
|
28
|
+
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB7cWlXqPn+aGaNKm2Rt1cwj6cVy0hL0hVXHhWJKtLZ9 celilo-web-console';
|
|
29
|
+
const OTHER_KEY =
|
|
30
|
+
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILlqL4YQ1MDe0J0X2ZQ4pXFqNbXK9y7dQe2Vd1kZ8sWq rotated';
|
|
31
|
+
|
|
32
|
+
describe('the grant set', () => {
|
|
33
|
+
test('is exactly the read-only ops the classifier derives', () => {
|
|
34
|
+
// Not a snapshot. If this were a literal list it would pass forever while a
|
|
35
|
+
// newer celilo added verbs it no longer covered.
|
|
36
|
+
expect(controlPlaneReadGrants()).toEqual(readOnlyGrants(COMMANDS));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('is not empty, so the derivation is doing something', () => {
|
|
40
|
+
// The failure mode a derived list has that a literal one does not: a
|
|
41
|
+
// classifier change makes it return nothing and every read starts failing
|
|
42
|
+
// closed. That is safe and completely broken, so it is asserted.
|
|
43
|
+
expect(controlPlaneReadGrants().length).toBeGreaterThan(10);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('contains no obviously mutating op', () => {
|
|
47
|
+
// A coarse net on purpose. The classifier is the real gate; this catches the
|
|
48
|
+
// case where the classifier itself is changed to admit writes, which no test
|
|
49
|
+
// of the classifier's own output would notice.
|
|
50
|
+
const mutating = /:(deploy|remove|delete|create|set|add|uninstall|restore|grant|revoke|pause)$/;
|
|
51
|
+
expect(controlPlaneReadGrants().filter((op) => mutating.test(op))).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('enrolment', () => {
|
|
56
|
+
let dir: string;
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
// The service reaches for the global `getDb()`, so isolation is by
|
|
60
|
+
// CELILO_DB_PATH rather than by passing a client around.
|
|
61
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
|
|
62
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
63
|
+
closeDb();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
closeDb();
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('creates a principal carrying the derived grants', async () => {
|
|
72
|
+
const result = await enrolControlPlanePrincipal({
|
|
73
|
+
name: 'celilo-web-console',
|
|
74
|
+
publicKey: PUBLIC_KEY,
|
|
75
|
+
});
|
|
76
|
+
expect(result.created).toBe(true);
|
|
77
|
+
expect(result.grants).toEqual(controlPlaneReadGrants());
|
|
78
|
+
|
|
79
|
+
const stored = await getPrincipalByName('celilo-web-console');
|
|
80
|
+
expect(stored?.publicKey).toBe(PUBLIC_KEY);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('is idempotent, because a redeploy presents the same name again', async () => {
|
|
84
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
85
|
+
const second = await enrolControlPlanePrincipal({
|
|
86
|
+
name: 'celilo-web-console',
|
|
87
|
+
publicKey: PUBLIC_KEY,
|
|
88
|
+
});
|
|
89
|
+
expect(second.created).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a new key for an existing name ROTATES rather than duplicating', async () => {
|
|
93
|
+
// What a module that regenerated its keypair needs. Refusing it would leave
|
|
94
|
+
// a console that cannot authenticate and no way to fix it from the module.
|
|
95
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
96
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: OTHER_KEY });
|
|
97
|
+
expect((await getPrincipalByName('celilo-web-console'))?.publicKey).toBe(OTHER_KEY);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a malformed public key is refused here, not at login', async () => {
|
|
101
|
+
// Otherwise it lands in authorized_keys and fails later with a message
|
|
102
|
+
// about SSH rather than about the thing that was wrong.
|
|
103
|
+
await expect(
|
|
104
|
+
enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: 'not-a-key' }),
|
|
105
|
+
).rejects.toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('what the enrolled principal may actually do', () => {
|
|
110
|
+
let dir: string;
|
|
111
|
+
|
|
112
|
+
beforeEach(async () => {
|
|
113
|
+
// The service reaches for the global `getDb()`, so isolation is by
|
|
114
|
+
// CELILO_DB_PATH rather than by passing a client around.
|
|
115
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
|
|
116
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
117
|
+
closeDb();
|
|
118
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
closeDb();
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('may run the console reads it exists for', async () => {
|
|
127
|
+
expect(await isAuthorized('celilo-web-console', 'console', 'status')).toBe(true);
|
|
128
|
+
expect(await isAuthorized('celilo-web-console', 'console', 'get')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('may run the other reads the console polls', async () => {
|
|
132
|
+
// `alerts:list`, not bare `alerts`. The API authorises command:subcommand,
|
|
133
|
+
// so a caller that omits the subcommand is refused — which is what the
|
|
134
|
+
// console server did until this test caught it.
|
|
135
|
+
expect(await isAuthorized('celilo-web-console', 'alerts', 'list')).toBe(true);
|
|
136
|
+
expect(await isAuthorized('celilo-web-console', 'alerts')).toBe(false);
|
|
137
|
+
expect(await isAuthorized('celilo-web-console', 'backup', 'list')).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('may NOT deploy a module', async () => {
|
|
141
|
+
// The whole point. Everything above is convenience; this is the boundary.
|
|
142
|
+
expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('may NOT uninstall, restore, or grant', async () => {
|
|
146
|
+
expect(await isAuthorized('celilo-web-console', 'module', 'remove')).toBe(false);
|
|
147
|
+
expect(await isAuthorized('celilo-web-console', 'backup', 'restore')).toBe(false);
|
|
148
|
+
expect(await isAuthorized('celilo-web-console', 'api', 'grant')).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('revocation', () => {
|
|
153
|
+
let dir: string;
|
|
154
|
+
|
|
155
|
+
beforeEach(async () => {
|
|
156
|
+
// The service reaches for the global `getDb()`, so isolation is by
|
|
157
|
+
// CELILO_DB_PATH rather than by passing a client around.
|
|
158
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
|
|
159
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
160
|
+
closeDb();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
closeDb();
|
|
165
|
+
rmSync(dir, { recursive: true, force: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('removes the principal, so an uninstalled console leaves no working key', async () => {
|
|
169
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
170
|
+
expect(await revokeControlPlanePrincipal('celilo-web-console')).toBe(true);
|
|
171
|
+
expect(await getPrincipalByName('celilo-web-console')).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('revoking one that is already gone is false, not a throw', async () => {
|
|
175
|
+
// An uninstall that fails on tidy-up is worse than one finding nothing to
|
|
176
|
+
// tidy, and a hook that throws here leaves the module half-removed.
|
|
177
|
+
expect(await revokeControlPlanePrincipal('never-existed')).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enrol a module's read-only principal on the remote API.
|
|
3
|
+
*
|
|
4
|
+
* Named for the MECHANISM, not for the capability it backs. A core service
|
|
5
|
+
* named after one capability is core doing that provider's work, which is the
|
|
6
|
+
* pattern `no-module-business-in-core` Scan C exists to stop — and it caught
|
|
7
|
+
* this file under its first name. Enrolling an API principal is celilo's own
|
|
8
|
+
* job: the row lands in celilo's `api_principals` table and the grants come
|
|
9
|
+
* from celilo's command registry. `control_plane_api` is one caller of it.
|
|
10
|
+
*
|
|
11
|
+
* The consuming module generates an ed25519 keypair on its own system and
|
|
12
|
+
* presents the public half here. The private half never crosses this boundary,
|
|
13
|
+
* which is what makes "the console holds no credential celilo issued it"
|
|
14
|
+
* checkable rather than merely intended.
|
|
15
|
+
*
|
|
16
|
+
* ## The grant set is derived, never passed in
|
|
17
|
+
*
|
|
18
|
+
* `readOnlyGrants(COMMANDS)` is the same classifier that decides whether a CLI
|
|
19
|
+
* leaf is a read. Two consequences, both wanted:
|
|
20
|
+
*
|
|
21
|
+
* A new read verb is picked up WITHOUT anyone editing a list — but only at the
|
|
22
|
+
* next enrolment. The grants are MATERIALISED into the principal row here, so a
|
|
23
|
+
* celilo-mgr that is upgraded without the consumer being redeployed keeps
|
|
24
|
+
* serving the grant set that existed when the consumer last installed. Measured:
|
|
25
|
+
* 27 literal ops in the row, and a read verb celilo gains afterwards is denied.
|
|
26
|
+
*
|
|
27
|
+
* That is a limitation rather than a hole. It fails CLOSED, and it heals on the
|
|
28
|
+
* next redeploy. Making it dynamic means a `ro:*` token resolved at check time,
|
|
29
|
+
* which is a change to the authorization core and a real trade — a principal's
|
|
30
|
+
* authority would then move whenever celilo reclassifies a verb, with nobody
|
|
31
|
+
* re-granting anything. celilo#1153 carries that decision.
|
|
32
|
+
*
|
|
33
|
+
* A new WRITE verb is never granted, however it is named, because the
|
|
34
|
+
* classifier reads the verb token rather than a list somebody maintains. A
|
|
35
|
+
* hand-written list would drift in exactly one direction — wider — because
|
|
36
|
+
* widening it is what unblocks whoever is stuck.
|
|
37
|
+
*
|
|
38
|
+
* There is deliberately no `grants` parameter. The widest thing this can issue
|
|
39
|
+
* is the widest thing it should ever issue, and an argument would turn that
|
|
40
|
+
* into a negotiation with the caller.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { COMMANDS, readOnlyGrants } from '@celilo/core';
|
|
44
|
+
import { grantPrincipal, revokePrincipal } from './api-access';
|
|
45
|
+
|
|
46
|
+
export interface EnrolResult {
|
|
47
|
+
name: string;
|
|
48
|
+
grants: string[];
|
|
49
|
+
created: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every read-only op, sorted.
|
|
54
|
+
*
|
|
55
|
+
* Computed on each call rather than cached at module load. It is derived from a
|
|
56
|
+
* static registry so the answer does not change within a process, but a cached
|
|
57
|
+
* copy is one more thing that can be stale in a long-lived server and the
|
|
58
|
+
* computation is a filter over a few hundred leaves.
|
|
59
|
+
*/
|
|
60
|
+
export function controlPlaneReadGrants(): string[] {
|
|
61
|
+
return readOnlyGrants(COMMANDS);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Enrol a consuming module's principal. Idempotent on `name`.
|
|
66
|
+
*
|
|
67
|
+
* Re-presenting the same name and key is a no-op reporting `created: false`,
|
|
68
|
+
* which is what a redeploy does. Presenting a NEW key for an existing name
|
|
69
|
+
* rotates it — the case a module that regenerated its keypair needs, and one a
|
|
70
|
+
* module that did not will never hit.
|
|
71
|
+
*
|
|
72
|
+
* `grantPrincipal` validates the name and the key, so a malformed public key is
|
|
73
|
+
* refused here rather than written into `authorized_keys` and failing at login
|
|
74
|
+
* with a message about the wrong thing.
|
|
75
|
+
*/
|
|
76
|
+
export async function enrolControlPlanePrincipal(params: {
|
|
77
|
+
name: string;
|
|
78
|
+
publicKey: string;
|
|
79
|
+
}): Promise<EnrolResult> {
|
|
80
|
+
const grants = controlPlaneReadGrants();
|
|
81
|
+
const { principal, created } = await grantPrincipal({
|
|
82
|
+
name: params.name,
|
|
83
|
+
publicKey: params.publicKey,
|
|
84
|
+
grants,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// `grantPrincipal` already reports false for a principal that existed, and a
|
|
88
|
+
// re-grant that rotates a key is correctly not-a-creation. Re-deriving that
|
|
89
|
+
// here with a second lookup would be two sources for one answer.
|
|
90
|
+
return { name: principal.name, grants, created };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Remove a principal. Idempotent.
|
|
95
|
+
*
|
|
96
|
+
* Called from a consumer's `on_uninstall` so an uninstalled module does not
|
|
97
|
+
* leave a working key behind. Revoking one that is already gone returns false
|
|
98
|
+
* rather than throwing: an uninstall that fails on tidy-up is worse than one
|
|
99
|
+
* that finds nothing to tidy.
|
|
100
|
+
*/
|
|
101
|
+
export async function revokeControlPlanePrincipal(name: string): Promise<boolean> {
|
|
102
|
+
return revokePrincipal(name);
|
|
103
|
+
}
|
|
@@ -65,7 +65,16 @@ export function backupStaleThresholdMs(cadence: Cadence): number | null {
|
|
|
65
65
|
return interval + Math.max(HOUR, interval * 0.1);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Whether celilo can back this module up at all.
|
|
70
|
+
*
|
|
71
|
+
* Exported because it is the gate `backup-create.ts` actually applies, and the
|
|
72
|
+
* console has to draw the same distinction. On the live fleet five of 23
|
|
73
|
+
* deployed modules declare the hook: the other eighteen are not overdue and not
|
|
74
|
+
* failing, there is simply nothing to run (celilo#1131). A second copy of this
|
|
75
|
+
* test elsewhere would eventually disagree about which eighteen.
|
|
76
|
+
*/
|
|
77
|
+
export function moduleHasBackupHook(manifest: ModuleManifest): boolean {
|
|
69
78
|
return Boolean(manifest.hooks?.on_backup);
|
|
70
79
|
}
|
|
71
80
|
|