@celilo/cli 1.12.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/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/schema.ts +35 -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/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
|
@@ -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',
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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:
|
|
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)
|