@celilo/cli 1.13.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 +1 -1
- package/CELILO_SUBSYSTEMS.md +6 -2
- package/package.json +2 -2
- 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/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- 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 +1 -0
- package/src/cli/index.ts +6 -0
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +67 -10
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +14 -2
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +32 -0
- package/src/services/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +33 -7
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- 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
|
@@ -45,7 +45,8 @@ export interface ContractField {
|
|
|
45
45
|
* A field carrying a path-shaped value with no annotation here is a defect.
|
|
46
46
|
* `db_path` was one for months — passed by `backup-create.ts` and declared
|
|
47
47
|
* nowhere, so anything reasoning from this table was wrong about what a
|
|
48
|
-
* backup hook receives.
|
|
48
|
+
* backup hook receives. It is gone: design D9b replaced it with the staged
|
|
49
|
+
* `system_state_root` below, which is declared and annotated.
|
|
49
50
|
*/
|
|
50
51
|
path?: { access: PathAccess };
|
|
51
52
|
}
|
|
@@ -152,6 +153,26 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
152
153
|
* terraform.tfstate.backup
|
|
153
154
|
*/
|
|
154
155
|
cross_module_root: { required: false, path: { access: 'read' } },
|
|
156
|
+
/**
|
|
157
|
+
* Path to a directory holding copies of celilo's OWN state: a
|
|
158
|
+
* WAL-correct `celilo.db` snapshot, `master.key`, the fleet `ssh/`
|
|
159
|
+
* keypair, and `module_src/<id>/` for every module's lean source.
|
|
160
|
+
* Populated by the framework under the same `cross_module_read`
|
|
161
|
+
* allow-list as `cross_module_root`; only celilo-mgmt receives it.
|
|
162
|
+
*
|
|
163
|
+
* This input is what makes celilo's own backup possible WITHOUT
|
|
164
|
+
* exempting celilo-mgmt from the hook jail (design D9b). The hook
|
|
165
|
+
* never read those bytes — it copied them into `backup_dir` — so the
|
|
166
|
+
* framework copies them into a directory it creates and hands over,
|
|
167
|
+
* and the data directory itself stays out of every mount set.
|
|
168
|
+
*
|
|
169
|
+
* It replaces `db_path`, which `backup-create.ts` passed for months
|
|
170
|
+
* while the contract declared nothing about it. That is the omission
|
|
171
|
+
* the docblock on `ContractField.path` names: anything reasoning from
|
|
172
|
+
* this table was wrong about what a backup hook receives, and a
|
|
173
|
+
* mount-set derivation walking the declared inputs could not see it.
|
|
174
|
+
*/
|
|
175
|
+
system_state_root: { required: false, path: { access: 'read' } },
|
|
155
176
|
},
|
|
156
177
|
outputs: {
|
|
157
178
|
artifact_count: { required: true },
|
package/src/manifest/validate.ts
CHANGED
|
@@ -202,14 +202,35 @@ const PRIVILEGED_CAPABILITY_ALLOW_LIST: Record<string, readonly string[]> = {
|
|
|
202
202
|
cross_module_read: ['celilo-mgmt'],
|
|
203
203
|
};
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Framework-granted capabilities that any module may declare.
|
|
207
|
+
*
|
|
208
|
+
* Same satisfaction path as the allow-list above — celilo supplies them, so no
|
|
209
|
+
* module provides them and the resolver must not go looking for one — but a
|
|
210
|
+
* different authorization story, and the two were welded together while
|
|
211
|
+
* `cross_module_read` was the only entry.
|
|
212
|
+
*
|
|
213
|
+
* `cross_module_read` hands a module every OTHER module's terraform state, so
|
|
214
|
+
* who may hold it is a per-module trust decision and the list is the gate.
|
|
215
|
+
* `control_plane_api` mints a principal whose grants are derived from
|
|
216
|
+
* `readOnlyGrants(COMMANDS)` and cannot be widened by the caller, so the worst
|
|
217
|
+
* a wrongly-declared consumer obtains is celilo's own read verbs. The gate is
|
|
218
|
+
* the `requires` line, which a reviewer reads before the module is ever
|
|
219
|
+
* imported (web-ui-console D7b).
|
|
220
|
+
*
|
|
221
|
+
* An allow-list here was considered and rejected: it would put consumer module
|
|
222
|
+
* ids in core, so every new console-shaped consumer would need a core change
|
|
223
|
+
* and a `.deb` release before it could be imported at all.
|
|
224
|
+
*/
|
|
225
|
+
const FRAMEWORK_GRANTED_CAPABILITIES: ReadonlySet<string> = new Set(['control_plane_api']);
|
|
226
|
+
|
|
205
227
|
/**
|
|
206
228
|
* Whether `name` is a framework-granted privilege rather than a normal
|
|
207
|
-
* provider-backed capability. Privileges are satisfied by the framework
|
|
208
|
-
*
|
|
209
|
-
* must NOT expect a module to "provide" them.
|
|
229
|
+
* provider-backed capability. Privileges are satisfied by the framework, so
|
|
230
|
+
* the capability-provider resolver must NOT expect a module to "provide" them.
|
|
210
231
|
*/
|
|
211
232
|
export function isPrivilegedCapability(name: string): boolean {
|
|
212
|
-
return name in PRIVILEGED_CAPABILITY_ALLOW_LIST;
|
|
233
|
+
return name in PRIVILEGED_CAPABILITY_ALLOW_LIST || FRAMEWORK_GRANTED_CAPABILITIES.has(name);
|
|
213
234
|
}
|
|
214
235
|
|
|
215
236
|
/**
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a module's built web site lives on this host.
|
|
3
|
+
*
|
|
4
|
+
* ONE application of the `site/dist` convention. It is deliberately not applied
|
|
5
|
+
* inside the providers: `public_web` is framework-provided, but `private_web`
|
|
6
|
+
* and `external_web` are implemented by modules (`caddy-internal`,
|
|
7
|
+
* `generic-cpanel-hosting-provider`), and three copies of one convention is how
|
|
8
|
+
* the copies drift.
|
|
9
|
+
*
|
|
10
|
+
* Core is also the only party that CAN resolve it in the case that matters.
|
|
11
|
+
* When the `public_web` provider converges a rebuilt host, no consumer is
|
|
12
|
+
* running to be asked where its bytes are (design D10). So the path has to come
|
|
13
|
+
* from the module id, and `modules.source_path` is what core already holds.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { MODULE_WEB_ROOT } from '@celilo/capabilities';
|
|
18
|
+
import { eq } from 'drizzle-orm';
|
|
19
|
+
import type { DbClient } from '../db/client';
|
|
20
|
+
import { modules } from '../db/schema';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Absolute path to `<module root>/site/dist`, or undefined when no such module
|
|
24
|
+
* is installed.
|
|
25
|
+
*
|
|
26
|
+
* Does NOT check that the directory exists. Resolving a path and deciding
|
|
27
|
+
* whether it is usable are different jobs, and the second one belongs where the
|
|
28
|
+
* error can name the caller (Rule 10.1). `createPublicWeb`'s `requireWebRoot`
|
|
29
|
+
* does the existence check and says what to ship.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | undefined {
|
|
32
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
33
|
+
if (!module) return undefined;
|
|
34
|
+
return join(module.sourcePath, MODULE_WEB_ROOT);
|
|
35
|
+
}
|
|
@@ -255,6 +255,18 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
255
255
|
count: 1,
|
|
256
256
|
why: "PERMANENT — validating a manifest's declared capability names against the registry is core's job",
|
|
257
257
|
},
|
|
258
|
+
{
|
|
259
|
+
file: 'apps/celilo/src/manifest/validate.ts',
|
|
260
|
+
capability: 'control_plane_api',
|
|
261
|
+
count: 1,
|
|
262
|
+
why: 'PERMANENT — framework-granted, so celilo satisfies it and no module provides it; the resolver has to be told that by name (web-ui-console D7b)',
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
file: 'apps/celilo/src/hooks/capability-loader.ts',
|
|
266
|
+
capability: 'control_plane_api',
|
|
267
|
+
count: 4,
|
|
268
|
+
why: "PERMANENT — framework-granted like web_routes above; core builds the method table because enrolment writes celilo's own api_principals row, which no module script can reach (web-ui-console D7b)",
|
|
269
|
+
},
|
|
258
270
|
{
|
|
259
271
|
file: 'apps/celilo/src/services/alerting/inbound-poller.ts',
|
|
260
272
|
capability: 'notification',
|
|
@@ -420,8 +432,8 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
420
432
|
{
|
|
421
433
|
file: 'packages/capabilities/src/firewall.ts',
|
|
422
434
|
capability: 'firewall',
|
|
423
|
-
count:
|
|
424
|
-
why: 'PERMANENT — X10, the firewall capability contract',
|
|
435
|
+
count: 3,
|
|
436
|
+
why: 'PERMANENT — X10, the firewall capability contract. The third is FIREWALL_CAPABILITY_NAME, exported so core queries the provider registry without spelling the literal; it moved a name OUT of apps/celilo/src/console/projection.ts rather than adding one',
|
|
425
437
|
},
|
|
426
438
|
{
|
|
427
439
|
file: 'packages/capabilities/src/public-web.ts',
|
|
@@ -38,6 +38,28 @@ describe('module script scan — SSH rules', () => {
|
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
describe('module script scan — namespace tools (hygiene, not a boundary)', () => {
|
|
42
|
+
const NS = 'namespace escape (bwrap/unshare/nsenter)';
|
|
43
|
+
|
|
44
|
+
// Each token asserted separately and by name. An alternation that silently
|
|
45
|
+
// loses one leg still passes a test that only ever exercises the first.
|
|
46
|
+
it.each([
|
|
47
|
+
['bwrap', 'run(`bwrap --dev-bind / / ${cmd}`);'],
|
|
48
|
+
['unshare', "run('unshare --user --map-root-user id');"],
|
|
49
|
+
['nsenter', "run('nsenter -t 1 -m -- ls /');"],
|
|
50
|
+
['CLONE_NEWUSER', 'const flags = CLONE_NEWUSER | CLONE_NEWNS;'],
|
|
51
|
+
])('catches %s', (_token, src) => {
|
|
52
|
+
expect(rules(src)).toContain(NS);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The word boundaries are the whole of this rule's false-positive defence, so
|
|
56
|
+
// the negative case has to be a word that CONTAINS a token. `buildSharedConfig`
|
|
57
|
+
// would pass whether or not the `\b`s are there, which makes it no test at all.
|
|
58
|
+
it('does not fire on an identifier that merely contains a token', () => {
|
|
59
|
+
expect(rules('const unshared = pending.filter((p) => !p.shared);')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
41
63
|
describe('module script scan — raw-exec escape hatch', () => {
|
|
42
64
|
it('flags a runAppCommand call with no justification', () => {
|
|
43
65
|
expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain(
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* **modules never hand-build SSH, and the one sanctioned raw-exec path is
|
|
4
4
|
* always justified in writing.**
|
|
5
5
|
*
|
|
6
|
+
* It carries a second, weaker rule class as well — the namespace-tool rule
|
|
7
|
+
* below. Read that rule's own comment before touching it: it is hygiene and it
|
|
8
|
+
* is **not** a boundary, and the two classes in this file are not the same kind
|
|
9
|
+
* of thing.
|
|
10
|
+
*
|
|
6
11
|
* ONE definition of the rules, used by both enforcement points:
|
|
7
12
|
*
|
|
8
13
|
* - `apps/celilo/src/policy/no-hand-built-ssh.test.ts` — every in-repo module,
|
|
@@ -63,6 +68,33 @@ const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
|
|
|
63
68
|
re: /(?:from|require\()\s*['"]ssh2['"]/,
|
|
64
69
|
hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
|
|
65
70
|
},
|
|
71
|
+
/**
|
|
72
|
+
* HYGIENE, NOT A MITIGATION (openspec/changes/hook-process-boundary, task 4.2e,
|
|
73
|
+
* design D9). The boundary is the jail; this line is a courtesy to an honest
|
|
74
|
+
* author, and describing it as a security control would be a lie in three
|
|
75
|
+
* separate ways:
|
|
76
|
+
*
|
|
77
|
+
* - a hook runs arbitrary code, so anything this pattern catches can be
|
|
78
|
+
* spelled another way by anyone who wants to;
|
|
79
|
+
* - the scan walks in-repo `scripts/` and staged `.netapp` payloads, so it
|
|
80
|
+
* never sees a registry module or an out-of-repo one at all;
|
|
81
|
+
* - celilo#1014 is a live example of an exemption in this very file reading
|
|
82
|
+
* wider than its author intended.
|
|
83
|
+
*
|
|
84
|
+
* What it is for: a module that shells out to `bwrap`/`unshare`/`nsenter`
|
|
85
|
+
* gets a namespace it is uid 0 in, and under the jail that either fails or
|
|
86
|
+
* nests badly. Telling the author at package time beats a confusing runtime
|
|
87
|
+
* failure on the fleet. Task 4.2d is the related-but-different guarantee, and
|
|
88
|
+
* it IS load-bearing: `bwrap` is never in the derived mount set.
|
|
89
|
+
*/
|
|
90
|
+
{
|
|
91
|
+
rule: 'namespace escape (bwrap/unshare/nsenter)',
|
|
92
|
+
re: /\b(?:bwrap|unshare|nsenter)\b|CLONE_NEWUSER/,
|
|
93
|
+
hint:
|
|
94
|
+
'Hooks already run inside a namespace celilo builds; a module creating its own is ' +
|
|
95
|
+
'not supported and will not survive the jail. This is a hygiene check, not a security ' +
|
|
96
|
+
'boundary. If you need isolation the framework does not give you, say what for.',
|
|
97
|
+
},
|
|
66
98
|
];
|
|
67
99
|
|
|
68
100
|
/** A `runAppCommand(` / `runAppCommandWithSecret(` CALL — not the import. */
|
|
@@ -18,6 +18,7 @@ import { COMMANDS, readOnlyGrants } from '@celilo/core';
|
|
|
18
18
|
import { closeDb } from '../db/client';
|
|
19
19
|
import { getPrincipalByName, isAuthorized } from './api-access';
|
|
20
20
|
import {
|
|
21
|
+
buildControlPlaneApi,
|
|
21
22
|
controlPlaneReadGrants,
|
|
22
23
|
enrolControlPlanePrincipal,
|
|
23
24
|
revokeControlPlanePrincipal,
|
|
@@ -177,3 +178,75 @@ describe('revocation', () => {
|
|
|
177
178
|
expect(await revokeControlPlanePrincipal('never-existed')).toBe(false);
|
|
178
179
|
});
|
|
179
180
|
});
|
|
181
|
+
|
|
182
|
+
describe('the capability handed to a consuming module', () => {
|
|
183
|
+
let dir: string;
|
|
184
|
+
|
|
185
|
+
beforeEach(async () => {
|
|
186
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
|
|
187
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
188
|
+
closeDb();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
closeDb();
|
|
193
|
+
rmSync(dir, { recursive: true, force: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('enrols the caller and reports the grants it may use', async () => {
|
|
197
|
+
const api = buildControlPlaneApi('celilo-web-console');
|
|
198
|
+
const result = await api.enrol_principal({
|
|
199
|
+
name: 'celilo-web-console',
|
|
200
|
+
publicKey: PUBLIC_KEY,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result.created).toBe(true);
|
|
204
|
+
expect(result.grants).toEqual(controlPlaneReadGrants());
|
|
205
|
+
expect(await getPrincipalByName('celilo-web-console')).not.toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('a module may NOT enrol under a neighbour’s name', async () => {
|
|
209
|
+
// Otherwise any module holding this capability rotates the console's key by
|
|
210
|
+
// presenting its own public half under the console's name — the console then
|
|
211
|
+
// holds a principal whose authorized_keys entry it cannot log in with, and
|
|
212
|
+
// the attacker holds one that works.
|
|
213
|
+
const api = buildControlPlaneApi('hello-foo');
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }),
|
|
217
|
+
).rejects.toThrow(/only enrol its own principal/);
|
|
218
|
+
expect(await getPrincipalByName('celilo-web-console')).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a module may NOT revoke a neighbour’s principal', async () => {
|
|
222
|
+
// The denial-of-service half of the same hole: revocation is idempotent and
|
|
223
|
+
// returns false for a name that never existed, so an unguarded revoke gives
|
|
224
|
+
// no signal at all that a module just deleted someone else's access.
|
|
225
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
226
|
+
const api = buildControlPlaneApi('hello-foo');
|
|
227
|
+
|
|
228
|
+
await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
|
|
229
|
+
/only revoke its own principal/,
|
|
230
|
+
);
|
|
231
|
+
expect(await getPrincipalByName('celilo-web-console')).not.toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('withdraws its own principal, and finding nothing is not an error', async () => {
|
|
235
|
+
const api = buildControlPlaneApi('celilo-web-console');
|
|
236
|
+
await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
237
|
+
|
|
238
|
+
expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: true });
|
|
239
|
+
expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: false });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('what it enrols cannot write, whatever the caller asked for', async () => {
|
|
243
|
+
// The grant set is not a parameter, so there is nothing to ask for. This
|
|
244
|
+
// asserts the consequence rather than the absence: the principal the
|
|
245
|
+
// capability produces is refused a deploy.
|
|
246
|
+
const api = buildControlPlaneApi('celilo-web-console');
|
|
247
|
+
await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
248
|
+
|
|
249
|
+
expect(await isAuthorized('celilo-web-console', 'module', 'list')).toBe(true);
|
|
250
|
+
expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* into a negotiation with the caller.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
+
import type { ControlPlaneApiCapability } from '@celilo/capabilities';
|
|
43
44
|
import { COMMANDS, readOnlyGrants } from '@celilo/core';
|
|
44
45
|
import { grantPrincipal, revokePrincipal } from './api-access';
|
|
45
46
|
|
|
@@ -101,3 +102,57 @@ export async function enrolControlPlanePrincipal(params: {
|
|
|
101
102
|
export async function revokeControlPlanePrincipal(name: string): Promise<boolean> {
|
|
102
103
|
return revokePrincipal(name);
|
|
103
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The `control_plane_api` method table handed to one consuming module.
|
|
108
|
+
*
|
|
109
|
+
* ## Why celilo builds this and no module provides it
|
|
110
|
+
*
|
|
111
|
+
* Every other function capability is implemented by a provider module's script,
|
|
112
|
+
* which the loader imports and calls. That is structurally impossible here.
|
|
113
|
+
* Enrolment writes celilo's own `api_principals` row and derives its grants from
|
|
114
|
+
* celilo's own command registry, and a module script may import nothing but
|
|
115
|
+
* `@celilo/capabilities` — its scripts are copied to a data directory at deploy,
|
|
116
|
+
* so a relative import into the backend breaks. celilo-mgmt has exactly the same
|
|
117
|
+
* limit as any other module here; being the management server's module does not
|
|
118
|
+
* give its SCRIPTS a database handle.
|
|
119
|
+
*
|
|
120
|
+
* The only shape that would let celilo-mgmt "provide" it is core injecting
|
|
121
|
+
* enrol/revoke into a factory context so the module's script forwards them
|
|
122
|
+
* unchanged. Core would still do all the work, and the enrolment would newly
|
|
123
|
+
* depend on the deployed copy of that script being current — a way to fail that
|
|
124
|
+
* does not exist today. So this is framework-granted, like `cross_module_read`.
|
|
125
|
+
*
|
|
126
|
+
* ## Why the caller cannot name someone else's principal
|
|
127
|
+
*
|
|
128
|
+
* `name` is on the request because the contract puts it there, and the only
|
|
129
|
+
* value accepted is the calling module's id. Without that check any module with
|
|
130
|
+
* the capability could rotate another module's key by enrolling under its name,
|
|
131
|
+
* or delete it outright by revoking it — the console's read access removed by a
|
|
132
|
+
* module that has nothing to do with the console.
|
|
133
|
+
*
|
|
134
|
+
* The parameter should come off the contract entirely, the same argument that
|
|
135
|
+
* kept `grants` off it: a caller that can negotiate WHOSE authority this is has
|
|
136
|
+
* the same problem as one that can negotiate how much. That is a breaking change
|
|
137
|
+
* to `@celilo/capabilities`' exported types, so it waits for the next major
|
|
138
|
+
* rather than forcing one (celilo#1196).
|
|
139
|
+
*/
|
|
140
|
+
export function buildControlPlaneApi(consumerModuleId: string): ControlPlaneApiCapability {
|
|
141
|
+
function requireOwnPrincipal(name: string, verb: string): void {
|
|
142
|
+
if (name === consumerModuleId) return;
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Module '${consumerModuleId}' tried to ${verb} the API principal '${name}'. A module may only ${verb} its own principal, named for its module id.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
async enrol_principal(request) {
|
|
150
|
+
requireOwnPrincipal(request.name, 'enrol');
|
|
151
|
+
return enrolControlPlanePrincipal(request);
|
|
152
|
+
},
|
|
153
|
+
async revoke_principal(request) {
|
|
154
|
+
requireOwnPrincipal(request.name, 'revoke');
|
|
155
|
+
return { revoked: await revokeControlPlanePrincipal(request.name) };
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { eq } from 'drizzle-orm';
|
|
9
|
-
import { getDbPath } from '../config/paths';
|
|
9
|
+
import { getDbPath, getMasterKeyPath } from '../config/paths';
|
|
10
10
|
import { getDb } from '../db/client';
|
|
11
11
|
import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
|
|
12
12
|
import { invokeHook } from '../hooks/executor';
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
refuseIfInFlight,
|
|
42
42
|
startOperation,
|
|
43
43
|
} from './module-operations';
|
|
44
|
+
import { stageSystemState } from './system-state-stage';
|
|
44
45
|
|
|
45
46
|
export interface BackupCreateOptions {
|
|
46
47
|
storageId?: string;
|
|
@@ -345,19 +346,44 @@ export async function createModuleBackup(
|
|
|
345
346
|
const { configMap, secretMap } = await buildModuleContext(moduleId);
|
|
346
347
|
const logger = createConsoleLogger(moduleId, 'on_backup');
|
|
347
348
|
|
|
348
|
-
// Inject the framework-resolved DB path so the hook need not re-derive the
|
|
349
|
-
// data dir from an env var the CLI may not export (ISS-0014). getDbPath()
|
|
350
|
-
// is celilo's single source of truth for where the DB lives, regardless of
|
|
351
|
-
// CELILO_DATA_DIR / XDG / explicit override; the hook derives the data dir
|
|
352
|
-
// (master.key, fleet .ssh) as dirname(db_path) from this.
|
|
353
349
|
const hookInputs: Record<string, unknown> = {
|
|
354
350
|
backup_dir: dataDir,
|
|
355
|
-
db_path: getDbPath(),
|
|
356
351
|
};
|
|
352
|
+
// Both privileged inputs ride the same allow-list. `cross_module_root`
|
|
353
|
+
// mirrors OTHER modules' terraform state; `system_state_root` stages
|
|
354
|
+
// celilo's own — the DB snapshot, master.key, the fleet key, and every
|
|
355
|
+
// module's lean source.
|
|
356
|
+
//
|
|
357
|
+
// Staging is what lets celilo back ITSELF up without exempting
|
|
358
|
+
// celilo-mgmt from the hook jail (design D9b). The hook used to receive
|
|
359
|
+
// `db_path` and walk out from `dirname(db_path)` into celilo's data
|
|
360
|
+
// directory; it never read those bytes, it copied them, so the framework
|
|
361
|
+
// does the copying and the data directory stays out of the mount set.
|
|
357
362
|
if (moduleHasCrossModuleRead(manifest)) {
|
|
358
363
|
const crossModuleRoot = join(tempDir, 'cross-module-read');
|
|
359
364
|
materializeCrossModuleRoot(crossModuleRoot, moduleId);
|
|
360
365
|
hookInputs.cross_module_root = crossModuleRoot;
|
|
366
|
+
|
|
367
|
+
const staged = stageSystemState(join(tempDir, 'system-state'));
|
|
368
|
+
hookInputs.system_state_root = staged.root;
|
|
369
|
+
if (!staged.masterKeyStaged) {
|
|
370
|
+
logger.warn(
|
|
371
|
+
`master.key not found at ${getMasterKeyPath()} — secrets in the DB snapshot will be unreadable on restore.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (!staged.fleetSshStaged) {
|
|
375
|
+
logger.info(
|
|
376
|
+
'No fleet SSH keypair on this box — none staged (celilo-mgmt may not have been deployed yet).',
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
logger.info(`Staged celilo state: ${staged.moduleSourceCount} module source tree(s)`);
|
|
380
|
+
if (staged.skippedLarge.length > 0) {
|
|
381
|
+
// No silent caps: name what was dropped. These are build artifacts the
|
|
382
|
+
// target rebuilds on deploy.
|
|
383
|
+
logger.info(
|
|
384
|
+
`Skipped ${staged.skippedLarge.length} large/non-source file(s) (rebuilt on deploy): ${staged.skippedLarge.join(', ')}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
// Execute on_backup hook — it writes artifacts to dataDir (envelope/data/)
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Behavior tests for the celilo-mgmt module's on_backup / on_restore
|
|
3
|
-
* hooks (Phase 3 of openspec/specs/management-server-backup/spec.md).
|
|
4
|
-
*
|
|
5
|
-
* We import the hook scripts directly and call their handlers with a
|
|
6
|
-
* synthesized HookContext, then assert on the side-effects (files
|
|
7
|
-
* written to backup_dir / restore staging dirs). This catches
|
|
8
|
-
* regressions in the hook bodies without spinning up the full
|
|
9
|
-
* deploy/backup pipeline.
|
|
10
|
-
*
|
|
11
|
-
* What this DOESN'T test: invokeHook validation, end-to-end backup
|
|
12
|
-
* envelope round-trip. Those live in backup-envelope-roundtrip.test.ts
|
|
13
|
-
* and (eventually) a fresh-box e2e.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { Database } from 'bun:sqlite';
|
|
17
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
18
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
3
|
import { tmpdir } from 'node:os';
|
|
@@ -82,13 +66,13 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
82
66
|
let dir: string;
|
|
83
67
|
let backupDir: string;
|
|
84
68
|
let crossModuleRoot: string;
|
|
85
|
-
let
|
|
86
|
-
let keyPath: string;
|
|
69
|
+
let systemStateRoot: string;
|
|
87
70
|
|
|
88
71
|
beforeEach(() => {
|
|
89
72
|
dir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-backup-test-'));
|
|
90
73
|
backupDir = join(dir, 'backup-dir');
|
|
91
74
|
mkdirSync(backupDir, { recursive: true });
|
|
75
|
+
|
|
92
76
|
crossModuleRoot = join(dir, 'cross-module-root');
|
|
93
77
|
mkdirSync(join(crossModuleRoot, 'modules', 'caddy', 'terraform'), { recursive: true });
|
|
94
78
|
writeFileSync(
|
|
@@ -104,25 +88,20 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
104
88
|
}),
|
|
105
89
|
);
|
|
106
90
|
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
keyPath = join(dir, 'master.key');
|
|
119
|
-
writeFileSync(keyPath, 'fake-master-key-32-bytes-padding!');
|
|
120
|
-
process.env.CELILO_MASTER_KEY_PATH = keyPath;
|
|
91
|
+
// celilo's own state, as the framework stages it before invoking the hook
|
|
92
|
+
// (services/system-state-stage.ts). The hook reads nothing outside this
|
|
93
|
+
// directory and cross_module_root — that is what design D9b bought, and
|
|
94
|
+
// it is why there is no CELILO_DB_PATH or master.key on disk here.
|
|
95
|
+
systemStateRoot = join(dir, 'system-state');
|
|
96
|
+
mkdirSync(join(systemStateRoot, 'ssh'), { recursive: true });
|
|
97
|
+
mkdirSync(join(systemStateRoot, 'module_src', 'caddy'), { recursive: true });
|
|
98
|
+
writeFileSync(join(systemStateRoot, 'celilo.db'), 'SQLite format 3\u0000snapshot');
|
|
99
|
+
writeFileSync(join(systemStateRoot, 'master.key'), 'fake-master-key-32-bytes-padding!');
|
|
100
|
+
writeFileSync(join(systemStateRoot, 'ssh', 'id_ed25519'), 'PRIVATE');
|
|
101
|
+
writeFileSync(join(systemStateRoot, 'module_src', 'caddy', 'manifest.yml'), 'id: caddy');
|
|
121
102
|
});
|
|
122
103
|
|
|
123
104
|
afterEach(() => {
|
|
124
|
-
process.env.CELILO_DB_PATH = undefined;
|
|
125
|
-
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
126
105
|
try {
|
|
127
106
|
rmSync(dir, { recursive: true, force: true });
|
|
128
107
|
} catch {
|
|
@@ -130,16 +109,20 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
130
109
|
}
|
|
131
110
|
});
|
|
132
111
|
|
|
133
|
-
it('
|
|
112
|
+
it('lays the staged celilo state into backup_dir alongside the cross-module state', async () => {
|
|
134
113
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
114
|
+
const result = (await hook(
|
|
115
|
+
buildContext({
|
|
116
|
+
backup_dir: backupDir,
|
|
117
|
+
cross_module_root: crossModuleRoot,
|
|
118
|
+
system_state_root: systemStateRoot,
|
|
119
|
+
}),
|
|
120
|
+
)) as BackupHookOutput;
|
|
140
121
|
|
|
141
122
|
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
142
123
|
expect(existsSync(join(backupDir, 'master.key'))).toBe(true);
|
|
124
|
+
expect(existsSync(join(backupDir, 'ssh', 'id_ed25519'))).toBe(true);
|
|
125
|
+
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
|
|
143
126
|
expect(existsSync(join(backupDir, 'machine-pool.json'))).toBe(true);
|
|
144
127
|
expect(
|
|
145
128
|
existsSync(
|
|
@@ -153,39 +136,27 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
153
136
|
expect(result.size_bytes).toBeGreaterThan(0);
|
|
154
137
|
});
|
|
155
138
|
|
|
156
|
-
it('
|
|
157
|
-
// stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
|
|
158
|
-
const modSrc = join(dir, 'modules', 'caddy');
|
|
159
|
-
mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
|
|
160
|
-
mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
|
|
161
|
-
mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
|
|
162
|
-
writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
|
|
163
|
-
writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
|
|
164
|
-
writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
|
|
165
|
-
writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
|
|
166
|
-
// A >2MB "compiled binary" sitting in source — must be skipped by size.
|
|
167
|
-
writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
|
|
168
|
-
|
|
139
|
+
it('refuses when the framework did not stage celilo state', async () => {
|
|
169
140
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
170
|
-
await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
|
|
171
141
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
// not OOM the in-memory tar+encrypt — turnip's full dirs were ~1.6GB).
|
|
177
|
-
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
|
|
178
|
-
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'node_modules'))).toBe(
|
|
179
|
-
false,
|
|
180
|
-
);
|
|
142
|
+
// Silence is the dangerous outcome here. Without the DB and the master
|
|
143
|
+
// key an envelope restores to nothing, and a hook that shrugged and
|
|
144
|
+
// returned success would report artifact_count > 0 for the machine-pool
|
|
145
|
+
// and cross-module files alone. Found at restore, which is too late.
|
|
181
146
|
expect(
|
|
182
|
-
|
|
183
|
-
).
|
|
147
|
+
hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot })),
|
|
148
|
+
).rejects.toThrow(/system_state_root was not provided/);
|
|
184
149
|
});
|
|
185
150
|
|
|
186
151
|
it('machine-pool.json is valid JSON (array)', async () => {
|
|
187
152
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
188
|
-
await hook(
|
|
153
|
+
await hook(
|
|
154
|
+
buildContext({
|
|
155
|
+
backup_dir: backupDir,
|
|
156
|
+
cross_module_root: crossModuleRoot,
|
|
157
|
+
system_state_root: systemStateRoot,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
189
160
|
|
|
190
161
|
const machinePool = JSON.parse(readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'));
|
|
191
162
|
expect(Array.isArray(machinePool)).toBe(true);
|
|
@@ -194,25 +165,13 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
194
165
|
it('proceeds (with a warning) when cross_module_root is missing', async () => {
|
|
195
166
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
196
167
|
const result = (await hook(
|
|
197
|
-
buildContext({ backup_dir: backupDir }),
|
|
168
|
+
buildContext({ backup_dir: backupDir, system_state_root: systemStateRoot }),
|
|
198
169
|
)) as BackupHookOutput;
|
|
199
170
|
|
|
200
171
|
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
201
172
|
expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
|
|
202
173
|
expect(result.schema_version).toBe('1.1');
|
|
203
174
|
});
|
|
204
|
-
|
|
205
|
-
it('proceeds (with a warning) when master.key is missing on disk', async () => {
|
|
206
|
-
rmSync(keyPath);
|
|
207
|
-
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
208
|
-
const result = (await hook(
|
|
209
|
-
buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }),
|
|
210
|
-
)) as BackupHookOutput;
|
|
211
|
-
|
|
212
|
-
expect(existsSync(join(backupDir, 'master.key'))).toBe(false);
|
|
213
|
-
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
214
|
-
expect(result.artifact_count).toBeGreaterThan(0);
|
|
215
|
-
});
|
|
216
175
|
});
|
|
217
176
|
|
|
218
177
|
describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_restore', () => {
|