@rizom/ops 0.2.0-alpha.45 → 0.2.0-alpha.451

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 (70) hide show
  1. package/README.md +82 -1
  2. package/dist/age-key-bootstrap.d.ts +1 -1
  3. package/dist/brains-ops.js +736 -335
  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 +736 -323
  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 -42
  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 +936 -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
  70. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
@@ -0,0 +1,936 @@
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
+ const tables = [
214
+ ["embeddings", "entity_id, entity_type"],
215
+ ["embeddings_embedding_idx_shadow", "index_key"],
216
+ ["libsql_vector_meta_shadow", "name"],
217
+ ] as const;
218
+ const counts: Record<string, number> = {};
219
+ for (const [table, order] of tables) {
220
+ hasher.update(`table:${table}\n`);
221
+ const selected: unknown[] = database
222
+ .query(`SELECT * FROM ${table} ORDER BY ${order}`)
223
+ .all();
224
+ const rows = selected.filter(isPlainRecord);
225
+ counts[table] = rows.length;
226
+ for (const row of rows) {
227
+ for (const [key, value] of Object.entries(row)) {
228
+ hasher.update(`${key}:`);
229
+ if (value instanceof Uint8Array) hasher.update(value);
230
+ else hasher.update(`${typeof value}:${String(value)}`);
231
+ hasher.update("\0");
232
+ }
233
+ hasher.update("\n");
234
+ }
235
+ }
236
+ return { counts, sha256: hasher.digest("hex") };
237
+ }
238
+
239
+ function vectorDigest(path: string): {
240
+ counts: Record<string, number>;
241
+ sha256: string;
242
+ } {
243
+ const database = new Database(path, { readonly: true });
244
+ try {
245
+ return vectorDigestDatabase(database);
246
+ } finally {
247
+ database.close(false);
248
+ }
249
+ }
250
+
251
+ async function captureDatabases(
252
+ config: PredeployCaptureConfig,
253
+ ): Promise<DatabaseCaptureRecord[]> {
254
+ const captures: DatabaseCaptureRecord[] = [];
255
+
256
+ for (const source of config.databases) {
257
+ if (!(await Bun.file(source.source).exists())) {
258
+ throw new Error(`Required database missing: ${source.source}`);
259
+ }
260
+ const destination = `${config.backupDir}/${source.name}`;
261
+ if (await Bun.file(destination).exists()) {
262
+ throw new Error(`Snapshot destination already exists: ${source.name}`);
263
+ }
264
+
265
+ const startedAt = new Date().toISOString();
266
+ const database = new Database(source.source, { readonly: true });
267
+ let originalVectorDigest:
268
+ { counts: Record<string, number>; sha256: string } | undefined;
269
+ if (source.method === "serialize") {
270
+ if (source.logicalVector) {
271
+ database.run("BEGIN");
272
+ originalVectorDigest = vectorDigestDatabase(database);
273
+ }
274
+ const bytes = database.serialize();
275
+ if (source.logicalVector) database.run("COMMIT");
276
+ database.close(false);
277
+ await Bun.write(destination, bytes);
278
+ } else {
279
+ const escapedDestination = destination.replaceAll("'", "''");
280
+ database.run(`VACUUM INTO '${escapedDestination}'`);
281
+ database.close(false);
282
+ }
283
+
284
+ const check = await quickCheck(destination, source.quickCheck);
285
+ if (check !== "ok") {
286
+ throw new Error(`${source.name}: PRAGMA quick_check=${check}`);
287
+ }
288
+ if (source.logicalVector) {
289
+ if (!originalVectorDigest) {
290
+ throw new Error(`${source.name}: source vector digest missing`);
291
+ }
292
+ const snapshot = vectorDigest(destination);
293
+ if (
294
+ originalVectorDigest.sha256 !== snapshot.sha256 ||
295
+ JSON.stringify(originalVectorDigest.counts) !==
296
+ JSON.stringify(snapshot.counts)
297
+ ) {
298
+ throw new Error(`${source.name}: logical vector verification failed`);
299
+ }
300
+ await Bun.write(
301
+ `${config.backupDir}/${source.name}.logical-verify.json`,
302
+ `${JSON.stringify({ original: originalVectorDigest, snapshot, exact: true }, null, 2)}\n`,
303
+ );
304
+ }
305
+
306
+ const file = Bun.file(destination);
307
+ const { quickCheck: quickCheckDriver, ...sourceMetadata } = source;
308
+ captures.push({
309
+ ...sourceMetadata,
310
+ status: "captured",
311
+ required: true,
312
+ startedAt,
313
+ completedAt: new Date().toISOString(),
314
+ bytes: file.size,
315
+ sha256: await sha256(destination),
316
+ quickCheckDriver,
317
+ quickCheck: check,
318
+ });
319
+ }
320
+
321
+ await Bun.write(
322
+ `${config.backupDir}/database-captures.json`,
323
+ `${JSON.stringify(captures, null, 2)}\n`,
324
+ );
325
+ return captures;
326
+ }
327
+
328
+ async function captureGitCheckout(
329
+ contentRoot: string,
330
+ backupDir: string,
331
+ ): Promise<GitCaptureRecord> {
332
+ const git = async (args: string[]): Promise<CommandResult> =>
333
+ runCommand("git", args, contentRoot);
334
+ const head = text((await git(["rev-parse", "HEAD"])).stdout);
335
+ const tree = text((await git(["rev-parse", "HEAD^{tree}"])).stdout);
336
+ const branch = text((await git(["branch", "--show-current"])).stdout);
337
+ const upstream = text(
338
+ (await git(["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout,
339
+ );
340
+ const remoteBranch = upstream.replace(/^[^/]+\//, "");
341
+ if (!remoteBranch) throw new Error("Content checkout has no remote branch");
342
+ const observedRemote = text(
343
+ (
344
+ await runCommand(
345
+ "git",
346
+ ["ls-remote", "origin", `refs/heads/${remoteBranch}`],
347
+ contentRoot,
348
+ gitRemoteEnvironment(),
349
+ )
350
+ ).stdout,
351
+ ).split(/\s+/)[0];
352
+ if (!observedRemote) throw new Error("Content remote head is unavailable");
353
+ const aheadBehind = text(
354
+ (await git(["rev-list", "--left-right", "--count", `HEAD...${upstream}`]))
355
+ .stdout,
356
+ );
357
+ const status = await git([
358
+ "status",
359
+ "--porcelain=v1",
360
+ "--branch",
361
+ "--untracked-files=all",
362
+ ]);
363
+ const dirtyStatus = await git([
364
+ "status",
365
+ "--porcelain=v1",
366
+ "--untracked-files=all",
367
+ ]);
368
+ const staged = await git(["diff", "--cached", "--binary", "--full-index"]);
369
+ const unstaged = await git(["diff", "--binary", "--full-index"]);
370
+ const untracked = await git([
371
+ "ls-files",
372
+ "--others",
373
+ "--exclude-standard",
374
+ "-z",
375
+ ]);
376
+ const ignored = await git([
377
+ "ls-files",
378
+ "--others",
379
+ "--ignored",
380
+ "--exclude-standard",
381
+ "-z",
382
+ ]);
383
+ const refs = await git(["show-ref"]);
384
+
385
+ await Promise.all([
386
+ Bun.write(`${backupDir}/content-status.txt`, status.stdout),
387
+ Bun.write(`${backupDir}/content-staged.patch`, staged.stdout),
388
+ Bun.write(`${backupDir}/content-unstaged.patch`, unstaged.stdout),
389
+ Bun.write(`${backupDir}/content-untracked.zlist`, untracked.stdout),
390
+ Bun.write(`${backupDir}/content-ignored.zlist`, ignored.stdout),
391
+ Bun.write(`${backupDir}/content-head.txt`, `${head}\n`),
392
+ Bun.write(`${backupDir}/content-tree.txt`, `${tree}\n`),
393
+ Bun.write(
394
+ `${backupDir}/content-observed-remote.txt`,
395
+ `${observedRemote}\n`,
396
+ ),
397
+ Bun.write(`${backupDir}/content-ahead-behind.txt`, `${aheadBehind}\n`),
398
+ ]);
399
+
400
+ await git(["bundle", "create", `${backupDir}/content.bundle`, "--all"]);
401
+ const bundleVerify = await git([
402
+ "bundle",
403
+ "verify",
404
+ `${backupDir}/content.bundle`,
405
+ ]);
406
+ await Bun.write(
407
+ `${backupDir}/content-bundle-verify.txt`,
408
+ `${new TextDecoder().decode(bundleVerify.stdout)}${new TextDecoder().decode(bundleVerify.stderr)}`,
409
+ );
410
+ await Bun.write(`${backupDir}/content-refs.txt`, refs.stdout);
411
+
412
+ for (const [listName, archiveName] of [
413
+ ["content-untracked.zlist", "content-untracked.tar"],
414
+ ["content-ignored.zlist", "content-ignored.tar"],
415
+ ] as const) {
416
+ const archivePath = `${backupDir}/${archiveName}`;
417
+ await runCommand("tar", [
418
+ "--null",
419
+ "--no-recursion",
420
+ "-C",
421
+ contentRoot,
422
+ "-cf",
423
+ archivePath,
424
+ `--files-from=${backupDir}/${listName}`,
425
+ ]);
426
+ await runCommand("tar", [
427
+ "--compare",
428
+ `--file=${archivePath}`,
429
+ "-C",
430
+ contentRoot,
431
+ ]);
432
+ }
433
+
434
+ const [
435
+ postHead,
436
+ postStatus,
437
+ postStaged,
438
+ postUnstaged,
439
+ postUntracked,
440
+ postIgnored,
441
+ postRefs,
442
+ ] = await Promise.all([
443
+ git(["rev-parse", "HEAD"]),
444
+ git(["status", "--porcelain=v1", "--branch", "--untracked-files=all"]),
445
+ git(["diff", "--cached", "--binary", "--full-index"]),
446
+ git(["diff", "--binary", "--full-index"]),
447
+ git(["ls-files", "--others", "--exclude-standard", "-z"]),
448
+ git(["ls-files", "--others", "--ignored", "--exclude-standard", "-z"]),
449
+ git(["show-ref"]),
450
+ ]);
451
+ const stable =
452
+ text(postHead.stdout) === head &&
453
+ bytesEqual(postStatus.stdout, status.stdout) &&
454
+ bytesEqual(postStaged.stdout, staged.stdout) &&
455
+ bytesEqual(postUnstaged.stdout, unstaged.stdout) &&
456
+ bytesEqual(postUntracked.stdout, untracked.stdout) &&
457
+ bytesEqual(postIgnored.stdout, ignored.stdout) &&
458
+ bytesEqual(postRefs.stdout, refs.stdout);
459
+ if (!stable) {
460
+ throw new Error("Content checkout changed during snapshot capture");
461
+ }
462
+
463
+ return {
464
+ head,
465
+ tree,
466
+ branch,
467
+ upstream,
468
+ observedRemoteHead: observedRemote,
469
+ aheadBehind,
470
+ clean: dirtyStatus.stdout.length === 0,
471
+ stagedPatchBytes: staged.stdout.length,
472
+ unstagedPatchBytes: unstaged.stdout.length,
473
+ untrackedFiles: nullDelimitedCount(untracked.stdout),
474
+ ignoredFiles: nullDelimitedCount(ignored.stdout),
475
+ bundleVerified: true,
476
+ };
477
+ }
478
+
479
+ export async function capturePredeployBackup(
480
+ config: PredeployCaptureConfig,
481
+ ): Promise<void> {
482
+ const databases = await captureDatabases(config);
483
+ const git = await captureGitCheckout(config.contentRoot, config.backupDir);
484
+ const manifest = {
485
+ schemaVersion: 1,
486
+ outcome: "verified",
487
+ ...config.metadata,
488
+ completedAt: new Date().toISOString(),
489
+ databases,
490
+ git,
491
+ };
492
+ await Bun.write(
493
+ `${config.backupDir}/manifest.json`,
494
+ `${JSON.stringify(manifest, null, 2)}\n`,
495
+ );
496
+ }
497
+
498
+ function captureConfigFromEnvironment(): PredeployCaptureConfig {
499
+ const value = (name: string): string => {
500
+ const result = process.env[name];
501
+ if (!result) throw new Error(`Missing ${name}`);
502
+ return result;
503
+ };
504
+ return {
505
+ backupDir: value("BACKUP_DIR"),
506
+ contentRoot: "/app/brain-data",
507
+ databases: [
508
+ {
509
+ source: "/data/brain.db",
510
+ name: "brain.db",
511
+ method: "vacuum",
512
+ quickCheck: "bun",
513
+ logicalVector: false,
514
+ },
515
+ {
516
+ source: "/data/brain-jobs.db",
517
+ name: "brain-jobs.db",
518
+ method: "vacuum",
519
+ quickCheck: "bun",
520
+ logicalVector: false,
521
+ },
522
+ {
523
+ source: "/data/conversations.db",
524
+ name: "conversations.db",
525
+ method: "vacuum",
526
+ quickCheck: "bun",
527
+ logicalVector: false,
528
+ },
529
+ {
530
+ source: "/data/embeddings.db",
531
+ name: "embeddings.db",
532
+ method: "serialize",
533
+ quickCheck: "libsql",
534
+ logicalVector: true,
535
+ },
536
+ {
537
+ source: "/data/runtime-state.db",
538
+ name: "runtime-state.db",
539
+ method: "vacuum",
540
+ quickCheck: "bun",
541
+ logicalVector: false,
542
+ },
543
+ {
544
+ source: "/app/data/auth/auth.db",
545
+ name: "auth.db",
546
+ method: "vacuum",
547
+ quickCheck: "bun",
548
+ logicalVector: false,
549
+ },
550
+ ],
551
+ metadata: {
552
+ snapshotId: value("SNAPSHOT_ID"),
553
+ targetHandle: value("TARGET_HANDLE"),
554
+ host: value("HOST_IDENTIFIER"),
555
+ startedAt: value("STARTED_AT"),
556
+ sourceVersion: value("SOURCE_VERSION"),
557
+ targetVersion: value("TARGET_VERSION"),
558
+ toolVersion: value("TOOL_VERSION"),
559
+ containerId: value("CONTAINER_ID"),
560
+ imageId: value("SOURCE_IMAGE_ID"),
561
+ imageDigest: value("SOURCE_IMAGE_DIGEST"),
562
+ },
563
+ };
564
+ }
565
+
566
+ function shellSafe(value: string, name: string): string {
567
+ if (!/^[A-Za-z0-9._-]+$/.test(value)) {
568
+ throw new Error(`${name} contains unsupported characters`);
569
+ }
570
+ return value;
571
+ }
572
+
573
+ /**
574
+ * Run inside the current runtime before capture. The snapshot proves itself
575
+ * (transactional copies, quick_check, checksums), so it needs a serving
576
+ * runtime with no queued work, not a healthy one: a deploy is often the fix
577
+ * for whatever a plugin reports as degraded. Degradation is named, not refused.
578
+ * A job whose lease expired is abandoned, not running: the next worker
579
+ * reclaims and reruns it, so it is named and backed up as it stands.
580
+ */
581
+ export function renderPredeployReadinessProgram(
582
+ healthUrl: string = "http://127.0.0.1:8080/health/ready",
583
+ ): string {
584
+ return `const response = await fetch(${JSON.stringify(healthUrl)});
585
+ const health = await response.json();
586
+ const queue = health.resources?.queue;
587
+ if (response.status !== 200 || health.status !== "ready") {
588
+ console.error("pre-deploy snapshot: current runtime is not ready");
589
+ process.exit(1);
590
+ }
591
+ const abandoned = queue?.staleLeaseCount ?? 0;
592
+ if (queue && (queue.totals?.pending !== 0 || queue.totals?.processing !== abandoned)) {
593
+ console.error("pre-deploy snapshot: job queue is not idle");
594
+ process.exit(1);
595
+ }
596
+ if (abandoned > 0) {
597
+ console.error("pre-deploy snapshot: " + abandoned + " abandoned job(s) will rerun after the deploy");
598
+ }
599
+ if (health.operationalStatus !== "operational") {
600
+ const degraded = (health.checks ?? []).filter((check) => check.status !== "healthy").map((check) => check.name);
601
+ console.error("pre-deploy snapshot: runtime is degraded (" + degraded.join(", ") + "); backing it up anyway");
602
+ }
603
+ `;
604
+ }
605
+
606
+ /** The runtime's own notices from a successful capture, as workflow warnings;
607
+ * other remote output (ssh host notes) stays out of the log. */
608
+ export function predeployBackupNotices(stderr: string): string[] {
609
+ const prefix = "pre-deploy snapshot: ";
610
+ return stderr
611
+ .split("\n")
612
+ .filter((line) => line.startsWith(prefix))
613
+ .map(
614
+ (line) =>
615
+ `::warning title=Pre-deploy backup::${line.slice(prefix.length).trim()}`,
616
+ );
617
+ }
618
+
619
+ export function renderPredeployBackupRemoteScript(options?: {
620
+ captureProgramBase64?: string;
621
+ }): string {
622
+ const captureProgramBase64 =
623
+ options?.captureProgramBase64 ??
624
+ Buffer.from("capture-program").toString("base64");
625
+ return `#!/usr/bin/env bash
626
+ set -euo pipefail
627
+ umask 077
628
+
629
+ TARGET_HANDLE="$1"
630
+ TARGET_VERSION="$2"
631
+ SERVICE_NAME="$3"
632
+ DEFAULT_RETENTION_COUNT="$4"
633
+ TOOL_VERSION="${PREDEPLOY_BACKUP_TOOL_VERSION}"
634
+ CAPTURE_PROGRAM_BASE64="${captureProgramBase64}"
635
+ state_root="/opt/brain-state"
636
+ runtime_root="/opt/brain-runtime"
637
+ content_root="/opt/brain-data"
638
+ backup_root="\${state_root}/backups"
639
+
640
+ mapfile -t containers < <(docker ps --filter "label=service=\${SERVICE_NAME}" --filter label=role=web --format '{{.ID}}')
641
+ if [ "\${#containers[@]}" -gt 1 ]; then
642
+ echo "pre-deploy snapshot: ambiguous runtime" >&2
643
+ exit 1
644
+ fi
645
+ if [ "\${#containers[@]}" -eq 0 ]; then
646
+ has_state=false
647
+ for path in "$state_root" "$runtime_root" "$content_root" /opt/brain.yaml /opt/brain-dist; do
648
+ if [ -f "$path" ] || { [ -d "$path" ] && find "$path" -mindepth 1 -print -quit 2>/dev/null | grep -q .; }; then
649
+ has_state=true
650
+ break
651
+ fi
652
+ done
653
+ if [ "$has_state" = true ]; then
654
+ echo "pre-deploy snapshot: persistent state exists without an identifiable runtime" >&2
655
+ exit 1
656
+ fi
657
+ echo "pre-deploy snapshot: not applicable (new server)"
658
+ exit 0
659
+ fi
660
+ container="\${containers[0]}"
661
+ status="$(docker inspect "$container" --format '{{.State.Status}}')"
662
+ health="$(docker inspect "$container" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}')"
663
+ if [ "$status" != running ] || [ "$health" != healthy ]; then
664
+ echo "pre-deploy snapshot: current runtime is not healthy" >&2
665
+ exit 1
666
+ fi
667
+
668
+ docker exec "$container" bun -e '
669
+ ${renderPredeployReadinessProgram()}'
670
+
671
+ required_databases=(
672
+ "$state_root/brain.db"
673
+ "$state_root/brain-jobs.db"
674
+ "$state_root/conversations.db"
675
+ "$state_root/embeddings.db"
676
+ "$state_root/runtime-state.db"
677
+ "$runtime_root/auth/auth.db"
678
+ )
679
+ for database in "\${required_databases[@]}"; do
680
+ if [ ! -f "$database" ]; then
681
+ echo "pre-deploy snapshot: required database missing" >&2
682
+ exit 1
683
+ fi
684
+ done
685
+ if [ ! -d "$content_root/.git" ]; then
686
+ echo "pre-deploy snapshot: content checkout missing" >&2
687
+ exit 1
688
+ fi
689
+
690
+ prune_verified() {
691
+ local limit="$1"
692
+ local preserve="\${2:-}"
693
+ local candidates=()
694
+ shopt -s nullglob
695
+ for candidate in "$backup_root"/predeploy-"$TARGET_HANDLE"-*; do
696
+ case "$candidate" in *.incomplete) continue ;; esac
697
+ [ -d "$candidate" ] || continue
698
+ [ -f "$candidate/manifest.json" ] || continue
699
+ [ -f "$candidate/manifest.sha256" ] || continue
700
+ grep -q '"outcome": "verified"' "$candidate/manifest.json" || continue
701
+ candidates+=("$candidate")
702
+ done
703
+ shopt -u nullglob
704
+ if [ "\${#candidates[@]}" -le "$limit" ]; then return; fi
705
+ mapfile -t candidates < <(printf '%s\n' "\${candidates[@]}" | sort)
706
+ local remove_count=$((\${#candidates[@]} - limit))
707
+ for candidate in "\${candidates[@]}"; do
708
+ [ "$remove_count" -gt 0 ] || break
709
+ [ "$candidate" != "$preserve" ] || continue
710
+ rm -rf -- "$candidate"
711
+ remove_count=$((remove_count - 1))
712
+ done
713
+ }
714
+
715
+ install -d -m 700 "$backup_root"
716
+ prune_verified "$DEFAULT_RETENTION_COUNT"
717
+ state_bytes=0
718
+ for database in "\${required_databases[@]}"; do
719
+ state_bytes=$((state_bytes + $(stat -c %s "$database")))
720
+ done
721
+ content_bytes="$(du -sb "$content_root" | cut -f1)"
722
+ base_bytes=$((state_bytes + content_bytes))
723
+ required_bytes=$((base_bytes + base_bytes / 2 + 1073741824))
724
+ available_bytes="$(df --output=avail -B1 "$state_root" | tail -1 | tr -d ' ')"
725
+ if [ "$available_bytes" -lt "$required_bytes" ]; then
726
+ echo "pre-deploy snapshot: insufficient disk" >&2
727
+ exit 1
728
+ fi
729
+
730
+ started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
731
+ stamp="$(date -u +%Y%m%dT%H%M%SZ)"
732
+ target_label="\${TARGET_VERSION:0:12}"
733
+ snapshot_id="predeploy-\${TARGET_HANDLE}-\${target_label}-\${stamp}"
734
+ incomplete="\${backup_root}/\${snapshot_id}.incomplete"
735
+ final="\${backup_root}/\${snapshot_id}"
736
+ container_backup="/data/backups/\${snapshot_id}.incomplete"
737
+ test ! -e "$incomplete"
738
+ test ! -e "$final"
739
+ install -d -m 700 "$incomplete"
740
+
741
+ 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)')"
742
+ source_image_id="$(docker inspect "$container" --format '{{.Image}}')"
743
+ source_image_digest="$(docker image inspect "$source_image_id" --format '{{join .RepoDigests ","}}')"
744
+ host_identifier="$(hostname)"
745
+ docker inspect "$container" --format '{{json .State}}' > "$incomplete/container-state.json"
746
+ docker inspect "$container" --format '{{json .Mounts}}' > "$incomplete/container-mounts.json"
747
+ printf '%s\n' "$container" > "$incomplete/container-id.txt"
748
+ printf '%s\n' "$source_image_id" > "$incomplete/container-image-id.txt"
749
+ printf '%s\n' "$source_image_digest" > "$incomplete/container-image-digest.txt"
750
+ cp /opt/brain.yaml "$incomplete/brain.yaml"
751
+
752
+ printf '%s' "$CAPTURE_PROGRAM_BASE64" | base64 -d | docker exec -i \
753
+ -e BACKUP_DIR="$container_backup" \
754
+ -e SNAPSHOT_ID="$snapshot_id" \
755
+ -e TARGET_HANDLE="$TARGET_HANDLE" \
756
+ -e HOST_IDENTIFIER="$host_identifier" \
757
+ -e STARTED_AT="$started_at" \
758
+ -e SOURCE_VERSION="$source_version" \
759
+ -e TARGET_VERSION="$TARGET_VERSION" \
760
+ -e TOOL_VERSION="$TOOL_VERSION" \
761
+ -e CONTAINER_ID="$container" \
762
+ -e SOURCE_IMAGE_ID="$source_image_id" \
763
+ -e SOURCE_IMAGE_DIGEST="$source_image_digest" \
764
+ "$container" bun run - --capture
765
+
766
+ chmod 600 "$incomplete"/*
767
+ (
768
+ cd "$incomplete"
769
+ sha256sum manifest.json > manifest-checksum.txt
770
+ chmod 600 manifest-checksum.txt
771
+ : > manifest.sha256
772
+ while IFS= read -r file; do
773
+ sha256sum "$file" >> manifest.sha256
774
+ done < <(find . -mindepth 1 -maxdepth 1 -type f ! -name manifest.sha256 -printf '%f\n' | sort)
775
+ chmod 600 manifest.sha256
776
+ sha256sum --check manifest.sha256 >/dev/null
777
+ )
778
+
779
+ test "$(stat -c %a "$incomplete")" = 700
780
+ if find "$incomplete" -mindepth 1 -maxdepth 1 -type f ! -perm 600 -print -quit | grep -q .; then
781
+ echo "pre-deploy snapshot: invalid file permissions" >&2
782
+ exit 1
783
+ fi
784
+ mv "$incomplete" "$final"
785
+ (cd "$final" && sha256sum --check manifest.sha256 >/dev/null)
786
+ prune_verified "$DEFAULT_RETENTION_COUNT" "$final"
787
+
788
+ duration_seconds=$(( $(date -u +%s) - $(date -u -d "$started_at" +%s) ))
789
+ total_bytes="$(du -sb "$final" | cut -f1)"
790
+ echo "SNAPSHOT_ID=$snapshot_id"
791
+ echo "BACKUP=$final"
792
+ echo "SOURCE_VERSION=$source_version"
793
+ echo "TARGET_VERSION=$TARGET_VERSION"
794
+ echo "TOTAL_BYTES=$total_bytes"
795
+ echo "DURATION_SECONDS=$duration_seconds"
796
+ echo "VERIFICATION=passed"
797
+ `;
798
+ }
799
+
800
+ export function parsePredeployBackupOutput(
801
+ output: string,
802
+ ): PredeployBackupResult {
803
+ if (output.includes("pre-deploy snapshot: not applicable (new server)")) {
804
+ return { applicable: false };
805
+ }
806
+ const values = new Map<string, string>();
807
+ for (const line of output.split(/\r?\n/)) {
808
+ const index = line.indexOf("=");
809
+ if (index > 0) values.set(line.slice(0, index), line.slice(index + 1));
810
+ }
811
+ const snapshotId = values.get("SNAPSHOT_ID");
812
+ const backupPath = values.get("BACKUP");
813
+ const sourceVersion = values.get("SOURCE_VERSION");
814
+ const targetVersion = values.get("TARGET_VERSION");
815
+ const totalBytes = Number(values.get("TOTAL_BYTES"));
816
+ const durationSeconds = Number(values.get("DURATION_SECONDS"));
817
+ if (
818
+ !snapshotId ||
819
+ !backupPath ||
820
+ !sourceVersion ||
821
+ !targetVersion ||
822
+ !Number.isSafeInteger(totalBytes) ||
823
+ totalBytes < 0 ||
824
+ !Number.isSafeInteger(durationSeconds) ||
825
+ durationSeconds < 0 ||
826
+ values.get("VERIFICATION") !== "passed"
827
+ ) {
828
+ throw new Error("Incomplete predeploy backup result");
829
+ }
830
+ return {
831
+ applicable: true,
832
+ snapshotId,
833
+ backupPath,
834
+ sourceVersion,
835
+ targetVersion,
836
+ totalBytes,
837
+ durationSeconds,
838
+ verification: "passed",
839
+ };
840
+ }
841
+
842
+ function appendWorkflowResult(result: PredeployBackupResult): void {
843
+ const outputPath = process.env["GITHUB_OUTPUT"];
844
+ const summaryPath = process.env["GITHUB_STEP_SUMMARY"];
845
+ if (outputPath) {
846
+ appendFileSync(
847
+ outputPath,
848
+ result.applicable
849
+ ? `applicable=true\nsnapshot_id=${result.snapshotId}\nbackup_path=${result.backupPath}\n`
850
+ : "applicable=false\n",
851
+ );
852
+ }
853
+ if (summaryPath) {
854
+ appendFileSync(
855
+ summaryPath,
856
+ result.applicable
857
+ ? `### 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`
858
+ : "### Pre-deploy snapshot\nNot applicable: new server with no persistent state.\n",
859
+ );
860
+ }
861
+ }
862
+
863
+ export async function runPredeployBackup(): Promise<PredeployBackupResult> {
864
+ const serverIp = shellSafe(process.env["SERVER_IP"] ?? "", "SERVER_IP");
865
+ const targetHandle = shellSafe(
866
+ process.env["TARGET_HANDLE"] ?? "",
867
+ "TARGET_HANDLE",
868
+ );
869
+ const targetVersion = shellSafe(
870
+ process.env["TARGET_VERSION"] ?? "",
871
+ "TARGET_VERSION",
872
+ );
873
+ const serviceName = shellSafe(
874
+ process.env["SERVICE_NAME"] ?? "",
875
+ "SERVICE_NAME",
876
+ );
877
+ const sshUser = shellSafe(process.env["SSH_USER"] ?? "root", "SSH_USER");
878
+ const retention = Number(
879
+ process.env["PREDEPLOY_BACKUP_RETENTION_COUNT"] ??
880
+ DEFAULT_PREDEPLOY_BACKUP_RETENTION_COUNT,
881
+ );
882
+ if (!Number.isInteger(retention) || retention < 1 || retention > 100) {
883
+ throw new Error(
884
+ "PREDEPLOY_BACKUP_RETENTION_COUNT must be between 1 and 100",
885
+ );
886
+ }
887
+
888
+ const source = await Bun.file(import.meta.path).arrayBuffer();
889
+ const remoteScript = renderPredeployBackupRemoteScript({
890
+ captureProgramBase64: Buffer.from(source).toString("base64"),
891
+ });
892
+ const child = Bun.spawn(
893
+ [
894
+ "ssh",
895
+ `${sshUser}@${serverIp}`,
896
+ "bash",
897
+ "-s",
898
+ "--",
899
+ targetHandle,
900
+ targetVersion,
901
+ serviceName,
902
+ String(retention),
903
+ ],
904
+ { stdin: "pipe", stdout: "pipe", stderr: "pipe" },
905
+ );
906
+ await child.stdin.write(remoteScript);
907
+ await child.stdin.end();
908
+ const [exitCode, stdout, stderr] = await Promise.all([
909
+ child.exited,
910
+ new Response(child.stdout).text(),
911
+ new Response(child.stderr).text(),
912
+ ]);
913
+ if (exitCode !== 0) {
914
+ const diagnostic = stderr.trim().slice(0, 1000);
915
+ throw new Error(
916
+ `Predeploy backup failed${diagnostic ? `: ${diagnostic}` : ""}`,
917
+ );
918
+ }
919
+ for (const notice of predeployBackupNotices(stderr)) console.log(notice);
920
+ const result = parsePredeployBackupOutput(stdout);
921
+ appendWorkflowResult(result);
922
+ console.log(
923
+ result.applicable
924
+ ? `pre-deploy snapshot verified: ${result.snapshotId}`
925
+ : "pre-deploy snapshot: not applicable (new server)",
926
+ );
927
+ return result;
928
+ }
929
+
930
+ if (import.meta.main) {
931
+ if (process.argv.includes("--capture")) {
932
+ await capturePredeployBackup(captureConfigFromEnvironment());
933
+ } else {
934
+ await runPredeployBackup();
935
+ }
936
+ }