@rizom/brain 0.2.0-alpha.342 → 0.2.0-alpha.343

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