@rizom/ops 0.2.0-alpha.72 → 0.2.0-alpha.720

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