@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
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Instance identity
|
|
2
|
+
* Instance identity and the on-disk shape of an instance
|
|
3
|
+
* (openspec/changes/submodules, D2 and D4).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
+
* Two jobs, both small and both load-bearing.
|
|
6
|
+
*
|
|
7
|
+
* **Identity.** A parent addresses its instances by an opaque key it chose.
|
|
5
8
|
* celilo addresses them by a `modules.id`, because an instance IS a module row,
|
|
6
9
|
* and that is what lets every reader keyed on `moduleId` keep working. This
|
|
7
10
|
* file is the single place those two names meet, so the mapping cannot drift.
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* **Layout.** An instance directory is real and private, holding only
|
|
13
|
+
* `generated/`, with the submodule's authored subtrees symlinked in. That keeps
|
|
14
|
+
* `join(sourcePath, 'generated')` true (the invariant nine call sites already
|
|
15
|
+
* depend on) while giving each instance somewhere of its own to be written to.
|
|
16
|
+
*
|
|
17
|
+
* The alternative, pointing `sourcePath` at the shared submodule directory,
|
|
18
|
+
* looks cleaner and is not. Hooks run with the module directory as their root
|
|
19
|
+
* and WRITE into it (`browser.ts` puts screenshots there, `module build` uses it
|
|
20
|
+
* as cwd), so N instances would land on top of each other. Copying instead is
|
|
21
|
+
* out on size: a module is about 20MB, nearly all of it the bundled hook closure
|
|
22
|
+
* that celilo#173 makes load-bearing, against about 72KB of authored source.
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
import { createHash } from 'node:crypto';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { lstat, mkdir, readdir, readlink, rm, symlink } from 'node:fs/promises';
|
|
28
|
+
import { join, relative, resolve } from 'node:path';
|
|
17
29
|
import { eq } from 'drizzle-orm';
|
|
18
30
|
import type { DbClient } from '../db/client';
|
|
19
|
-
import { moduleInstances } from '../db/schema';
|
|
31
|
+
import { type InstanceState, moduleInstances } from '../db/schema';
|
|
32
|
+
import { SUBMODULES_DIR } from '../manifest/validate';
|
|
20
33
|
|
|
21
34
|
/**
|
|
22
35
|
* Bits of hash in a derived instance id, as hex characters.
|
|
@@ -28,14 +41,19 @@ import { moduleInstances } from '../db/schema';
|
|
|
28
41
|
*/
|
|
29
42
|
const INSTANCE_ID_HASH_CHARS = 12;
|
|
30
43
|
|
|
44
|
+
/** Entries an instance never links, because it owns its own copy or none. */
|
|
45
|
+
const NOT_SYMLINKED = new Set(['generated', 'screenshots', 'cookies.json', 'checksums.json']);
|
|
46
|
+
|
|
47
|
+
/** The one subdirectory of an instance that is real rather than a link. */
|
|
48
|
+
export const INSTANCE_GENERATED_DIR = 'generated';
|
|
49
|
+
|
|
31
50
|
/**
|
|
32
51
|
* The `modules.id` an instance runs as.
|
|
33
52
|
*
|
|
34
53
|
* Derived rather than supplied, for two reasons. The parent's key is opaque and
|
|
35
54
|
* may be anything (D2 forbids celilo interpreting it), while `modules.id` must
|
|
36
|
-
* be kebab-case. And identity is the TRIPLE
|
|
37
|
-
*
|
|
38
|
-
* would collide.
|
|
55
|
+
* be kebab-case. And identity is the TRIPLE, so the parent and submodule have to
|
|
56
|
+
* be inside the hash, or two parents using the same key would collide.
|
|
39
57
|
*
|
|
40
58
|
* Deterministic: the same triple always yields the same id, which is what makes
|
|
41
59
|
* an instantiate safe to retry.
|
|
@@ -56,6 +74,191 @@ export function deriveInstanceModuleId(
|
|
|
56
74
|
return `${parentId}-${submodule}-${digest}`;
|
|
57
75
|
}
|
|
58
76
|
|
|
77
|
+
/** Where a parent's shared submodule source lives inside its own install. */
|
|
78
|
+
export function submoduleSourcePath(parentSourcePath: string, submodule: string): string {
|
|
79
|
+
return join(parentSourcePath, SUBMODULES_DIR, submodule);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Which entries of a submodule's source get symlinked into an instance.
|
|
84
|
+
*
|
|
85
|
+
* Pure, so the decision is testable without a filesystem. `generated/` is
|
|
86
|
+
* excluded because the instance owns its own. The derived hook outputs
|
|
87
|
+
* (`screenshots/`, `cookies.json`) are excluded because linking them would put
|
|
88
|
+
* every instance's writes back into one shared place, which is the failure this
|
|
89
|
+
* layout exists to prevent.
|
|
90
|
+
*/
|
|
91
|
+
export function planInstanceLinks(submoduleEntries: string[]): string[] {
|
|
92
|
+
return submoduleEntries.filter((entry) => !NOT_SYMLINKED.has(entry)).sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create or repair an instance's symlink farm.
|
|
97
|
+
*
|
|
98
|
+
* Idempotent, and deliberately a full converge rather than a create. A submodule
|
|
99
|
+
* that gains a top-level directory in a later version would otherwise leave
|
|
100
|
+
* every existing instance stale, so this runs on instantiate AND on parent
|
|
101
|
+
* update. A link pointing somewhere else is replaced rather than left, because a
|
|
102
|
+
* repointed link is the one integrity property unique to instances.
|
|
103
|
+
*
|
|
104
|
+
* Returns the entries linked, so a caller can report what changed.
|
|
105
|
+
*/
|
|
106
|
+
export async function buildInstanceLinkFarm(opts: {
|
|
107
|
+
instancePath: string;
|
|
108
|
+
submodulePath: string;
|
|
109
|
+
}): Promise<string[]> {
|
|
110
|
+
const { instancePath, submodulePath } = opts;
|
|
111
|
+
|
|
112
|
+
if (!existsSync(submodulePath)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Cannot build an instance at ${instancePath}: its submodule source ${submodulePath} does not exist. The parent's install is incomplete.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await mkdir(instancePath, { recursive: true });
|
|
119
|
+
await mkdir(join(instancePath, INSTANCE_GENERATED_DIR), { recursive: true });
|
|
120
|
+
|
|
121
|
+
const wanted = planInstanceLinks(await readdir(submodulePath));
|
|
122
|
+
|
|
123
|
+
for (const entry of wanted) {
|
|
124
|
+
const linkPath = join(instancePath, entry);
|
|
125
|
+
// Relative, so the farm survives the whole data directory being moved or
|
|
126
|
+
// restored somewhere else, which `celilo restore` does.
|
|
127
|
+
const target = relative(instancePath, join(submodulePath, entry));
|
|
128
|
+
|
|
129
|
+
const existing = await readExistingLink(linkPath);
|
|
130
|
+
if (existing === target) continue;
|
|
131
|
+
if (existing !== null) await rm(linkPath, { recursive: true, force: true });
|
|
132
|
+
|
|
133
|
+
await symlink(target, linkPath);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Links for entries the submodule no longer has. Left behind they dangle, and
|
|
137
|
+
// a dangling link reads as a broken install rather than as a stale one.
|
|
138
|
+
for (const entry of await readdir(instancePath)) {
|
|
139
|
+
if (entry === INSTANCE_GENERATED_DIR || wanted.includes(entry)) continue;
|
|
140
|
+
const stale = join(instancePath, entry);
|
|
141
|
+
if ((await lstat(stale)).isSymbolicLink()) await rm(stale, { force: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return wanted;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What `linkPath` currently points at, or null if it is absent or not a link.
|
|
149
|
+
*
|
|
150
|
+
* A real file or directory where a link belongs returns null so the caller
|
|
151
|
+
* replaces it. That is the right answer: an instance directory holds nothing of
|
|
152
|
+
* its own except `generated/`, so anything else there is debris.
|
|
153
|
+
*/
|
|
154
|
+
async function readExistingLink(linkPath: string): Promise<string | null> {
|
|
155
|
+
if (!existsSync(linkPath)) {
|
|
156
|
+
// existsSync FOLLOWS links, so a dangling link reports absent. lstat is what
|
|
157
|
+
// distinguishes "nothing here" from "a link to nowhere", and only the second
|
|
158
|
+
// needs removing before symlink() will succeed.
|
|
159
|
+
try {
|
|
160
|
+
await lstat(linkPath);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
await rm(linkPath, { force: true });
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return (await lstat(linkPath)).isSymbolicLink() ? await readlink(linkPath) : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One link that does not point where it should. */
|
|
175
|
+
export interface LinkViolation {
|
|
176
|
+
entry: string;
|
|
177
|
+
expected: string;
|
|
178
|
+
actual: string | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Verify an instance's farm points where it claims.
|
|
183
|
+
*
|
|
184
|
+
* The integrity property unique to instances (D4). An instance carries no
|
|
185
|
+
* authored bytes of its own, so `module verify` has nothing of its own to
|
|
186
|
+
* checksum: its bytes are the submodule's, which are covered by the parent's
|
|
187
|
+
* baseline. What IS worth checking, and what nothing else checks, is that the
|
|
188
|
+
* links still resolve inside the declaring parent and have not been repointed at
|
|
189
|
+
* somebody else's source.
|
|
190
|
+
*/
|
|
191
|
+
export async function verifyInstanceLinks(opts: {
|
|
192
|
+
instancePath: string;
|
|
193
|
+
submodulePath: string;
|
|
194
|
+
}): Promise<LinkViolation[]> {
|
|
195
|
+
const { instancePath, submodulePath } = opts;
|
|
196
|
+
const violations: LinkViolation[] = [];
|
|
197
|
+
|
|
198
|
+
for (const entry of planInstanceLinks(await readdir(submodulePath))) {
|
|
199
|
+
const linkPath = join(instancePath, entry);
|
|
200
|
+
const expected = resolve(submodulePath, entry);
|
|
201
|
+
const actual = await readExistingLink(linkPath);
|
|
202
|
+
const resolved = actual === null ? null : resolve(instancePath, actual);
|
|
203
|
+
|
|
204
|
+
if (resolved !== expected) {
|
|
205
|
+
violations.push({ entry, expected, actual: resolved });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return violations;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** An instance row, joined to what the caller needs to reach its source. */
|
|
213
|
+
export interface InstanceRecord {
|
|
214
|
+
moduleId: string;
|
|
215
|
+
parentId: string;
|
|
216
|
+
submodule: string;
|
|
217
|
+
instanceKey: string;
|
|
218
|
+
label: string | null;
|
|
219
|
+
state: InstanceState;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The instance row for `moduleId`, or null if it names an ordinary module.
|
|
224
|
+
*
|
|
225
|
+
* The one question every caller that must treat an instance differently has to
|
|
226
|
+
* ask, so it lives here rather than being a join each of them writes.
|
|
227
|
+
*/
|
|
228
|
+
export function loadInstance(moduleId: string, db: DbClient): InstanceRecord | null {
|
|
229
|
+
const row = db
|
|
230
|
+
.select({
|
|
231
|
+
moduleId: moduleInstances.moduleId,
|
|
232
|
+
parentId: moduleInstances.parentId,
|
|
233
|
+
submodule: moduleInstances.submodule,
|
|
234
|
+
instanceKey: moduleInstances.instanceKey,
|
|
235
|
+
label: moduleInstances.label,
|
|
236
|
+
state: moduleInstances.state,
|
|
237
|
+
})
|
|
238
|
+
.from(moduleInstances)
|
|
239
|
+
.where(eq(moduleInstances.moduleId, moduleId))
|
|
240
|
+
.get();
|
|
241
|
+
|
|
242
|
+
return row ?? null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Every instance a parent owns, oldest first so listings are stable. */
|
|
246
|
+
export function loadInstancesForParent(parentId: string, db: DbClient): InstanceRecord[] {
|
|
247
|
+
return db
|
|
248
|
+
.select({
|
|
249
|
+
moduleId: moduleInstances.moduleId,
|
|
250
|
+
parentId: moduleInstances.parentId,
|
|
251
|
+
submodule: moduleInstances.submodule,
|
|
252
|
+
instanceKey: moduleInstances.instanceKey,
|
|
253
|
+
label: moduleInstances.label,
|
|
254
|
+
state: moduleInstances.state,
|
|
255
|
+
})
|
|
256
|
+
.from(moduleInstances)
|
|
257
|
+
.where(eq(moduleInstances.parentId, parentId))
|
|
258
|
+
.orderBy(moduleInstances.createdAt)
|
|
259
|
+
.all();
|
|
260
|
+
}
|
|
261
|
+
|
|
59
262
|
/**
|
|
60
263
|
* Every module id whose systems `moduleId` transitively owns: itself, plus each
|
|
61
264
|
* of its instances.
|
|
@@ -66,24 +269,14 @@ export function deriveInstanceModuleId(
|
|
|
66
269
|
* `IN (...)` set.
|
|
67
270
|
*
|
|
68
271
|
* NOT RECURSIVE, and that is a property of the model rather than a shortcut.
|
|
69
|
-
* Nesting is refused
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* A recursive walk that happens to terminate and one that cannot recurse are
|
|
76
|
-
* different things, and only the second is safe to call per invocation without
|
|
77
|
-
* reasoning about depth.
|
|
272
|
+
* Nesting is refused at parent import (`validateSubmoduleManifest`), so
|
|
273
|
+
* ownership is exactly one level deep by construction: a parent owns instances,
|
|
274
|
+
* and an instance owns nothing. So this is one indexed lookup on
|
|
275
|
+
* `module_instances_parent_idx` rather than a walk, which is what makes it
|
|
276
|
+
* cheap enough to run at hook-invocation time.
|
|
78
277
|
*
|
|
79
278
|
* An instance asking gets only itself, which is correct: an instance provisions
|
|
80
279
|
* its own systems and owns nobody else's.
|
|
81
|
-
*
|
|
82
|
-
* Correct with no instances in existence, which is the state of every fleet
|
|
83
|
-
* until submodules ship: the table is empty, so every module gets `[itself]`,
|
|
84
|
-
* and a caller reaches only the systems it provisioned. That is the answer the
|
|
85
|
-
* allow-list wants today, and it picks up submodule behaviour later with no
|
|
86
|
-
* second edit.
|
|
87
280
|
*/
|
|
88
281
|
export function ownedSystemModuleIds(moduleId: string, db: DbClient): string[] {
|
|
89
282
|
const instances = db
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
rmSync,
|
|
33
33
|
} from 'node:fs';
|
|
34
34
|
import { tmpdir } from 'node:os';
|
|
35
|
-
import {
|
|
35
|
+
import { join } from 'node:path';
|
|
36
36
|
import { eq } from 'drizzle-orm';
|
|
37
37
|
import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
|
|
38
38
|
import { closeDb, getDb } from '../db/client';
|
|
@@ -47,6 +47,7 @@ import { decryptFileToFile } from './backup-cipher';
|
|
|
47
47
|
import { assertCompatibleSchema, parseManifest } from './backup-manifest';
|
|
48
48
|
import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read';
|
|
49
49
|
import { getModuleSystems } from './deployed-systems';
|
|
50
|
+
import { getFleetSshDir } from './fleet-key';
|
|
50
51
|
import {
|
|
51
52
|
completeOperation,
|
|
52
53
|
failOperation,
|
|
@@ -376,10 +377,10 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
376
377
|
// managed machines; the DB only carries the public half, so without the
|
|
377
378
|
// private half on disk the restored box can't authenticate to the fleet.
|
|
378
379
|
if (existsSync(stagedSsh)) {
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
const liveSshDir =
|
|
380
|
+
// One helper, shared with the minting side, so restore and mint cannot
|
|
381
|
+
// drift to different directories. See getFleetSshDir for why it follows
|
|
382
|
+
// the DB rather than getDataDir().
|
|
383
|
+
const liveSshDir = getFleetSshDir();
|
|
383
384
|
mkdirSync(liveSshDir, { recursive: true, mode: 0o700 });
|
|
384
385
|
chmodSync(liveSshDir, 0o700);
|
|
385
386
|
for (const entry of readdirSync(stagedSsh)) {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staging celilo's own state for a backup hook (design D9b of
|
|
3
|
+
* openspec/changes/hook-process-boundary).
|
|
4
|
+
*
|
|
5
|
+
* These assertions used to live in celilo-mgmt-hooks.test.ts, because the
|
|
6
|
+
* work used to live in celilo-mgmt's on_backup. They moved here with the
|
|
7
|
+
* code: the hook no longer reads celilo's data directory, the framework
|
|
8
|
+
* copies it into a staged directory first.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Database } from 'bun:sqlite';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { snapshotDatabase, stageSystemState } from './system-state-stage';
|
|
17
|
+
|
|
18
|
+
describe('stageSystemState', () => {
|
|
19
|
+
let dataDir: string;
|
|
20
|
+
let stageRoot: string;
|
|
21
|
+
let masterKeyPath: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
dataDir = mkdtempSync(join(tmpdir(), 'celilo-system-state-'));
|
|
25
|
+
stageRoot = join(dataDir, 'staged');
|
|
26
|
+
process.env.CELILO_DATA_DIR = dataDir;
|
|
27
|
+
process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
|
|
28
|
+
|
|
29
|
+
const seed = new Database(join(dataDir, 'celilo.db'));
|
|
30
|
+
seed.run('CREATE TABLE probe (id INTEGER PRIMARY KEY, v TEXT)');
|
|
31
|
+
seed.run("INSERT INTO probe (v) VALUES ('hello')");
|
|
32
|
+
seed.close();
|
|
33
|
+
|
|
34
|
+
masterKeyPath = join(dataDir, 'master.key');
|
|
35
|
+
writeFileSync(masterKeyPath, 'fake-master-key-32-bytes-padding!');
|
|
36
|
+
process.env.CELILO_MASTER_KEY_PATH = masterKeyPath;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
41
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
42
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
43
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('stages the DB snapshot and master.key into the root it is given', () => {
|
|
47
|
+
const staged = stageSystemState(stageRoot);
|
|
48
|
+
|
|
49
|
+
expect(existsSync(join(stageRoot, 'celilo.db'))).toBe(true);
|
|
50
|
+
expect(existsSync(join(stageRoot, 'master.key'))).toBe(true);
|
|
51
|
+
expect(staged.masterKeyStaged).toBe(true);
|
|
52
|
+
expect(staged.root).toBe(stageRoot);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reports a missing master.key rather than throwing', () => {
|
|
56
|
+
rmSync(masterKeyPath);
|
|
57
|
+
|
|
58
|
+
const staged = stageSystemState(stageRoot);
|
|
59
|
+
|
|
60
|
+
expect(staged.masterKeyStaged).toBe(false);
|
|
61
|
+
expect(existsSync(join(stageRoot, 'master.key'))).toBe(false);
|
|
62
|
+
// The DB still travels: a snapshot without the key is degraded, not
|
|
63
|
+
// useless, and refusing here would block backups on a box whose key
|
|
64
|
+
// path is overridden.
|
|
65
|
+
expect(existsSync(join(stageRoot, 'celilo.db'))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('stages the fleet keypair, private half included', () => {
|
|
69
|
+
const sshDir = join(dataDir, '.ssh');
|
|
70
|
+
mkdirSync(sshDir, { recursive: true });
|
|
71
|
+
writeFileSync(join(sshDir, 'id_ed25519'), 'PRIVATE');
|
|
72
|
+
writeFileSync(join(sshDir, 'id_ed25519.pub'), 'ssh-ed25519 AAAA celilo-fleet');
|
|
73
|
+
|
|
74
|
+
const staged = stageSystemState(stageRoot);
|
|
75
|
+
|
|
76
|
+
expect(staged.fleetSshStaged).toBe(true);
|
|
77
|
+
// The DB carries only the public half. Without the private half on disk a
|
|
78
|
+
// restored box cannot reach machines that already trust the key.
|
|
79
|
+
expect(readFileSync(join(stageRoot, 'ssh', 'id_ed25519'), 'utf-8')).toBe('PRIVATE');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reports an absent fleet keypair rather than staging an empty dir', () => {
|
|
83
|
+
const staged = stageSystemState(stageRoot);
|
|
84
|
+
|
|
85
|
+
expect(staged.fleetSshStaged).toBe(false);
|
|
86
|
+
expect(existsSync(join(stageRoot, 'ssh'))).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('captures LEAN module source: no generated/, no node_modules/, no oversized files', () => {
|
|
90
|
+
const modSrc = join(dataDir, 'modules', 'caddy');
|
|
91
|
+
mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
|
|
92
|
+
mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
|
|
93
|
+
mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
|
|
94
|
+
writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
|
|
95
|
+
writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
|
|
96
|
+
writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
|
|
97
|
+
writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
|
|
98
|
+
// A >2MB "compiled binary" sitting in source — skipped by size, because
|
|
99
|
+
// excluding by directory name misses the ones outside a known build dir.
|
|
100
|
+
writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
|
|
101
|
+
|
|
102
|
+
const staged = stageSystemState(stageRoot);
|
|
103
|
+
const at = (...parts: string[]) => join(stageRoot, 'module_src', 'caddy', ...parts);
|
|
104
|
+
|
|
105
|
+
expect(staged.moduleSourceCount).toBe(1);
|
|
106
|
+
expect(existsSync(at('manifest.yml'))).toBe(true);
|
|
107
|
+
expect(existsSync(at('scripts', 'hook.ts'))).toBe(true);
|
|
108
|
+
expect(existsSync(at('generated'))).toBe(false);
|
|
109
|
+
expect(existsSync(at('scripts', 'node_modules'))).toBe(false);
|
|
110
|
+
expect(existsSync(at('ansible', 'files', 'server-bin'))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('names every file the size cap dropped', () => {
|
|
114
|
+
const modSrc = join(dataDir, 'modules', 'caddy');
|
|
115
|
+
mkdirSync(join(modSrc, 'ansible'), { recursive: true });
|
|
116
|
+
writeFileSync(join(modSrc, 'ansible', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
|
|
117
|
+
|
|
118
|
+
const staged = stageSystemState(stageRoot);
|
|
119
|
+
|
|
120
|
+
// No silent caps: a backup that quietly dropped a file reads as complete.
|
|
121
|
+
expect(staged.skippedLarge).toHaveLength(1);
|
|
122
|
+
expect(staged.skippedLarge[0]).toContain('caddy/ansible/server-bin');
|
|
123
|
+
expect(staged.skippedLarge[0]).toContain('3.0MB');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('snapshotDatabase', () => {
|
|
128
|
+
let dir: string;
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-db-snapshot-'));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
rmSync(dir, { recursive: true, force: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('captures rows still sitting in the WAL, uncheckpointed', () => {
|
|
139
|
+
// The defect this guards: celilo runs the DB in WAL mode, so committed
|
|
140
|
+
// rows live in celilo.db-wal until a checkpoint folds them into the main
|
|
141
|
+
// file. A plain copy of the main file produces a snapshot that opens
|
|
142
|
+
// cleanly and contains NOTHING, and restore then installs it. Swap
|
|
143
|
+
// serialize() for copyFileSync and this test is the thing that notices.
|
|
144
|
+
const src = join(dir, 'celilo.db');
|
|
145
|
+
const live = new Database(src);
|
|
146
|
+
live.run('PRAGMA journal_mode = WAL');
|
|
147
|
+
live.run('CREATE TABLE probe (id INTEGER PRIMARY KEY, v TEXT)');
|
|
148
|
+
for (let i = 0; i < 200; i++) {
|
|
149
|
+
live.run('INSERT INTO probe (v) VALUES (?)', [`row-${i}`]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dest = join(dir, 'snapshot.db');
|
|
153
|
+
snapshotDatabase(src, dest);
|
|
154
|
+
live.close();
|
|
155
|
+
|
|
156
|
+
// Opened read-write, not readonly: the serialized bytes carry WAL journal
|
|
157
|
+
// mode in their header, so the FIRST open has to be able to create the
|
|
158
|
+
// -wal/-shm sidecars. Restore opens it read-write too (it copies the file
|
|
159
|
+
// into place and runs migrations), so this is the real consumer's path.
|
|
160
|
+
const restored = new Database(dest);
|
|
161
|
+
const row = restored.query('SELECT COUNT(*) AS n FROM probe').get() as { n: number };
|
|
162
|
+
restored.close();
|
|
163
|
+
expect(row.n).toBe(200);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staging of celilo's own state for a backup hook
|
|
3
|
+
* (openspec/changes/hook-process-boundary, design D9b).
|
|
4
|
+
*
|
|
5
|
+
* celilo-mgmt's `on_backup` used to reach into celilo's data directory and
|
|
6
|
+
* copy `master.key`, the DB, the fleet `.ssh` and every other module's
|
|
7
|
+
* source tree out of it. Measured across the whole hook, it read none of
|
|
8
|
+
* those bytes: every one was a `copyFileSync` / `cpSync` into `backup_dir`.
|
|
9
|
+
* It does not need those files in its filesystem view. It needs them to end
|
|
10
|
+
* up in the backup.
|
|
11
|
+
*
|
|
12
|
+
* So the framework copies them into a directory it creates and hands over,
|
|
13
|
+
* exactly as `materializeCrossModuleRoot` already does for
|
|
14
|
+
* `cross_module_read`. The hook reads from a staged location it was given,
|
|
15
|
+
* celilo-mgmt is fully jailed, and "the jail applies to every module" stays
|
|
16
|
+
* true with no exemption to audit.
|
|
17
|
+
*
|
|
18
|
+
* The layout mirrors what `on_backup` puts in the envelope, so the hook's
|
|
19
|
+
* remaining job is a copy:
|
|
20
|
+
*
|
|
21
|
+
* <root>/celilo.db WAL-correct snapshot
|
|
22
|
+
* <root>/master.key if present
|
|
23
|
+
* <root>/ssh/ fleet keypair, if present
|
|
24
|
+
* <root>/module_src/<id>/ each module's lean source
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Database } from 'bun:sqlite';
|
|
28
|
+
import {
|
|
29
|
+
copyFileSync,
|
|
30
|
+
cpSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
readdirSync,
|
|
34
|
+
statSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
|
|
39
|
+
import { getFleetSshDir } from './fleet-key';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Consistent SQLite snapshot via bun:sqlite's serialize().
|
|
43
|
+
*
|
|
44
|
+
* celilo runs the DB in WAL mode (apps/celilo/src/db/client.ts), so committed
|
|
45
|
+
* rows live in `celilo.db-wal` until a checkpoint folds them into the main
|
|
46
|
+
* file. The main file is routinely a single near-empty page while ALL the
|
|
47
|
+
* real data (20+ tables, modules, config, secrets) sits in the WAL. A readonly
|
|
48
|
+
* connection reads THROUGH the WAL, so serialize() captures the full committed
|
|
49
|
+
* state into one standalone file — exactly what restore needs.
|
|
50
|
+
*
|
|
51
|
+
* An earlier implementation shelled out to `sqlite3 ".backup"` and fell back
|
|
52
|
+
* to a plain copyFileSync when the CLI was absent. On a deb-installed box
|
|
53
|
+
* there IS no sqlite3 CLI, so the fallback ran — and a plain copy of the main
|
|
54
|
+
* file alone DROPS the WAL, producing a silently EMPTY backup (restore then
|
|
55
|
+
* installs an empty DB). bun:sqlite is a Bun built-in and reads the WAL
|
|
56
|
+
* correctly — no CLI dependency, no data loss.
|
|
57
|
+
*
|
|
58
|
+
* This lives in the framework rather than in a module because celilo owns the
|
|
59
|
+
* schema and `getDbPath()`, and because a bug that silently empties backups
|
|
60
|
+
* should be fixed once, where it is tested.
|
|
61
|
+
*/
|
|
62
|
+
export function snapshotDatabase(srcPath: string, destPath: string): void {
|
|
63
|
+
const db = new Database(srcPath, { readonly: true });
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(destPath, db.serialize());
|
|
66
|
+
} finally {
|
|
67
|
+
db.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Directories never worth capturing from a module's source tree. Every one
|
|
73
|
+
* is rebuilt on deploy (`module build` / `generate`) or re-vendored on
|
|
74
|
+
* restore (`installScriptDependencies`).
|
|
75
|
+
*/
|
|
76
|
+
const EXCLUDE_DIRS = new Set([
|
|
77
|
+
'node_modules',
|
|
78
|
+
'generated',
|
|
79
|
+
'dist',
|
|
80
|
+
'coverage',
|
|
81
|
+
'coverage-raw',
|
|
82
|
+
'.git',
|
|
83
|
+
'screenshots',
|
|
84
|
+
'e2e',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Size ceiling for a single captured source file.
|
|
89
|
+
*
|
|
90
|
+
* celilo module dirs bundle large BUILD artifacts (compiled binaries, built
|
|
91
|
+
* assets, `*.netapp` packages) — turnip's were ~1.6 GB, which made the
|
|
92
|
+
* in-memory tar+encrypt segfault. Excluding by directory name misses the ones
|
|
93
|
+
* that sit outside a known build dir, so a size cap catches them generically.
|
|
94
|
+
*/
|
|
95
|
+
const MAX_SRC_FILE_BYTES = 2 * 1024 * 1024;
|
|
96
|
+
|
|
97
|
+
export interface StagedSystemState {
|
|
98
|
+
/** The directory the caller passes to the hook. */
|
|
99
|
+
root: string;
|
|
100
|
+
masterKeyStaged: boolean;
|
|
101
|
+
fleetSshStaged: boolean;
|
|
102
|
+
moduleSourceCount: number;
|
|
103
|
+
/** Files the size cap or the `.netapp` rule dropped, named. No silent caps. */
|
|
104
|
+
skippedLarge: string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Populate `rootDir` with celilo's own state and return what landed there.
|
|
109
|
+
*
|
|
110
|
+
* Absent pieces are reported rather than thrown on: a box with no fleet key
|
|
111
|
+
* yet is an ordinary pre-deploy state, and a missing `master.key` is a fact
|
|
112
|
+
* the caller surfaces to the operator (secrets in the snapshot would be
|
|
113
|
+
* unreadable on restore) rather than a reason to abort the backup.
|
|
114
|
+
*/
|
|
115
|
+
export function stageSystemState(rootDir: string): StagedSystemState {
|
|
116
|
+
mkdirSync(rootDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
snapshotDatabase(getDbPath(), join(rootDir, 'celilo.db'));
|
|
119
|
+
|
|
120
|
+
// `getMasterKeyPath()` honours CELILO_MASTER_KEY_PATH and otherwise sits
|
|
121
|
+
// under getDataDir(). The hook used to re-derive it as
|
|
122
|
+
// `dirname(db_path)/master.key`, which is the same file on a deb install
|
|
123
|
+
// and a different one whenever CELILO_DB_PATH points elsewhere.
|
|
124
|
+
const masterKeyPath = getMasterKeyPath();
|
|
125
|
+
const masterKeyStaged = existsSync(masterKeyPath);
|
|
126
|
+
if (masterKeyStaged) {
|
|
127
|
+
copyFileSync(masterKeyPath, join(rootDir, 'master.key'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The private half too: the DB carries only `ssh.public_key`, so without
|
|
131
|
+
// it a restored box cannot reach the fleet, and re-keying means
|
|
132
|
+
// re-authorizing every managed machine.
|
|
133
|
+
const fleetSshDir = getFleetSshDir();
|
|
134
|
+
const fleetSshStaged = existsSync(join(fleetSshDir, 'id_ed25519'));
|
|
135
|
+
if (fleetSshStaged) {
|
|
136
|
+
cpSync(fleetSshDir, join(rootDir, 'ssh'), { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { moduleSourceCount, skippedLarge } = stageModuleSources(join(rootDir, 'module_src'));
|
|
140
|
+
|
|
141
|
+
return { root: rootDir, masterKeyStaged, fleetSshStaged, moduleSourceCount, skippedLarge };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Capture each module's SOURCE (manifest, scripts, ansible, templates) into
|
|
146
|
+
* `destDir`, minus build artifacts.
|
|
147
|
+
*
|
|
148
|
+
* The DB references every module by `source_path`, but the rest of the
|
|
149
|
+
* envelope carries only DB + state, not module CODE. Restoring onto a fresh
|
|
150
|
+
* box — especially a different OS, where the source box's absolute
|
|
151
|
+
* `source_path` does not exist — would otherwise leave it unable to deploy
|
|
152
|
+
* ANY module, including non-registry ones (e.g. lunacycle) that a
|
|
153
|
+
* re-import-from-registry cannot recover.
|
|
154
|
+
*
|
|
155
|
+
* Reads `getModuleStoragePath()`, which is where `applyStagedSystemFiles`
|
|
156
|
+
* lays the source back down on restore. The hook derived
|
|
157
|
+
* `dirname(db_path)/modules` instead — the same directory on a deb install,
|
|
158
|
+
* and a different one otherwise, so backup and restore could disagree about
|
|
159
|
+
* where module source lives.
|
|
160
|
+
*/
|
|
161
|
+
function stageModuleSources(destDir: string): {
|
|
162
|
+
moduleSourceCount: number;
|
|
163
|
+
skippedLarge: string[];
|
|
164
|
+
} {
|
|
165
|
+
const modulesSrcDir = getModuleStoragePath();
|
|
166
|
+
const skippedLarge: string[] = [];
|
|
167
|
+
let moduleSourceCount = 0;
|
|
168
|
+
|
|
169
|
+
if (!existsSync(modulesSrcDir)) {
|
|
170
|
+
return { moduleSourceCount, skippedLarge };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const entry of readdirSync(modulesSrcDir, { withFileTypes: true })) {
|
|
174
|
+
if (!entry.isDirectory()) continue;
|
|
175
|
+
const srcModuleDir = join(modulesSrcDir, entry.name);
|
|
176
|
+
cpSync(srcModuleDir, join(destDir, entry.name), {
|
|
177
|
+
recursive: true,
|
|
178
|
+
filter: (src: string) => {
|
|
179
|
+
const rel = src.slice(srcModuleDir.length).replace(/^\//, '');
|
|
180
|
+
if (rel === '') return true; // module root
|
|
181
|
+
if (rel.split('/').some((seg) => EXCLUDE_DIRS.has(seg))) return false;
|
|
182
|
+
const st = statSync(src);
|
|
183
|
+
if (st.isDirectory()) return true;
|
|
184
|
+
if (src.endsWith('.netapp')) return false;
|
|
185
|
+
if (st.size > MAX_SRC_FILE_BYTES) {
|
|
186
|
+
skippedLarge.push(`${entry.name}/${rel} (${(st.size / 1048576).toFixed(1)}MB)`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
moduleSourceCount += 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { moduleSourceCount, skippedLarge };
|
|
196
|
+
}
|