@rizom/ops 0.2.0-alpha.44 → 0.2.0-alpha.440
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -1
- package/dist/age-key-bootstrap.d.ts +1 -1
- package/dist/brains-ops.js +736 -335
- package/dist/capability-bundle-migration.d.ts +22 -0
- package/dist/cert-bootstrap.d.ts +4 -2
- package/dist/content-repo-ref.d.ts +10 -0
- package/dist/content-repo.d.ts +1 -1
- package/dist/deploy.js +110 -166
- package/dist/directory-sync-stress-system.d.ts +75 -0
- package/dist/directory-sync-stress.d.ts +117 -0
- package/dist/entries/deploy.d.ts +4 -2
- package/dist/health-watchdog-smoke.d.ts +54 -0
- package/dist/image-inventory.d.ts +4 -0
- package/dist/image-types.d.ts +7 -0
- package/dist/images.d.ts +82 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +736 -323
- package/dist/legacy-pilot-migration.d.ts +11 -0
- package/dist/legacy-projection-job-recovery.d.ts +29 -0
- package/dist/load-registry.d.ts +61 -6
- package/dist/observed-status.d.ts +1 -1
- package/dist/parse-args.d.ts +2 -12
- package/dist/preview-domain.d.ts +9 -0
- package/dist/reconcile-dry-run.d.ts +9 -0
- package/dist/run-command.d.ts +20 -4
- package/dist/schema.d.ts +126 -163
- package/dist/secrets-encrypt.d.ts +7 -13
- package/dist/secrets-push.d.ts +1 -1
- package/dist/ssh-key-bootstrap.d.ts +1 -26
- package/dist/stage-legacy-crossover.d.ts +25 -0
- package/dist/stress-command.d.ts +14 -0
- package/dist/stress-git-checkout.d.ts +21 -0
- package/dist/stress-health-monitor.d.ts +46 -0
- package/dist/upgrade.d.ts +10 -0
- package/dist/user-offboard.d.ts +61 -0
- package/dist/verify-user.d.ts +22 -0
- package/package.json +50 -42
- package/templates/rover-pilot/.env.schema +20 -5
- package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
- package/templates/rover-pilot/.github/workflows/build.yml +70 -17
- package/templates/rover-pilot/.github/workflows/deploy.yml +74 -57
- package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
- package/templates/rover-pilot/.github/workflows/health-watchdog-smoke.yml +95 -0
- package/templates/rover-pilot/.github/workflows/offboard.yml +107 -0
- package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
- package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
- package/templates/rover-pilot/README.md +13 -5
- package/templates/rover-pilot/deploy/scripts/create-predeploy-backup.ts +936 -0
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -24
- package/templates/rover-pilot/deploy/scripts/helpers.ts +2 -0
- package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
- package/templates/rover-pilot/deploy/scripts/provision-server.ts +51 -21
- package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +4 -0
- package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +40 -9
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +72 -16
- package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
- package/templates/rover-pilot/deploy/scripts/verify-runtime-image.ts +18 -0
- package/templates/rover-pilot/docs/canonical-crossover-record.md +107 -0
- package/templates/rover-pilot/docs/onboarding-checklist.md +23 -14
- package/templates/rover-pilot/docs/operator-playbook.md +357 -29
- package/templates/rover-pilot/docs/user-onboarding.md +48 -463
- package/templates/rover-pilot/package.json +1 -0
- package/templates/rover-pilot/pilot.yaml +6 -4
- package/dist/origin-ca.d.ts +0 -1
- package/dist/push-secrets.d.ts +0 -9
- package/dist/push-target.d.ts +0 -2
- package/dist/run-subprocess.d.ts +0 -6
- 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
|
+
}
|