@celilo/cli 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +21 -2
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +24 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +130 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +6 -0
- package/src/cli/index.ts +31 -1
- package/src/console/closure.test.ts +322 -0
- package/src/console/closure.ts +294 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +293 -0
- package/src/console/projection.ts +364 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +67 -10
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/schema.ts +35 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +126 -4
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/module/web-root.ts +35 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +26 -2
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +32 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +252 -0
- package/src/services/api-principal-enrolment.ts +158 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-create.ts +33 -7
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
- package/src/services/restore-from-file.ts +6 -5
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,158 @@
|
|
|
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 type { ControlPlaneApiCapability } from '@celilo/capabilities';
|
|
44
|
+
import { COMMANDS, readOnlyGrants } from '@celilo/core';
|
|
45
|
+
import { grantPrincipal, revokePrincipal } from './api-access';
|
|
46
|
+
|
|
47
|
+
export interface EnrolResult {
|
|
48
|
+
name: string;
|
|
49
|
+
grants: string[];
|
|
50
|
+
created: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every read-only op, sorted.
|
|
55
|
+
*
|
|
56
|
+
* Computed on each call rather than cached at module load. It is derived from a
|
|
57
|
+
* static registry so the answer does not change within a process, but a cached
|
|
58
|
+
* copy is one more thing that can be stale in a long-lived server and the
|
|
59
|
+
* computation is a filter over a few hundred leaves.
|
|
60
|
+
*/
|
|
61
|
+
export function controlPlaneReadGrants(): string[] {
|
|
62
|
+
return readOnlyGrants(COMMANDS);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Enrol a consuming module's principal. Idempotent on `name`.
|
|
67
|
+
*
|
|
68
|
+
* Re-presenting the same name and key is a no-op reporting `created: false`,
|
|
69
|
+
* which is what a redeploy does. Presenting a NEW key for an existing name
|
|
70
|
+
* rotates it — the case a module that regenerated its keypair needs, and one a
|
|
71
|
+
* module that did not will never hit.
|
|
72
|
+
*
|
|
73
|
+
* `grantPrincipal` validates the name and the key, so a malformed public key is
|
|
74
|
+
* refused here rather than written into `authorized_keys` and failing at login
|
|
75
|
+
* with a message about the wrong thing.
|
|
76
|
+
*/
|
|
77
|
+
export async function enrolControlPlanePrincipal(params: {
|
|
78
|
+
name: string;
|
|
79
|
+
publicKey: string;
|
|
80
|
+
}): Promise<EnrolResult> {
|
|
81
|
+
const grants = controlPlaneReadGrants();
|
|
82
|
+
const { principal, created } = await grantPrincipal({
|
|
83
|
+
name: params.name,
|
|
84
|
+
publicKey: params.publicKey,
|
|
85
|
+
grants,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// `grantPrincipal` already reports false for a principal that existed, and a
|
|
89
|
+
// re-grant that rotates a key is correctly not-a-creation. Re-deriving that
|
|
90
|
+
// here with a second lookup would be two sources for one answer.
|
|
91
|
+
return { name: principal.name, grants, created };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove a principal. Idempotent.
|
|
96
|
+
*
|
|
97
|
+
* Called from a consumer's `on_uninstall` so an uninstalled module does not
|
|
98
|
+
* leave a working key behind. Revoking one that is already gone returns false
|
|
99
|
+
* rather than throwing: an uninstall that fails on tidy-up is worse than one
|
|
100
|
+
* that finds nothing to tidy.
|
|
101
|
+
*/
|
|
102
|
+
export async function revokeControlPlanePrincipal(name: string): Promise<boolean> {
|
|
103
|
+
return revokePrincipal(name);
|
|
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
|
+
}
|
|
@@ -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
|
|
|
@@ -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/)
|
|
@@ -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
|
/**
|
|
@@ -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', () => {
|
|
@@ -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)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* celilo's fleet SSH keypair, minted by the framework rather than by
|
|
3
|
+
* celilo-mgmt's on_install (design D9b of
|
|
4
|
+
* openspec/changes/hook-process-boundary).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { ensureFleetKey, getFleetSshDir } from './fleet-key';
|
|
12
|
+
|
|
13
|
+
describe('ensureFleetKey', () => {
|
|
14
|
+
let dataDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
dataDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-key-'));
|
|
18
|
+
process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
23
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('mints an ed25519 keypair next to the DB and returns the public half', () => {
|
|
27
|
+
const key = ensureFleetKey();
|
|
28
|
+
|
|
29
|
+
expect(key.created).toBe(true);
|
|
30
|
+
expect(key.publicKey.startsWith('ssh-ed25519 ')).toBe(true);
|
|
31
|
+
expect(existsSync(join(dataDir, '.ssh', 'id_ed25519'))).toBe(true);
|
|
32
|
+
expect(readFileSync(join(dataDir, '.ssh', 'id_ed25519.pub'), 'utf-8').trim()).toBe(
|
|
33
|
+
key.publicKey,
|
|
34
|
+
);
|
|
35
|
+
expect(getFleetSshDir()).toBe(join(dataDir, '.ssh'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reuses an existing key instead of re-keying', () => {
|
|
39
|
+
const first = ensureFleetKey();
|
|
40
|
+
const second = ensureFleetKey();
|
|
41
|
+
|
|
42
|
+
// Re-keying would strand every machine whose authorized_keys holds the
|
|
43
|
+
// old public half, and a redeploy calls this every time.
|
|
44
|
+
expect(second.created).toBe(false);
|
|
45
|
+
expect(second.publicKey).toBe(first.publicKey);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* celilo's fleet SSH keypair — the key celilo authenticates to managed
|
|
3
|
+
* machines with. The DB carries only the public half (`ssh.public_key`);
|
|
4
|
+
* the private half lives on disk and never leaves the management box.
|
|
5
|
+
*
|
|
6
|
+
* It used to be minted by celilo-mgmt's `on_install`, which derived
|
|
7
|
+
* `dirname(config.db_path)` and wrote into celilo's data directory from
|
|
8
|
+
* inside a module hook. That is a write into the one directory the hook
|
|
9
|
+
* jail exists to keep out of the mount set
|
|
10
|
+
* (openspec/changes/hook-process-boundary, design D9b): staging covers
|
|
11
|
+
* copies OUT of celilo's state, and nothing covers writes IN.
|
|
12
|
+
*
|
|
13
|
+
* So minting moved here. celilo owns the key's lifecycle, and "create it
|
|
14
|
+
* if absent" wants to be idempotent and tested once rather than in each
|
|
15
|
+
* module that reaches for it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { getDbPath } from '../config/paths';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where the fleet keypair lives.
|
|
25
|
+
*
|
|
26
|
+
* Next to the DB, not under `getDataDir()`. Those are the same directory
|
|
27
|
+
* on a deb install (`CELILO_DATA_DIR=/var/celilo`, db at
|
|
28
|
+
* `/var/celilo/celilo.db`) and differ only when `CELILO_DB_PATH` points
|
|
29
|
+
* somewhere custom. Following the DB is what `on_install` did, so it is
|
|
30
|
+
* where every existing box's key already sits, and restore has followed
|
|
31
|
+
* the same rule since it was written (`applyStagedSystemFiles`).
|
|
32
|
+
*
|
|
33
|
+
* One exported helper rather than two hand-derived joins is the point:
|
|
34
|
+
* mint and restore can no longer drift to different directories.
|
|
35
|
+
*/
|
|
36
|
+
export function getFleetSshDir(): string {
|
|
37
|
+
return join(dirname(getDbPath()), '.ssh');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FleetKey {
|
|
41
|
+
/** The public half, as it goes into `ssh.public_key` and authorized_keys. */
|
|
42
|
+
publicKey: string;
|
|
43
|
+
/** True when this call minted the key; false when it was already there. */
|
|
44
|
+
created: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ensure the fleet keypair exists and return its public half.
|
|
49
|
+
*
|
|
50
|
+
* Idempotent: an existing key is reused, never regenerated. Re-keying
|
|
51
|
+
* would silently strand every machine whose authorized_keys holds the old
|
|
52
|
+
* public half, and a redeploy must not do that.
|
|
53
|
+
*
|
|
54
|
+
* Permissions are left to `mkdirSync`'s mode and to ssh-keygen, which
|
|
55
|
+
* `fchmod`s the private half to 0600 itself. An explicit chmod pass was
|
|
56
|
+
* written here first and then removed: umask only ever REMOVES mode bits, so
|
|
57
|
+
* neither the directory nor the key can come out wider than asked for, and no
|
|
58
|
+
* test could be made to fail without it.
|
|
59
|
+
*/
|
|
60
|
+
export function ensureFleetKey(): FleetKey {
|
|
61
|
+
const sshDir = getFleetSshDir();
|
|
62
|
+
const keyPath = join(sshDir, 'id_ed25519');
|
|
63
|
+
const publicKeyPath = `${keyPath}.pub`;
|
|
64
|
+
|
|
65
|
+
if (existsSync(publicKeyPath)) {
|
|
66
|
+
return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
70
|
+
execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath, '-C', 'celilo-fleet'], {
|
|
71
|
+
stdio: 'pipe',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: true };
|
|
75
|
+
}
|