@rizom/ops 0.2.0-alpha.48 → 0.2.0-alpha.480

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.
Files changed (69) hide show
  1. package/README.md +82 -1
  2. package/dist/age-key-bootstrap.d.ts +1 -1
  3. package/dist/brains-ops.js +731 -216
  4. package/dist/capability-bundle-migration.d.ts +22 -0
  5. package/dist/cert-bootstrap.d.ts +4 -2
  6. package/dist/content-repo-ref.d.ts +10 -0
  7. package/dist/content-repo.d.ts +1 -1
  8. package/dist/deploy.js +110 -166
  9. package/dist/directory-sync-stress-system.d.ts +75 -0
  10. package/dist/directory-sync-stress.d.ts +117 -0
  11. package/dist/entries/deploy.d.ts +4 -2
  12. package/dist/health-watchdog-smoke.d.ts +54 -0
  13. package/dist/image-inventory.d.ts +4 -0
  14. package/dist/image-types.d.ts +7 -0
  15. package/dist/images.d.ts +82 -0
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.js +731 -204
  18. package/dist/legacy-pilot-migration.d.ts +11 -0
  19. package/dist/legacy-projection-job-recovery.d.ts +29 -0
  20. package/dist/load-registry.d.ts +61 -6
  21. package/dist/observed-status.d.ts +1 -1
  22. package/dist/parse-args.d.ts +2 -12
  23. package/dist/preview-domain.d.ts +9 -0
  24. package/dist/reconcile-dry-run.d.ts +9 -0
  25. package/dist/run-command.d.ts +20 -4
  26. package/dist/schema.d.ts +126 -163
  27. package/dist/secrets-encrypt.d.ts +7 -13
  28. package/dist/secrets-push.d.ts +1 -1
  29. package/dist/ssh-key-bootstrap.d.ts +1 -26
  30. package/dist/stage-legacy-crossover.d.ts +25 -0
  31. package/dist/stress-command.d.ts +14 -0
  32. package/dist/stress-git-checkout.d.ts +21 -0
  33. package/dist/stress-health-monitor.d.ts +46 -0
  34. package/dist/upgrade.d.ts +10 -0
  35. package/dist/user-offboard.d.ts +61 -0
  36. package/dist/verify-user.d.ts +22 -0
  37. package/package.json +50 -43
  38. package/templates/rover-pilot/.env.schema +20 -5
  39. package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
  40. package/templates/rover-pilot/.github/workflows/build.yml +70 -17
  41. package/templates/rover-pilot/.github/workflows/deploy.yml +74 -57
  42. package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
  43. package/templates/rover-pilot/.github/workflows/health-watchdog-smoke.yml +95 -0
  44. package/templates/rover-pilot/.github/workflows/offboard.yml +107 -0
  45. package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
  46. package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
  47. package/templates/rover-pilot/README.md +13 -5
  48. package/templates/rover-pilot/deploy/scripts/create-predeploy-backup.ts +948 -0
  49. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -24
  50. package/templates/rover-pilot/deploy/scripts/helpers.ts +2 -0
  51. package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
  52. package/templates/rover-pilot/deploy/scripts/provision-server.ts +51 -21
  53. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +4 -0
  54. package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
  55. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +40 -9
  56. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
  57. package/templates/rover-pilot/deploy/scripts/update-dns.ts +72 -16
  58. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
  59. package/templates/rover-pilot/deploy/scripts/verify-runtime-image.ts +18 -0
  60. package/templates/rover-pilot/docs/canonical-crossover-record.md +107 -0
  61. package/templates/rover-pilot/docs/onboarding-checklist.md +23 -14
  62. package/templates/rover-pilot/docs/operator-playbook.md +357 -29
  63. package/templates/rover-pilot/docs/user-onboarding.md +48 -463
  64. package/templates/rover-pilot/package.json +1 -0
  65. package/templates/rover-pilot/pilot.yaml +6 -4
  66. package/dist/origin-ca.d.ts +0 -1
  67. package/dist/push-secrets.d.ts +0 -9
  68. package/dist/push-target.d.ts +0 -2
  69. package/dist/run-subprocess.d.ts +0 -6
@@ -0,0 +1,948 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { Buffer } from "node:buffer";
3
+ import { appendFileSync } from "node:fs";
4
+
5
+ const getPrototypeOf: (value: object) => object | null = Object.getPrototypeOf;
6
+
7
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
8
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
9
+ return false;
10
+ }
11
+
12
+ const prototype = getPrototypeOf(value);
13
+ return prototype === Object.prototype || prototype === null;
14
+ }
15
+
16
+ /**
17
+ * The slice of `@libsql/client` this script uses.
18
+ *
19
+ * Declared here because the import is dynamic — the package is optional, and
20
+ * only the libsql quick-check driver needs it.
21
+ */
22
+ interface LibsqlModule {
23
+ createClient(options: { url: string }): {
24
+ execute(sql: string): Promise<{
25
+ rows: Array<Record<string | number, unknown>>;
26
+ }>;
27
+ close(): void;
28
+ };
29
+ }
30
+
31
+ export const DEFAULT_PREDEPLOY_BACKUP_RETENTION_COUNT = 5;
32
+ export const PREDEPLOY_BACKUP_TOOL_VERSION = "brains-predeploy-backup-v1";
33
+
34
+ type DatabaseCaptureMethod = "vacuum" | "serialize";
35
+ type QuickCheckDriver = "bun" | "libsql";
36
+
37
+ export interface PredeployDatabaseSource {
38
+ source: string;
39
+ name: string;
40
+ method: DatabaseCaptureMethod;
41
+ quickCheck: QuickCheckDriver;
42
+ logicalVector: boolean;
43
+ }
44
+
45
+ export interface PredeployBackupMetadata {
46
+ snapshotId: string;
47
+ targetHandle: string;
48
+ host: string;
49
+ startedAt: string;
50
+ sourceVersion: string;
51
+ targetVersion: string;
52
+ toolVersion: string;
53
+ containerId: string;
54
+ imageId: string;
55
+ imageDigest: string;
56
+ }
57
+
58
+ export interface PredeployCaptureConfig {
59
+ backupDir: string;
60
+ contentRoot: string;
61
+ databases: PredeployDatabaseSource[];
62
+ metadata: PredeployBackupMetadata;
63
+ }
64
+
65
+ export type PredeployBackupResult =
66
+ | { applicable: false }
67
+ | {
68
+ applicable: true;
69
+ snapshotId: string;
70
+ backupPath: string;
71
+ sourceVersion: string;
72
+ targetVersion: string;
73
+ totalBytes: number;
74
+ durationSeconds: number;
75
+ verification: "passed";
76
+ };
77
+
78
+ interface CommandResult {
79
+ stdout: Uint8Array;
80
+ stderr: Uint8Array;
81
+ }
82
+
83
+ interface DatabaseCaptureRecord extends Omit<
84
+ PredeployDatabaseSource,
85
+ "quickCheck"
86
+ > {
87
+ status: "captured";
88
+ required: true;
89
+ startedAt: string;
90
+ completedAt: string;
91
+ bytes: number;
92
+ sha256: string;
93
+ quickCheckDriver: QuickCheckDriver;
94
+ quickCheck: string;
95
+ }
96
+
97
+ interface GitCaptureRecord {
98
+ head: string;
99
+ tree: string;
100
+ branch: string;
101
+ upstream: string;
102
+ observedRemoteHead: string;
103
+ aheadBehind: string;
104
+ clean: boolean;
105
+ stagedPatchBytes: number;
106
+ unstagedPatchBytes: number;
107
+ untrackedFiles: number;
108
+ ignoredFiles: number;
109
+ bundleVerified: true;
110
+ }
111
+
112
+ async function runCommand(
113
+ command: string,
114
+ args: string[],
115
+ cwd?: string,
116
+ env?: Record<string, string | undefined>,
117
+ ): Promise<CommandResult> {
118
+ const options = {
119
+ ...(cwd ? { cwd } : {}),
120
+ ...(env ? { env } : {}),
121
+ stdout: "pipe" as const,
122
+ stderr: "pipe" as const,
123
+ };
124
+ const child = Bun.spawn([command, ...args], options);
125
+ const [exitCode, stdout, stderr] = await Promise.all([
126
+ child.exited,
127
+ new Response(child.stdout).bytes(),
128
+ new Response(child.stderr).bytes(),
129
+ ]);
130
+ if (exitCode !== 0) {
131
+ throw new Error(`${command} ${args[0] ?? ""} failed with exit ${exitCode}`);
132
+ }
133
+ return { stdout, stderr };
134
+ }
135
+
136
+ function text(bytes: Uint8Array): string {
137
+ return new TextDecoder().decode(bytes).trim();
138
+ }
139
+
140
+ async function sha256(path: string): Promise<string> {
141
+ const hasher = new Bun.CryptoHasher("sha256");
142
+ for await (const chunk of Bun.file(path).stream()) {
143
+ hasher.update(chunk);
144
+ }
145
+ return hasher.digest("hex");
146
+ }
147
+
148
+ function nullDelimitedCount(bytes: Uint8Array): number {
149
+ let count = 0;
150
+ for (const byte of bytes) {
151
+ if (byte === 0) count += 1;
152
+ }
153
+ return count;
154
+ }
155
+
156
+ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
157
+ if (left.length !== right.length) return false;
158
+ for (let index = 0; index < left.length; index += 1) {
159
+ if (left[index] !== right[index]) return false;
160
+ }
161
+ return true;
162
+ }
163
+
164
+ function gitRemoteEnvironment():
165
+ Record<string, string | undefined> | undefined {
166
+ const token = process.env["GIT_SYNC_TOKEN"];
167
+ if (!token) return undefined;
168
+ const currentCount = Number(process.env["GIT_CONFIG_COUNT"] ?? 0);
169
+ const index =
170
+ Number.isSafeInteger(currentCount) && currentCount >= 0 ? currentCount : 0;
171
+ return {
172
+ ...process.env,
173
+ GIT_CONFIG_COUNT: String(index + 1),
174
+ [`GIT_CONFIG_KEY_${index}`]: "http.extraHeader",
175
+ [`GIT_CONFIG_VALUE_${index}`]: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`,
176
+ };
177
+ }
178
+
179
+ async function quickCheck(
180
+ path: string,
181
+ driver: QuickCheckDriver,
182
+ ): Promise<string> {
183
+ if (driver === "bun") {
184
+ const database = new Database(path, { readonly: true });
185
+ try {
186
+ const result: unknown = database.query("PRAGMA quick_check").get();
187
+ const row = isPlainRecord(result) ? result : null;
188
+ return String(row?.["quick_check"] ?? row?.["0"] ?? "");
189
+ } finally {
190
+ database.close(false);
191
+ }
192
+ }
193
+
194
+ const moduleName = "@libsql/client";
195
+ // Annotated rather than asserted: a dynamic import of an optional dependency
196
+ // resolves to `any`, so naming the slice this script uses is a checked
197
+ // assignment instead of a claim about the whole module.
198
+ const libsql: LibsqlModule = await import(moduleName);
199
+ const client = libsql.createClient({ url: `file:${path}` });
200
+ try {
201
+ const result = await client.execute("PRAGMA quick_check");
202
+ return String(result.rows[0]?.["quick_check"] ?? result.rows[0]?.[0] ?? "");
203
+ } finally {
204
+ client.close();
205
+ }
206
+ }
207
+
208
+ function vectorDigestDatabase(database: Database): {
209
+ counts: Record<string, number>;
210
+ sha256: string;
211
+ } {
212
+ const hasher = new Bun.CryptoHasher("sha256");
213
+ // A vector index keeps its entries in libSQL shadow tables. They exist only
214
+ // while an index does: a retired index drops its own shadow table, and a
215
+ // database that never had one has neither. The embeddings rows always count.
216
+ const existing = new Set(
217
+ database
218
+ .query("SELECT name FROM sqlite_master WHERE type = 'table'")
219
+ .all()
220
+ .filter(isPlainRecord)
221
+ .map((row) => String(row["name"])),
222
+ );
223
+ const tables = (
224
+ [
225
+ ["embeddings", "entity_id, entity_type"],
226
+ ["embeddings_embedding_idx_shadow", "index_key"],
227
+ ["libsql_vector_meta_shadow", "name"],
228
+ ] as const
229
+ ).filter(([table]) => table === "embeddings" || existing.has(table));
230
+ const counts: Record<string, number> = {};
231
+ for (const [table, order] of tables) {
232
+ hasher.update(`table:${table}\n`);
233
+ const selected: unknown[] = database
234
+ .query(`SELECT * FROM ${table} ORDER BY ${order}`)
235
+ .all();
236
+ const rows = selected.filter(isPlainRecord);
237
+ counts[table] = rows.length;
238
+ for (const row of rows) {
239
+ for (const [key, value] of Object.entries(row)) {
240
+ hasher.update(`${key}:`);
241
+ if (value instanceof Uint8Array) hasher.update(value);
242
+ else hasher.update(`${typeof value}:${String(value)}`);
243
+ hasher.update("\0");
244
+ }
245
+ hasher.update("\n");
246
+ }
247
+ }
248
+ return { counts, sha256: hasher.digest("hex") };
249
+ }
250
+
251
+ function vectorDigest(path: string): {
252
+ counts: Record<string, number>;
253
+ sha256: string;
254
+ } {
255
+ const database = new Database(path, { readonly: true });
256
+ try {
257
+ return vectorDigestDatabase(database);
258
+ } finally {
259
+ database.close(false);
260
+ }
261
+ }
262
+
263
+ async function captureDatabases(
264
+ config: PredeployCaptureConfig,
265
+ ): Promise<DatabaseCaptureRecord[]> {
266
+ const captures: DatabaseCaptureRecord[] = [];
267
+
268
+ for (const source of config.databases) {
269
+ if (!(await Bun.file(source.source).exists())) {
270
+ throw new Error(`Required database missing: ${source.source}`);
271
+ }
272
+ const destination = `${config.backupDir}/${source.name}`;
273
+ if (await Bun.file(destination).exists()) {
274
+ throw new Error(`Snapshot destination already exists: ${source.name}`);
275
+ }
276
+
277
+ const startedAt = new Date().toISOString();
278
+ const database = new Database(source.source, { readonly: true });
279
+ let originalVectorDigest:
280
+ { counts: Record<string, number>; sha256: string } | undefined;
281
+ if (source.method === "serialize") {
282
+ if (source.logicalVector) {
283
+ database.run("BEGIN");
284
+ originalVectorDigest = vectorDigestDatabase(database);
285
+ }
286
+ const bytes = database.serialize();
287
+ if (source.logicalVector) database.run("COMMIT");
288
+ database.close(false);
289
+ await Bun.write(destination, bytes);
290
+ } else {
291
+ const escapedDestination = destination.replaceAll("'", "''");
292
+ database.run(`VACUUM INTO '${escapedDestination}'`);
293
+ database.close(false);
294
+ }
295
+
296
+ const check = await quickCheck(destination, source.quickCheck);
297
+ if (check !== "ok") {
298
+ throw new Error(`${source.name}: PRAGMA quick_check=${check}`);
299
+ }
300
+ if (source.logicalVector) {
301
+ if (!originalVectorDigest) {
302
+ throw new Error(`${source.name}: source vector digest missing`);
303
+ }
304
+ const snapshot = vectorDigest(destination);
305
+ if (
306
+ originalVectorDigest.sha256 !== snapshot.sha256 ||
307
+ JSON.stringify(originalVectorDigest.counts) !==
308
+ JSON.stringify(snapshot.counts)
309
+ ) {
310
+ throw new Error(`${source.name}: logical vector verification failed`);
311
+ }
312
+ await Bun.write(
313
+ `${config.backupDir}/${source.name}.logical-verify.json`,
314
+ `${JSON.stringify({ original: originalVectorDigest, snapshot, exact: true }, null, 2)}\n`,
315
+ );
316
+ }
317
+
318
+ const file = Bun.file(destination);
319
+ const { quickCheck: quickCheckDriver, ...sourceMetadata } = source;
320
+ captures.push({
321
+ ...sourceMetadata,
322
+ status: "captured",
323
+ required: true,
324
+ startedAt,
325
+ completedAt: new Date().toISOString(),
326
+ bytes: file.size,
327
+ sha256: await sha256(destination),
328
+ quickCheckDriver,
329
+ quickCheck: check,
330
+ });
331
+ }
332
+
333
+ await Bun.write(
334
+ `${config.backupDir}/database-captures.json`,
335
+ `${JSON.stringify(captures, null, 2)}\n`,
336
+ );
337
+ return captures;
338
+ }
339
+
340
+ async function captureGitCheckout(
341
+ contentRoot: string,
342
+ backupDir: string,
343
+ ): Promise<GitCaptureRecord> {
344
+ const git = async (args: string[]): Promise<CommandResult> =>
345
+ runCommand("git", args, contentRoot);
346
+ const head = text((await git(["rev-parse", "HEAD"])).stdout);
347
+ const tree = text((await git(["rev-parse", "HEAD^{tree}"])).stdout);
348
+ const branch = text((await git(["branch", "--show-current"])).stdout);
349
+ const upstream = text(
350
+ (await git(["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout,
351
+ );
352
+ const remoteBranch = upstream.replace(/^[^/]+\//, "");
353
+ if (!remoteBranch) throw new Error("Content checkout has no remote branch");
354
+ const observedRemote = text(
355
+ (
356
+ await runCommand(
357
+ "git",
358
+ ["ls-remote", "origin", `refs/heads/${remoteBranch}`],
359
+ contentRoot,
360
+ gitRemoteEnvironment(),
361
+ )
362
+ ).stdout,
363
+ ).split(/\s+/)[0];
364
+ if (!observedRemote) throw new Error("Content remote head is unavailable");
365
+ const aheadBehind = text(
366
+ (await git(["rev-list", "--left-right", "--count", `HEAD...${upstream}`]))
367
+ .stdout,
368
+ );
369
+ const status = await git([
370
+ "status",
371
+ "--porcelain=v1",
372
+ "--branch",
373
+ "--untracked-files=all",
374
+ ]);
375
+ const dirtyStatus = await git([
376
+ "status",
377
+ "--porcelain=v1",
378
+ "--untracked-files=all",
379
+ ]);
380
+ const staged = await git(["diff", "--cached", "--binary", "--full-index"]);
381
+ const unstaged = await git(["diff", "--binary", "--full-index"]);
382
+ const untracked = await git([
383
+ "ls-files",
384
+ "--others",
385
+ "--exclude-standard",
386
+ "-z",
387
+ ]);
388
+ const ignored = await git([
389
+ "ls-files",
390
+ "--others",
391
+ "--ignored",
392
+ "--exclude-standard",
393
+ "-z",
394
+ ]);
395
+ const refs = await git(["show-ref"]);
396
+
397
+ await Promise.all([
398
+ Bun.write(`${backupDir}/content-status.txt`, status.stdout),
399
+ Bun.write(`${backupDir}/content-staged.patch`, staged.stdout),
400
+ Bun.write(`${backupDir}/content-unstaged.patch`, unstaged.stdout),
401
+ Bun.write(`${backupDir}/content-untracked.zlist`, untracked.stdout),
402
+ Bun.write(`${backupDir}/content-ignored.zlist`, ignored.stdout),
403
+ Bun.write(`${backupDir}/content-head.txt`, `${head}\n`),
404
+ Bun.write(`${backupDir}/content-tree.txt`, `${tree}\n`),
405
+ Bun.write(
406
+ `${backupDir}/content-observed-remote.txt`,
407
+ `${observedRemote}\n`,
408
+ ),
409
+ Bun.write(`${backupDir}/content-ahead-behind.txt`, `${aheadBehind}\n`),
410
+ ]);
411
+
412
+ await git(["bundle", "create", `${backupDir}/content.bundle`, "--all"]);
413
+ const bundleVerify = await git([
414
+ "bundle",
415
+ "verify",
416
+ `${backupDir}/content.bundle`,
417
+ ]);
418
+ await Bun.write(
419
+ `${backupDir}/content-bundle-verify.txt`,
420
+ `${new TextDecoder().decode(bundleVerify.stdout)}${new TextDecoder().decode(bundleVerify.stderr)}`,
421
+ );
422
+ await Bun.write(`${backupDir}/content-refs.txt`, refs.stdout);
423
+
424
+ for (const [listName, archiveName] of [
425
+ ["content-untracked.zlist", "content-untracked.tar"],
426
+ ["content-ignored.zlist", "content-ignored.tar"],
427
+ ] as const) {
428
+ const archivePath = `${backupDir}/${archiveName}`;
429
+ await runCommand("tar", [
430
+ "--null",
431
+ "--no-recursion",
432
+ "-C",
433
+ contentRoot,
434
+ "-cf",
435
+ archivePath,
436
+ `--files-from=${backupDir}/${listName}`,
437
+ ]);
438
+ await runCommand("tar", [
439
+ "--compare",
440
+ `--file=${archivePath}`,
441
+ "-C",
442
+ contentRoot,
443
+ ]);
444
+ }
445
+
446
+ const [
447
+ postHead,
448
+ postStatus,
449
+ postStaged,
450
+ postUnstaged,
451
+ postUntracked,
452
+ postIgnored,
453
+ postRefs,
454
+ ] = await Promise.all([
455
+ git(["rev-parse", "HEAD"]),
456
+ git(["status", "--porcelain=v1", "--branch", "--untracked-files=all"]),
457
+ git(["diff", "--cached", "--binary", "--full-index"]),
458
+ git(["diff", "--binary", "--full-index"]),
459
+ git(["ls-files", "--others", "--exclude-standard", "-z"]),
460
+ git(["ls-files", "--others", "--ignored", "--exclude-standard", "-z"]),
461
+ git(["show-ref"]),
462
+ ]);
463
+ const stable =
464
+ text(postHead.stdout) === head &&
465
+ bytesEqual(postStatus.stdout, status.stdout) &&
466
+ bytesEqual(postStaged.stdout, staged.stdout) &&
467
+ bytesEqual(postUnstaged.stdout, unstaged.stdout) &&
468
+ bytesEqual(postUntracked.stdout, untracked.stdout) &&
469
+ bytesEqual(postIgnored.stdout, ignored.stdout) &&
470
+ bytesEqual(postRefs.stdout, refs.stdout);
471
+ if (!stable) {
472
+ throw new Error("Content checkout changed during snapshot capture");
473
+ }
474
+
475
+ return {
476
+ head,
477
+ tree,
478
+ branch,
479
+ upstream,
480
+ observedRemoteHead: observedRemote,
481
+ aheadBehind,
482
+ clean: dirtyStatus.stdout.length === 0,
483
+ stagedPatchBytes: staged.stdout.length,
484
+ unstagedPatchBytes: unstaged.stdout.length,
485
+ untrackedFiles: nullDelimitedCount(untracked.stdout),
486
+ ignoredFiles: nullDelimitedCount(ignored.stdout),
487
+ bundleVerified: true,
488
+ };
489
+ }
490
+
491
+ export async function capturePredeployBackup(
492
+ config: PredeployCaptureConfig,
493
+ ): Promise<void> {
494
+ const databases = await captureDatabases(config);
495
+ const git = await captureGitCheckout(config.contentRoot, config.backupDir);
496
+ const manifest = {
497
+ schemaVersion: 1,
498
+ outcome: "verified",
499
+ ...config.metadata,
500
+ completedAt: new Date().toISOString(),
501
+ databases,
502
+ git,
503
+ };
504
+ await Bun.write(
505
+ `${config.backupDir}/manifest.json`,
506
+ `${JSON.stringify(manifest, null, 2)}\n`,
507
+ );
508
+ }
509
+
510
+ function captureConfigFromEnvironment(): PredeployCaptureConfig {
511
+ const value = (name: string): string => {
512
+ const result = process.env[name];
513
+ if (!result) throw new Error(`Missing ${name}`);
514
+ return result;
515
+ };
516
+ return {
517
+ backupDir: value("BACKUP_DIR"),
518
+ contentRoot: "/app/brain-data",
519
+ databases: [
520
+ {
521
+ source: "/data/brain.db",
522
+ name: "brain.db",
523
+ method: "vacuum",
524
+ quickCheck: "bun",
525
+ logicalVector: false,
526
+ },
527
+ {
528
+ source: "/data/brain-jobs.db",
529
+ name: "brain-jobs.db",
530
+ method: "vacuum",
531
+ quickCheck: "bun",
532
+ logicalVector: false,
533
+ },
534
+ {
535
+ source: "/data/conversations.db",
536
+ name: "conversations.db",
537
+ method: "vacuum",
538
+ quickCheck: "bun",
539
+ logicalVector: false,
540
+ },
541
+ {
542
+ source: "/data/embeddings.db",
543
+ name: "embeddings.db",
544
+ method: "serialize",
545
+ quickCheck: "libsql",
546
+ logicalVector: true,
547
+ },
548
+ {
549
+ source: "/data/runtime-state.db",
550
+ name: "runtime-state.db",
551
+ method: "vacuum",
552
+ quickCheck: "bun",
553
+ logicalVector: false,
554
+ },
555
+ {
556
+ source: "/app/data/auth/auth.db",
557
+ name: "auth.db",
558
+ method: "vacuum",
559
+ quickCheck: "bun",
560
+ logicalVector: false,
561
+ },
562
+ ],
563
+ metadata: {
564
+ snapshotId: value("SNAPSHOT_ID"),
565
+ targetHandle: value("TARGET_HANDLE"),
566
+ host: value("HOST_IDENTIFIER"),
567
+ startedAt: value("STARTED_AT"),
568
+ sourceVersion: value("SOURCE_VERSION"),
569
+ targetVersion: value("TARGET_VERSION"),
570
+ toolVersion: value("TOOL_VERSION"),
571
+ containerId: value("CONTAINER_ID"),
572
+ imageId: value("SOURCE_IMAGE_ID"),
573
+ imageDigest: value("SOURCE_IMAGE_DIGEST"),
574
+ },
575
+ };
576
+ }
577
+
578
+ function shellSafe(value: string, name: string): string {
579
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) {
580
+ throw new Error(`${name} contains unsupported characters`);
581
+ }
582
+ return value;
583
+ }
584
+
585
+ /**
586
+ * Run inside the current runtime before capture. The snapshot proves itself
587
+ * (transactional copies, quick_check, checksums), so it needs a serving
588
+ * runtime with no queued work, not a healthy one: a deploy is often the fix
589
+ * for whatever a plugin reports as degraded. Degradation is named, not refused.
590
+ * A job whose lease expired is abandoned, not running: the next worker
591
+ * reclaims and reruns it, so it is named and backed up as it stands.
592
+ */
593
+ export function renderPredeployReadinessProgram(
594
+ healthUrl: string = "http://127.0.0.1:8080/health/ready",
595
+ ): string {
596
+ return `const response = await fetch(${JSON.stringify(healthUrl)});
597
+ const health = await response.json();
598
+ const queue = health.resources?.queue;
599
+ if (response.status !== 200 || health.status !== "ready") {
600
+ console.error("pre-deploy snapshot: current runtime is not ready");
601
+ process.exit(1);
602
+ }
603
+ const abandoned = queue?.staleLeaseCount ?? 0;
604
+ if (queue && (queue.totals?.pending !== 0 || queue.totals?.processing !== abandoned)) {
605
+ console.error("pre-deploy snapshot: job queue is not idle");
606
+ process.exit(1);
607
+ }
608
+ if (abandoned > 0) {
609
+ console.error("pre-deploy snapshot: " + abandoned + " abandoned job(s) will rerun after the deploy");
610
+ }
611
+ if (health.operationalStatus !== "operational") {
612
+ const degraded = (health.checks ?? []).filter((check) => check.status !== "healthy").map((check) => check.name);
613
+ console.error("pre-deploy snapshot: runtime is degraded (" + degraded.join(", ") + "); backing it up anyway");
614
+ }
615
+ `;
616
+ }
617
+
618
+ /** The runtime's own notices from a successful capture, as workflow warnings;
619
+ * other remote output (ssh host notes) stays out of the log. */
620
+ export function predeployBackupNotices(stderr: string): string[] {
621
+ const prefix = "pre-deploy snapshot: ";
622
+ return stderr
623
+ .split("\n")
624
+ .filter((line) => line.startsWith(prefix))
625
+ .map(
626
+ (line) =>
627
+ `::warning title=Pre-deploy backup::${line.slice(prefix.length).trim()}`,
628
+ );
629
+ }
630
+
631
+ export function renderPredeployBackupRemoteScript(options?: {
632
+ captureProgramBase64?: string;
633
+ }): string {
634
+ const captureProgramBase64 =
635
+ options?.captureProgramBase64 ??
636
+ Buffer.from("capture-program").toString("base64");
637
+ return `#!/usr/bin/env bash
638
+ set -euo pipefail
639
+ umask 077
640
+
641
+ TARGET_HANDLE="$1"
642
+ TARGET_VERSION="$2"
643
+ SERVICE_NAME="$3"
644
+ DEFAULT_RETENTION_COUNT="$4"
645
+ TOOL_VERSION="${PREDEPLOY_BACKUP_TOOL_VERSION}"
646
+ CAPTURE_PROGRAM_BASE64="${captureProgramBase64}"
647
+ state_root="/opt/brain-state"
648
+ runtime_root="/opt/brain-runtime"
649
+ content_root="/opt/brain-data"
650
+ backup_root="\${state_root}/backups"
651
+
652
+ mapfile -t containers < <(docker ps --filter "label=service=\${SERVICE_NAME}" --filter label=role=web --format '{{.ID}}')
653
+ if [ "\${#containers[@]}" -gt 1 ]; then
654
+ echo "pre-deploy snapshot: ambiguous runtime" >&2
655
+ exit 1
656
+ fi
657
+ if [ "\${#containers[@]}" -eq 0 ]; then
658
+ has_state=false
659
+ for path in "$state_root" "$runtime_root" "$content_root" /opt/brain.yaml /opt/brain-dist; do
660
+ if [ -f "$path" ] || { [ -d "$path" ] && find "$path" -mindepth 1 -print -quit 2>/dev/null | grep -q .; }; then
661
+ has_state=true
662
+ break
663
+ fi
664
+ done
665
+ if [ "$has_state" = true ]; then
666
+ echo "pre-deploy snapshot: persistent state exists without an identifiable runtime" >&2
667
+ exit 1
668
+ fi
669
+ echo "pre-deploy snapshot: not applicable (new server)"
670
+ exit 0
671
+ fi
672
+ container="\${containers[0]}"
673
+ status="$(docker inspect "$container" --format '{{.State.Status}}')"
674
+ health="$(docker inspect "$container" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}')"
675
+ if [ "$status" != running ] || [ "$health" != healthy ]; then
676
+ echo "pre-deploy snapshot: current runtime is not healthy" >&2
677
+ exit 1
678
+ fi
679
+
680
+ docker exec "$container" bun -e '
681
+ ${renderPredeployReadinessProgram()}'
682
+
683
+ required_databases=(
684
+ "$state_root/brain.db"
685
+ "$state_root/brain-jobs.db"
686
+ "$state_root/conversations.db"
687
+ "$state_root/embeddings.db"
688
+ "$state_root/runtime-state.db"
689
+ "$runtime_root/auth/auth.db"
690
+ )
691
+ for database in "\${required_databases[@]}"; do
692
+ if [ ! -f "$database" ]; then
693
+ echo "pre-deploy snapshot: required database missing" >&2
694
+ exit 1
695
+ fi
696
+ done
697
+ if [ ! -d "$content_root/.git" ]; then
698
+ echo "pre-deploy snapshot: content checkout missing" >&2
699
+ exit 1
700
+ fi
701
+
702
+ prune_verified() {
703
+ local limit="$1"
704
+ local preserve="\${2:-}"
705
+ local candidates=()
706
+ shopt -s nullglob
707
+ for candidate in "$backup_root"/predeploy-"$TARGET_HANDLE"-*; do
708
+ case "$candidate" in *.incomplete) continue ;; esac
709
+ [ -d "$candidate" ] || continue
710
+ [ -f "$candidate/manifest.json" ] || continue
711
+ [ -f "$candidate/manifest.sha256" ] || continue
712
+ grep -q '"outcome": "verified"' "$candidate/manifest.json" || continue
713
+ candidates+=("$candidate")
714
+ done
715
+ shopt -u nullglob
716
+ if [ "\${#candidates[@]}" -le "$limit" ]; then return; fi
717
+ mapfile -t candidates < <(printf '%s\n' "\${candidates[@]}" | sort)
718
+ local remove_count=$((\${#candidates[@]} - limit))
719
+ for candidate in "\${candidates[@]}"; do
720
+ [ "$remove_count" -gt 0 ] || break
721
+ [ "$candidate" != "$preserve" ] || continue
722
+ rm -rf -- "$candidate"
723
+ remove_count=$((remove_count - 1))
724
+ done
725
+ }
726
+
727
+ install -d -m 700 "$backup_root"
728
+ prune_verified "$DEFAULT_RETENTION_COUNT"
729
+ state_bytes=0
730
+ for database in "\${required_databases[@]}"; do
731
+ state_bytes=$((state_bytes + $(stat -c %s "$database")))
732
+ done
733
+ content_bytes="$(du -sb "$content_root" | cut -f1)"
734
+ base_bytes=$((state_bytes + content_bytes))
735
+ required_bytes=$((base_bytes + base_bytes / 2 + 1073741824))
736
+ available_bytes="$(df --output=avail -B1 "$state_root" | tail -1 | tr -d ' ')"
737
+ if [ "$available_bytes" -lt "$required_bytes" ]; then
738
+ echo "pre-deploy snapshot: insufficient disk" >&2
739
+ exit 1
740
+ fi
741
+
742
+ started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
743
+ stamp="$(date -u +%Y%m%dT%H%M%SZ)"
744
+ target_label="\${TARGET_VERSION:0:12}"
745
+ snapshot_id="predeploy-\${TARGET_HANDLE}-\${target_label}-\${stamp}"
746
+ incomplete="\${backup_root}/\${snapshot_id}.incomplete"
747
+ final="\${backup_root}/\${snapshot_id}"
748
+ container_backup="/data/backups/\${snapshot_id}.incomplete"
749
+ test ! -e "$incomplete"
750
+ test ! -e "$final"
751
+ install -d -m 700 "$incomplete"
752
+
753
+ source_version="$(docker exec "$container" bun -e 'const pkg = await Bun.file("/app/node_modules/@rizom/brain/package.json").json(); process.stdout.write(pkg.version)')"
754
+ source_image_id="$(docker inspect "$container" --format '{{.Image}}')"
755
+ source_image_digest="$(docker image inspect "$source_image_id" --format '{{join .RepoDigests ","}}')"
756
+ host_identifier="$(hostname)"
757
+ docker inspect "$container" --format '{{json .State}}' > "$incomplete/container-state.json"
758
+ docker inspect "$container" --format '{{json .Mounts}}' > "$incomplete/container-mounts.json"
759
+ printf '%s\n' "$container" > "$incomplete/container-id.txt"
760
+ printf '%s\n' "$source_image_id" > "$incomplete/container-image-id.txt"
761
+ printf '%s\n' "$source_image_digest" > "$incomplete/container-image-digest.txt"
762
+ cp /opt/brain.yaml "$incomplete/brain.yaml"
763
+
764
+ printf '%s' "$CAPTURE_PROGRAM_BASE64" | base64 -d | docker exec -i \
765
+ -e BACKUP_DIR="$container_backup" \
766
+ -e SNAPSHOT_ID="$snapshot_id" \
767
+ -e TARGET_HANDLE="$TARGET_HANDLE" \
768
+ -e HOST_IDENTIFIER="$host_identifier" \
769
+ -e STARTED_AT="$started_at" \
770
+ -e SOURCE_VERSION="$source_version" \
771
+ -e TARGET_VERSION="$TARGET_VERSION" \
772
+ -e TOOL_VERSION="$TOOL_VERSION" \
773
+ -e CONTAINER_ID="$container" \
774
+ -e SOURCE_IMAGE_ID="$source_image_id" \
775
+ -e SOURCE_IMAGE_DIGEST="$source_image_digest" \
776
+ "$container" bun run - --capture
777
+
778
+ chmod 600 "$incomplete"/*
779
+ (
780
+ cd "$incomplete"
781
+ sha256sum manifest.json > manifest-checksum.txt
782
+ chmod 600 manifest-checksum.txt
783
+ : > manifest.sha256
784
+ while IFS= read -r file; do
785
+ sha256sum "$file" >> manifest.sha256
786
+ done < <(find . -mindepth 1 -maxdepth 1 -type f ! -name manifest.sha256 -printf '%f\n' | sort)
787
+ chmod 600 manifest.sha256
788
+ sha256sum --check manifest.sha256 >/dev/null
789
+ )
790
+
791
+ test "$(stat -c %a "$incomplete")" = 700
792
+ if find "$incomplete" -mindepth 1 -maxdepth 1 -type f ! -perm 600 -print -quit | grep -q .; then
793
+ echo "pre-deploy snapshot: invalid file permissions" >&2
794
+ exit 1
795
+ fi
796
+ mv "$incomplete" "$final"
797
+ (cd "$final" && sha256sum --check manifest.sha256 >/dev/null)
798
+ prune_verified "$DEFAULT_RETENTION_COUNT" "$final"
799
+
800
+ duration_seconds=$(( $(date -u +%s) - $(date -u -d "$started_at" +%s) ))
801
+ total_bytes="$(du -sb "$final" | cut -f1)"
802
+ echo "SNAPSHOT_ID=$snapshot_id"
803
+ echo "BACKUP=$final"
804
+ echo "SOURCE_VERSION=$source_version"
805
+ echo "TARGET_VERSION=$TARGET_VERSION"
806
+ echo "TOTAL_BYTES=$total_bytes"
807
+ echo "DURATION_SECONDS=$duration_seconds"
808
+ echo "VERIFICATION=passed"
809
+ `;
810
+ }
811
+
812
+ export function parsePredeployBackupOutput(
813
+ output: string,
814
+ ): PredeployBackupResult {
815
+ if (output.includes("pre-deploy snapshot: not applicable (new server)")) {
816
+ return { applicable: false };
817
+ }
818
+ const values = new Map<string, string>();
819
+ for (const line of output.split(/\r?\n/)) {
820
+ const index = line.indexOf("=");
821
+ if (index > 0) values.set(line.slice(0, index), line.slice(index + 1));
822
+ }
823
+ const snapshotId = values.get("SNAPSHOT_ID");
824
+ const backupPath = values.get("BACKUP");
825
+ const sourceVersion = values.get("SOURCE_VERSION");
826
+ const targetVersion = values.get("TARGET_VERSION");
827
+ const totalBytes = Number(values.get("TOTAL_BYTES"));
828
+ const durationSeconds = Number(values.get("DURATION_SECONDS"));
829
+ if (
830
+ !snapshotId ||
831
+ !backupPath ||
832
+ !sourceVersion ||
833
+ !targetVersion ||
834
+ !Number.isSafeInteger(totalBytes) ||
835
+ totalBytes < 0 ||
836
+ !Number.isSafeInteger(durationSeconds) ||
837
+ durationSeconds < 0 ||
838
+ values.get("VERIFICATION") !== "passed"
839
+ ) {
840
+ throw new Error("Incomplete predeploy backup result");
841
+ }
842
+ return {
843
+ applicable: true,
844
+ snapshotId,
845
+ backupPath,
846
+ sourceVersion,
847
+ targetVersion,
848
+ totalBytes,
849
+ durationSeconds,
850
+ verification: "passed",
851
+ };
852
+ }
853
+
854
+ function appendWorkflowResult(result: PredeployBackupResult): void {
855
+ const outputPath = process.env["GITHUB_OUTPUT"];
856
+ const summaryPath = process.env["GITHUB_STEP_SUMMARY"];
857
+ if (outputPath) {
858
+ appendFileSync(
859
+ outputPath,
860
+ result.applicable
861
+ ? `applicable=true\nsnapshot_id=${result.snapshotId}\nbackup_path=${result.backupPath}\n`
862
+ : "applicable=false\n",
863
+ );
864
+ }
865
+ if (summaryPath) {
866
+ appendFileSync(
867
+ summaryPath,
868
+ result.applicable
869
+ ? `### Verified pre-deploy snapshot\n- Snapshot: \`${result.snapshotId}\`\n- Path: \`${result.backupPath}\`\n- Bytes: \`${result.totalBytes}\`\n- Duration: \`${result.durationSeconds}s\`\n- Verification: passed\n`
870
+ : "### Pre-deploy snapshot\nNot applicable: new server with no persistent state.\n",
871
+ );
872
+ }
873
+ }
874
+
875
+ export async function runPredeployBackup(): Promise<PredeployBackupResult> {
876
+ const serverIp = shellSafe(process.env["SERVER_IP"] ?? "", "SERVER_IP");
877
+ const targetHandle = shellSafe(
878
+ process.env["TARGET_HANDLE"] ?? "",
879
+ "TARGET_HANDLE",
880
+ );
881
+ const targetVersion = shellSafe(
882
+ process.env["TARGET_VERSION"] ?? "",
883
+ "TARGET_VERSION",
884
+ );
885
+ const serviceName = shellSafe(
886
+ process.env["SERVICE_NAME"] ?? "",
887
+ "SERVICE_NAME",
888
+ );
889
+ const sshUser = shellSafe(process.env["SSH_USER"] ?? "root", "SSH_USER");
890
+ const retention = Number(
891
+ process.env["PREDEPLOY_BACKUP_RETENTION_COUNT"] ??
892
+ DEFAULT_PREDEPLOY_BACKUP_RETENTION_COUNT,
893
+ );
894
+ if (!Number.isInteger(retention) || retention < 1 || retention > 100) {
895
+ throw new Error(
896
+ "PREDEPLOY_BACKUP_RETENTION_COUNT must be between 1 and 100",
897
+ );
898
+ }
899
+
900
+ const source = await Bun.file(import.meta.path).arrayBuffer();
901
+ const remoteScript = renderPredeployBackupRemoteScript({
902
+ captureProgramBase64: Buffer.from(source).toString("base64"),
903
+ });
904
+ const child = Bun.spawn(
905
+ [
906
+ "ssh",
907
+ `${sshUser}@${serverIp}`,
908
+ "bash",
909
+ "-s",
910
+ "--",
911
+ targetHandle,
912
+ targetVersion,
913
+ serviceName,
914
+ String(retention),
915
+ ],
916
+ { stdin: "pipe", stdout: "pipe", stderr: "pipe" },
917
+ );
918
+ await child.stdin.write(remoteScript);
919
+ await child.stdin.end();
920
+ const [exitCode, stdout, stderr] = await Promise.all([
921
+ child.exited,
922
+ new Response(child.stdout).text(),
923
+ new Response(child.stderr).text(),
924
+ ]);
925
+ if (exitCode !== 0) {
926
+ const diagnostic = stderr.trim().slice(0, 1000);
927
+ throw new Error(
928
+ `Predeploy backup failed${diagnostic ? `: ${diagnostic}` : ""}`,
929
+ );
930
+ }
931
+ for (const notice of predeployBackupNotices(stderr)) console.log(notice);
932
+ const result = parsePredeployBackupOutput(stdout);
933
+ appendWorkflowResult(result);
934
+ console.log(
935
+ result.applicable
936
+ ? `pre-deploy snapshot verified: ${result.snapshotId}`
937
+ : "pre-deploy snapshot: not applicable (new server)",
938
+ );
939
+ return result;
940
+ }
941
+
942
+ if (import.meta.main) {
943
+ if (process.argv.includes("--capture")) {
944
+ await capturePredeployBackup(captureConfigFromEnvironment());
945
+ } else {
946
+ await runPredeployBackup();
947
+ }
948
+ }