@celilo/cli 1.13.0 → 2.0.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 +31 -5
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- 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 +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -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 +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static-content converge (openspec/changes/capability-owned-tables,
|
|
3
|
+
* stage 4 / design D10).
|
|
4
|
+
*
|
|
5
|
+
* `/srv/www` on the public_web provider stops being whatever the last
|
|
6
|
+
* hand-built ssh upload left there and becomes convergent state: every
|
|
7
|
+
* declared static route's release directory exists, `/srv/www/<slug>` points
|
|
8
|
+
* at the current one through an atomic symlink swap, and everything past the
|
|
9
|
+
* retention count is pruned. One Ansible converge makes the host match.
|
|
10
|
+
*
|
|
11
|
+
* Two callers, one code path:
|
|
12
|
+
* - the `public_web` capability's publish (`convergeStaticContent` callback
|
|
13
|
+
* injected by capability-loader), which runs the whole converge including
|
|
14
|
+
* `executeAnsible`, so a publish returns only once the host matches; and
|
|
15
|
+
* - the provider's OWN deploy (module-deploy), which writes the same release
|
|
16
|
+
* set into its generated inventory and lets the deploy's existing
|
|
17
|
+
* `executeAnsible` run the same role tasks — that is how a rebuilt host
|
|
18
|
+
* recovers with no consumer involvement (design D10, task 4.5).
|
|
19
|
+
*
|
|
20
|
+
* The desired state is the declared `web_routes` release set plus the source
|
|
21
|
+
* directory core derived from `modules.source_path`. Nothing here writes into
|
|
22
|
+
* a module tree: reading a module's web root is safe, writing is what breaks
|
|
23
|
+
* `module verify` (celilo#1018, deliberately left to that thread).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
27
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { and, eq, isNotNull } from 'drizzle-orm';
|
|
30
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
31
|
+
import type { DbClient } from '../db/client';
|
|
32
|
+
import { moduleConfigs, modules, webRoutes } from '../db/schema';
|
|
33
|
+
import { resolveModuleWebRoot } from '../module/web-root';
|
|
34
|
+
import { parseStoredConfigValue } from './module-config';
|
|
35
|
+
|
|
36
|
+
/** One content-hashed release the provider host must serve. */
|
|
37
|
+
export interface StaticRelease {
|
|
38
|
+
/** The `/srv/www/<slug>` key. Independent of hostname on purpose. */
|
|
39
|
+
slug: string;
|
|
40
|
+
/** Content hash of the release — the release directory's suffix. */
|
|
41
|
+
contentHash: string;
|
|
42
|
+
/** Every FQDN served by this slug's routes (a slug spans hostnames). */
|
|
43
|
+
hostnames: string[];
|
|
44
|
+
/** Absolute celilo-mgr path core derived: `<module source_path>/site/dist`. */
|
|
45
|
+
sourceDir: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StaticContentPlan {
|
|
49
|
+
releases: StaticRelease[];
|
|
50
|
+
/** How many release directories to keep per slug after a converge. */
|
|
51
|
+
retention: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Retention when the operator has not set one. Mirrors the manifest default
|
|
56
|
+
* declared on the provider module (`static_release_retention`); the manifest
|
|
57
|
+
* is the operator-facing source of truth, this only covers a provider whose
|
|
58
|
+
* manifest predates the variable.
|
|
59
|
+
*/
|
|
60
|
+
const DEFAULT_STATIC_RELEASE_RETENTION = 5;
|
|
61
|
+
|
|
62
|
+
export interface StaticContentConvergeResult {
|
|
63
|
+
success: boolean;
|
|
64
|
+
error?: string;
|
|
65
|
+
/** The written vars file, repo-absolute — for logs and tests. */
|
|
66
|
+
varsPath?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where the converge's desired state lands inside the provider's generated
|
|
71
|
+
* project. `group_vars/all/` is the existing auto-load directory (it already
|
|
72
|
+
* holds `secrets.yml`), so no playbook change is needed to read it.
|
|
73
|
+
*/
|
|
74
|
+
export function staticContentVarsPath(generatedPath: string): string {
|
|
75
|
+
return join(generatedPath, 'ansible', 'inventory', 'group_vars', 'all', 'static_content.yml');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the retention count for the provider module.
|
|
80
|
+
*
|
|
81
|
+
* Operator config first (`static_release_retention` module config), then the
|
|
82
|
+
* manifest's declared default, then the constant above. ONE resolution — the
|
|
83
|
+
* role reads the variable bare and never carries a literal.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveStaticContentRetention(db: DbClient, providerModuleId: string): number {
|
|
86
|
+
const configRow = db
|
|
87
|
+
.select()
|
|
88
|
+
.from(moduleConfigs)
|
|
89
|
+
.where(
|
|
90
|
+
and(
|
|
91
|
+
eq(moduleConfigs.moduleId, providerModuleId),
|
|
92
|
+
eq(moduleConfigs.key, 'static_release_retention'),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
.get();
|
|
96
|
+
if (configRow) {
|
|
97
|
+
const parsed = parseStoredConfigValue(configRow);
|
|
98
|
+
const count = typeof parsed === 'number' ? parsed : Number.parseInt(String(parsed), 10);
|
|
99
|
+
if (Number.isInteger(count) && count >= 1) return count;
|
|
100
|
+
throw new Error(
|
|
101
|
+
`static_release_retention for ${providerModuleId} must be a positive integer, got: ${String(parsed)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
|
|
106
|
+
if (module) {
|
|
107
|
+
const manifest = module.manifestData as {
|
|
108
|
+
variables?: { owns?: Array<{ name?: string; default?: unknown }> };
|
|
109
|
+
} | null;
|
|
110
|
+
const declared = manifest?.variables?.owns?.find((v) => v?.name === 'static_release_retention');
|
|
111
|
+
const fallback = typeof declared?.default === 'number' ? declared.default : undefined;
|
|
112
|
+
if (typeof fallback === 'number' && Number.isInteger(fallback) && fallback >= 1)
|
|
113
|
+
return fallback;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return DEFAULT_STATIC_RELEASE_RETENTION;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the release set from the declared `web_routes` rows.
|
|
121
|
+
*
|
|
122
|
+
* Pure: reads the DB, decides nothing on the host (Rule 10.4). Grouping is by
|
|
123
|
+
* slug, NOT by route — one slug is one release directory, and a slug can span
|
|
124
|
+
* hostnames (lunacycle's two routes, both at `/`). A group that spans modules
|
|
125
|
+
* or disagrees with itself about the hash is a contradiction the converge
|
|
126
|
+
* cannot express, so it fails here with both rows named rather than silently
|
|
127
|
+
* converging one of them.
|
|
128
|
+
*/
|
|
129
|
+
export function planStaticContent(db: DbClient): StaticRelease[] {
|
|
130
|
+
const rows = db
|
|
131
|
+
.select()
|
|
132
|
+
.from(webRoutes)
|
|
133
|
+
.where(and(eq(webRoutes.type, 'static'), isNotNull(webRoutes.contentHash)))
|
|
134
|
+
.all();
|
|
135
|
+
|
|
136
|
+
const bySlug = new Map<string, typeof rows>();
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
const group = bySlug.get(row.slug) ?? [];
|
|
139
|
+
group.push(row);
|
|
140
|
+
bySlug.set(row.slug, group);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const releases: StaticRelease[] = [];
|
|
144
|
+
for (const [slug, group] of bySlug) {
|
|
145
|
+
const moduleIds = [...new Set(group.map((r) => r.moduleId))];
|
|
146
|
+
if (moduleIds.length > 1) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Static route slug "${slug}" is claimed by ${moduleIds.length} modules (${moduleIds.join(', ')}). The slug is the release key — /srv/www/${slug} cannot serve two release sets. Fix the colliding modules' registered paths.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const moduleId = moduleIds[0];
|
|
152
|
+
if (!moduleId) continue;
|
|
153
|
+
|
|
154
|
+
const hashes = [...new Set(group.map((r) => r.contentHash))];
|
|
155
|
+
if (hashes.length > 1) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Static route slug "${slug}" (module ${moduleId}) holds ${hashes.length} content hashes (${hashes.join(', ')}) — rows sharing a slug must share a release. Re-publish the module so every row carries the current hash.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const contentHash = hashes[0];
|
|
161
|
+
if (!contentHash) continue;
|
|
162
|
+
|
|
163
|
+
const sourceDir = resolveModuleWebRoot(moduleId, db);
|
|
164
|
+
if (!sourceDir) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Static route slug "${slug}" belongs to module "${moduleId}", which is not installed — its release has no source to converge from.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (!existsSync(sourceDir)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Static route slug "${slug}": no built site at ${sourceDir}. A module that serves a static site ships it at <module root>/site/dist. Redeploy the module.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
releases.push({
|
|
176
|
+
slug,
|
|
177
|
+
contentHash,
|
|
178
|
+
hostnames: [...new Set(group.map((r) => r.hostname))].sort(),
|
|
179
|
+
sourceDir,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
releases.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
184
|
+
return releases;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The full desired state: the release set plus the retention count resolved
|
|
189
|
+
* for THIS provider module. The unit the vars file and the tests speak in.
|
|
190
|
+
*/
|
|
191
|
+
export function buildStaticContentPlan(db: DbClient, providerModuleId: string): StaticContentPlan {
|
|
192
|
+
return {
|
|
193
|
+
releases: planStaticContent(db),
|
|
194
|
+
retention: resolveStaticContentRetention(db, providerModuleId),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Render the vars file. Presentation function (Rule 10.1) — deterministic
|
|
200
|
+
* YAML, keyed by slug, carrying what 4.1 names: slug, content_hash, the
|
|
201
|
+
* hostnames served, and the source directory core derived.
|
|
202
|
+
*/
|
|
203
|
+
export function staticContentVarsYaml(plan: StaticContentPlan): string {
|
|
204
|
+
const vars = {
|
|
205
|
+
static_release_retention: plan.retention,
|
|
206
|
+
static_releases: plan.releases.map((r) => ({
|
|
207
|
+
slug: r.slug,
|
|
208
|
+
content_hash: r.contentHash,
|
|
209
|
+
hostnames: r.hostnames,
|
|
210
|
+
source_dir: r.sourceDir,
|
|
211
|
+
})),
|
|
212
|
+
};
|
|
213
|
+
const header =
|
|
214
|
+
'# Desired static-content state, generated by celilo (capability-owned-tables D10).\n' +
|
|
215
|
+
'# Hand edits are overwritten by the next converge. K = static_release_retention.\n';
|
|
216
|
+
return `${header}${stringifyYaml(vars, { lineWidth: 0, sortMapEntries: true })}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Plan the release set and write it into the provider's generated inventory.
|
|
221
|
+
*
|
|
222
|
+
* The shared half of both converge triggers: the capability's callback adds
|
|
223
|
+
* `executeAnsible` after this, and the provider's own deploy already runs it.
|
|
224
|
+
* Writing happens AFTER the deploy's staleness check (`refuseIfGeneratedIsStale`
|
|
225
|
+
* only inspects verbatim role assets), so an injected vars file never reads as
|
|
226
|
+
* a stale generation.
|
|
227
|
+
*/
|
|
228
|
+
export async function writeStaticContentVars(
|
|
229
|
+
db: DbClient,
|
|
230
|
+
providerModuleId: string,
|
|
231
|
+
generatedPath: string,
|
|
232
|
+
): Promise<StaticContentConvergeResult> {
|
|
233
|
+
const varsPath = staticContentVarsPath(generatedPath);
|
|
234
|
+
|
|
235
|
+
const plan = buildStaticContentPlan(db, providerModuleId);
|
|
236
|
+
|
|
237
|
+
await mkdir(join(varsPath, '..'), { recursive: true });
|
|
238
|
+
await writeFile(varsPath, staticContentVarsYaml(plan), 'utf-8');
|
|
239
|
+
return { success: true, varsPath };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The full converge: plan, write the desired state, run Ansible against the
|
|
244
|
+
* provider's generated project.
|
|
245
|
+
*
|
|
246
|
+
* `generatedPath` must already exist — it is the project the provider's last
|
|
247
|
+
* deploy generated. If the project predates the converge role tasks, the
|
|
248
|
+
* written state would be read by nothing and the transfer would silently
|
|
249
|
+
* never happen, so the playbook is checked for the converge's own variable
|
|
250
|
+
* first and a stale project fails loudly with the redeploy that fixes it.
|
|
251
|
+
*/
|
|
252
|
+
export async function convergeStaticContent(
|
|
253
|
+
db: DbClient,
|
|
254
|
+
providerModuleId: string,
|
|
255
|
+
): Promise<StaticContentConvergeResult> {
|
|
256
|
+
const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
|
|
257
|
+
if (!module) {
|
|
258
|
+
return { success: false, error: `Provider module '${providerModuleId}' is not installed` };
|
|
259
|
+
}
|
|
260
|
+
const generatedPath = join(module.sourcePath, 'generated');
|
|
261
|
+
if (!existsSync(generatedPath)) {
|
|
262
|
+
return {
|
|
263
|
+
success: false,
|
|
264
|
+
error:
|
|
265
|
+
`public_web provider '${providerModuleId}' has no generated deploy project at ${generatedPath}. ` +
|
|
266
|
+
`Run \`celilo module deploy ${providerModuleId}\` once, then retry the publish.`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const written = await writeStaticContentVars(db, providerModuleId, generatedPath);
|
|
271
|
+
|
|
272
|
+
// A generated project that predates the converge role tasks would read the
|
|
273
|
+
// new vars file and converge nothing. `static_releases` appearing nowhere in
|
|
274
|
+
// the playbook is the tell. Generic string check — core names no module.
|
|
275
|
+
const playbookPath = join(generatedPath, 'ansible', 'playbook.yml');
|
|
276
|
+
if (
|
|
277
|
+
existsSync(playbookPath) &&
|
|
278
|
+
!readFileSync(playbookPath, 'utf-8').includes('static_releases')
|
|
279
|
+
) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
error: `The generated project for '${providerModuleId}' predates the static-content converge (its playbook never reads static_releases). Redeploy the provider (\`celilo module deploy ${providerModuleId}\`) to regenerate it, then retry the publish.`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const { executeAnsible } = await import('./deploy-ansible');
|
|
287
|
+
// Tag-scoped: the full playbook re-templates the bootstrap Caddyfile, which
|
|
288
|
+
// would replace the real config the provider's reconcile wrote and reload
|
|
289
|
+
// caddy into serving nothing. A publish converge touches /srv/www only.
|
|
290
|
+
const result = await executeAnsible(generatedPath, { tags: ['static_content'] });
|
|
291
|
+
if (!result.success) {
|
|
292
|
+
return {
|
|
293
|
+
success: false,
|
|
294
|
+
error: `Static-content converge failed on ${providerModuleId}: ${result.error ?? 'unknown error'}`,
|
|
295
|
+
varsPath: written.varsPath,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return { success: true, varsPath: written.varsPath };
|
|
299
|
+
}
|
|
@@ -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
|
+
}
|