@or3/cloud 0.1.107 → 0.1.113
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/assets/Caddyfile +5 -1
- package/assets/compose.yaml +2 -2
- package/assets/dashboard-lifecycle.mjs +178 -5
- package/dist/cli.mjs +1801 -406
- package/package.json +4 -4
package/dist/cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@ var __export = (target, all) => {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
// src/cli.ts
|
|
18
|
-
import { basename as
|
|
18
|
+
import { basename as basename9, resolve as resolve18 } from "node:path";
|
|
19
19
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
20
20
|
|
|
21
21
|
// src/cli/args.ts
|
|
@@ -67,6 +67,7 @@ var COMMAND_FLAGS = {
|
|
|
67
67
|
init: ["local", "public", "domain", "admin-email", "admin-password", "admin-password-file", "port"],
|
|
68
68
|
update: ["to", "dry-run", "json"],
|
|
69
69
|
backup: ["keep", "force", "yes", "json"],
|
|
70
|
+
"vault-key": ["out"],
|
|
70
71
|
restore: ["yes"],
|
|
71
72
|
rollback: ["yes"],
|
|
72
73
|
doctor: [],
|
|
@@ -92,7 +93,7 @@ function assertCommandFlags(command, flags) {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
function assertCommandPositionals(command, positionals) {
|
|
95
|
-
if (command === "backup" || command === "credentials")
|
|
96
|
+
if (command === "backup" || command === "credentials" || command === "vault-key")
|
|
96
97
|
return;
|
|
97
98
|
if (command === "init" || command === "adopt") {
|
|
98
99
|
if (positionals.length > 1)
|
|
@@ -128,11 +129,11 @@ function requireStringFlag(flags, key) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// src/cli/dispatch.ts
|
|
131
|
-
import { basename as
|
|
132
|
+
import { basename as basename8, resolve as resolve17 } from "node:path";
|
|
132
133
|
|
|
133
134
|
// src/commands/adopt.ts
|
|
134
135
|
import { chmod as chmod5, mkdir as mkdir5 } from "node:fs/promises";
|
|
135
|
-
import { basename as basename3, join as
|
|
136
|
+
import { basename as basename3, join as join16, resolve as resolve7 } from "node:path";
|
|
136
137
|
|
|
137
138
|
// src/backup/archive.ts
|
|
138
139
|
import { createReadStream as createReadStream3 } from "node:fs";
|
|
@@ -164,9 +165,23 @@ function quote(value) {
|
|
|
164
165
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
165
166
|
}
|
|
166
167
|
function redact(text, secrets = []) {
|
|
167
|
-
|
|
168
|
-
for (const secret of secrets.filter(Boolean))
|
|
169
|
-
|
|
168
|
+
const mask = new Uint8Array(text.length);
|
|
169
|
+
for (const secret of secrets.filter(Boolean)) {
|
|
170
|
+
for (let index = text.indexOf(secret);index !== -1; index = text.indexOf(secret, index + 1))
|
|
171
|
+
mask.fill(1, index, index + secret.length);
|
|
172
|
+
}
|
|
173
|
+
let output = "", cursor = 0;
|
|
174
|
+
for (let index = 0;index < mask.length; ) {
|
|
175
|
+
if (!mask[index]) {
|
|
176
|
+
index += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
output += `${text.slice(cursor, index)}[REDACTED]`;
|
|
180
|
+
while (index < mask.length && mask[index])
|
|
181
|
+
index += 1;
|
|
182
|
+
cursor = index;
|
|
183
|
+
}
|
|
184
|
+
output += text.slice(cursor);
|
|
170
185
|
output = output.replace(/((?:PASSWORD|SECRET|TOKEN|JWT)\s*[=:]\s*)(?!\[REDACTED\])([^\r\n]+)/gi, "$1[REDACTED]");
|
|
171
186
|
return output;
|
|
172
187
|
}
|
|
@@ -179,7 +194,7 @@ import { readFileSync } from "node:fs";
|
|
|
179
194
|
import { join, resolve } from "node:path";
|
|
180
195
|
import { fileURLToPath } from "node:url";
|
|
181
196
|
var PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url)));
|
|
182
|
-
var PACKAGE_VERSION = "0.1.
|
|
197
|
+
var PACKAGE_VERSION = "0.1.113";
|
|
183
198
|
var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
|
|
184
199
|
var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
|
|
185
200
|
var SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
@@ -293,6 +308,9 @@ function composeProjectNames(directory) {
|
|
|
293
308
|
caddyConfig: `${base}-caddy-config`
|
|
294
309
|
};
|
|
295
310
|
}
|
|
311
|
+
function managedVaultVolumeNames(dataVolume) {
|
|
312
|
+
return ["vault-data", "vault-key", "vault-run", "vault-client"].map((suffix) => `${dataVolume}-${suffix}`);
|
|
313
|
+
}
|
|
296
314
|
function assertDeploymentIdentity(state, env) {
|
|
297
315
|
const expected = {
|
|
298
316
|
OR3_COMPOSE_PROJECT: state.composeProject,
|
|
@@ -366,10 +384,149 @@ var SECRET_KEYS = [
|
|
|
366
384
|
"OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
|
|
367
385
|
"OR3_AUTH_INVITE_TOKEN_SECRET",
|
|
368
386
|
"OR3_STORAGE_FS_TOKEN_SECRET",
|
|
387
|
+
"OR3_STORAGE_S3_ACCESS_KEY_ID",
|
|
388
|
+
"OR3_STORAGE_S3_SECRET_ACCESS_KEY",
|
|
389
|
+
"OR3_STORAGE_S3_SESSION_TOKEN",
|
|
390
|
+
"NUXT_CLERK_SECRET_KEY",
|
|
391
|
+
"NUXT_AUTH_INVITE_TOKEN_SECRET",
|
|
392
|
+
"NUXT_ADMIN_AUTH_PASSWORD",
|
|
393
|
+
"NUXT_ADMIN_AUTH_JWT_SECRET",
|
|
394
|
+
"OR3_LIBRARY_LINK_SECRET",
|
|
395
|
+
"NUXT_ADMIN_LIBRARY_LINK_SECRET",
|
|
396
|
+
"OR3_PLUGIN_CONNECTION_SECRET",
|
|
397
|
+
"NUXT_ADMIN_PLUGIN_CONNECTION_SECRET",
|
|
398
|
+
"OR3_BACKGROUND_ENCRYPTION_KEY",
|
|
399
|
+
"NUXT_BACKGROUND_JOBS_ENCRYPTION_KEY",
|
|
400
|
+
"OR3_WEBHOOKS_ENCRYPTION_KEY",
|
|
401
|
+
"NUXT_WEBHOOKS_ENCRYPTION_KEY",
|
|
402
|
+
"OR3_CONNECT_ENCRYPTION_KEY",
|
|
403
|
+
"NUXT_CONNECT_ENCRYPTION_KEY",
|
|
404
|
+
"OR3_CONNECT_CLOUDFLARE_API_TOKEN",
|
|
405
|
+
"NUXT_CONNECT_CLOUDFLARE_API_TOKEN",
|
|
406
|
+
"OPENROUTER_API_KEY",
|
|
407
|
+
"NUXT_OPENROUTER_API_KEY",
|
|
408
|
+
"OR3_OPENAI_API_KEY",
|
|
409
|
+
"NUXT_OPENAI_API_KEY",
|
|
410
|
+
"OR3_BILLING_STRIPE_SECRET_KEY",
|
|
411
|
+
"OR3_BILLING_STRIPE_WEBHOOK_SECRET",
|
|
412
|
+
"CONVEX_SELF_HOSTED_ADMIN_KEY",
|
|
413
|
+
"CONVEX_DEPLOY_KEY",
|
|
369
414
|
"OR3_ADMIN_JWT_SECRET",
|
|
370
415
|
"OR3_ADMIN_PASSWORD"
|
|
371
416
|
];
|
|
417
|
+
function managedProviderProfile(env) {
|
|
418
|
+
const auth = env.AUTH_PROVIDER ?? env.OR3_AUTH_PROVIDER ?? "basic-auth";
|
|
419
|
+
const sync = env.OR3_SYNC_PROVIDER ?? "sqlite";
|
|
420
|
+
const storage = env.NUXT_PUBLIC_STORAGE_PROVIDER ?? "fs";
|
|
421
|
+
if (env.AUTH_PROVIDER && env.OR3_AUTH_PROVIDER && env.AUTH_PROVIDER !== env.OR3_AUTH_PROVIDER || sync !== "sqlite") {
|
|
422
|
+
throw new Error("backup-profile-mismatch: unsupported or inconsistent managed provider profile.");
|
|
423
|
+
}
|
|
424
|
+
if (auth === "basic-auth" && storage === "fs")
|
|
425
|
+
return "basic-sqlite-fs";
|
|
426
|
+
if (auth === "clerk" && storage === "s3")
|
|
427
|
+
return "clerk-sqlite-s3";
|
|
428
|
+
throw new Error("backup-profile-mismatch: unsupported managed provider profile.");
|
|
429
|
+
}
|
|
430
|
+
function assertLegacyManagedProfile(env) {
|
|
431
|
+
const overrides = {
|
|
432
|
+
SSR_AUTH_ENABLED: "true",
|
|
433
|
+
OR3_SYNC_ENABLED: "true",
|
|
434
|
+
OR3_CLOUD_SYNC_ENABLED: "true",
|
|
435
|
+
OR3_STORAGE_ENABLED: "true",
|
|
436
|
+
OR3_CLOUD_STORAGE_ENABLED: "true",
|
|
437
|
+
OR3_SQLITE_DRIVER: "better-sqlite3",
|
|
438
|
+
OR3_STORAGE_PROVIDER: "fs",
|
|
439
|
+
NUXT_AUTH_ENABLED: "true",
|
|
440
|
+
NUXT_AUTH_PROVIDER: "basic-auth",
|
|
441
|
+
NUXT_PUBLIC_AUTH_PROVIDER: "basic-auth",
|
|
442
|
+
NUXT_PUBLIC_SSR_AUTH_ENABLED: "true",
|
|
443
|
+
NUXT_SYNC_ENABLED: "true",
|
|
444
|
+
NUXT_PUBLIC_SYNC_ENABLED: "true",
|
|
445
|
+
NUXT_SYNC_PROVIDER: "sqlite",
|
|
446
|
+
NUXT_PUBLIC_SYNC_PROVIDER: "sqlite",
|
|
447
|
+
NUXT_STORAGE_ENABLED: "true",
|
|
448
|
+
NUXT_PUBLIC_STORAGE_ENABLED: "true",
|
|
449
|
+
NUXT_STORAGE_PROVIDER: "fs"
|
|
450
|
+
};
|
|
451
|
+
const ambiguous = /^(?:RUNTIME_CONFIG$|NUXT_(?:AUTH|SYNC|STORAGE|PUBLIC)$|NITRO_ENV_(?:PREFIX|EXPANSION)$|NITRO_(?:AUTH|SYNC|STORAGE|PUBLIC)(?:_|$))/;
|
|
452
|
+
if (managedProviderProfile(env) !== "basic-sqlite-fs" || Object.entries(overrides).some(([key, expected]) => env[key] !== undefined && env[key] !== expected) || Object.keys(env).some((key) => ambiguous.test(key))) {
|
|
453
|
+
throw new Error("Legacy managed state supports only the Basic/SQLite/filesystem profile; provider conversion requires a separate qualified deployment.");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function restoreStorageConfiguration(backup, current) {
|
|
457
|
+
const sourceProvider = backup.NUXT_PUBLIC_STORAGE_PROVIDER;
|
|
458
|
+
const targetProvider = current.NUXT_PUBLIC_STORAGE_PROVIDER;
|
|
459
|
+
if (sourceProvider !== targetProvider && (sourceProvider === "s3" || targetProvider === "s3" || sourceProvider && targetProvider)) {
|
|
460
|
+
throw new Error("backup-profile-mismatch: backup and deployment use different storage providers.");
|
|
461
|
+
}
|
|
462
|
+
if (sourceProvider !== "s3")
|
|
463
|
+
return { ...backup };
|
|
464
|
+
const prefix = (value) => {
|
|
465
|
+
const trimmed = (value ?? "").trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
466
|
+
return trimmed ? `${trimmed}/` : "";
|
|
467
|
+
};
|
|
468
|
+
if (prefix(backup.OR3_STORAGE_S3_KEY_PREFIX) !== prefix(current.OR3_STORAGE_S3_KEY_PREFIX)) {
|
|
469
|
+
throw new Error("backup-prefix-mismatch: backup and target S3 key prefixes differ.");
|
|
470
|
+
}
|
|
471
|
+
const restored = Object.fromEntries(Object.entries(backup).filter(([key]) => !key.startsWith("OR3_STORAGE_S3_")));
|
|
472
|
+
for (const [key, value] of Object.entries(current))
|
|
473
|
+
if (key.startsWith("OR3_STORAGE_S3_"))
|
|
474
|
+
restored[key] = value;
|
|
475
|
+
return restored;
|
|
476
|
+
}
|
|
477
|
+
var ADOPTION_SECRET_KEYS = [
|
|
478
|
+
"OR3_BASIC_AUTH_JWT_SECRET",
|
|
479
|
+
"OR3_BASIC_AUTH_REFRESH_SECRET",
|
|
480
|
+
"OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
|
|
481
|
+
"OR3_AUTH_INVITE_TOKEN_SECRET",
|
|
482
|
+
"OR3_STORAGE_FS_TOKEN_SECRET",
|
|
483
|
+
"OR3_ADMIN_PASSWORD",
|
|
484
|
+
"OR3_ADMIN_JWT_SECRET",
|
|
485
|
+
"NUXT_AUTH_INVITE_TOKEN_SECRET",
|
|
486
|
+
"NUXT_ADMIN_AUTH_JWT_SECRET",
|
|
487
|
+
"OR3_LIBRARY_LINK_SECRET",
|
|
488
|
+
"NUXT_ADMIN_LIBRARY_LINK_SECRET",
|
|
489
|
+
"OR3_PLUGIN_CONNECTION_SECRET",
|
|
490
|
+
"NUXT_ADMIN_PLUGIN_CONNECTION_SECRET",
|
|
491
|
+
"OR3_BACKGROUND_ENCRYPTION_KEY",
|
|
492
|
+
"NUXT_BACKGROUND_JOBS_ENCRYPTION_KEY",
|
|
493
|
+
"OR3_WEBHOOKS_ENCRYPTION_KEY",
|
|
494
|
+
"NUXT_WEBHOOKS_ENCRYPTION_KEY",
|
|
495
|
+
"OR3_CONNECT_ENCRYPTION_KEY",
|
|
496
|
+
"NUXT_CONNECT_ENCRYPTION_KEY",
|
|
497
|
+
"OR3_CONNECT_CLOUDFLARE_API_TOKEN",
|
|
498
|
+
"NUXT_CONNECT_CLOUDFLARE_API_TOKEN",
|
|
499
|
+
"OPENROUTER_API_KEY",
|
|
500
|
+
"NUXT_OPENROUTER_API_KEY",
|
|
501
|
+
"OR3_OPENAI_API_KEY",
|
|
502
|
+
"NUXT_OPENAI_API_KEY"
|
|
503
|
+
];
|
|
504
|
+
var ADOPTION_RUNTIME_KEYS = [
|
|
505
|
+
"OR3_MARKETPLACE_REGISTRY_ORIGIN",
|
|
506
|
+
"OR3_MARKETPLACE_INSTALL_ENABLED",
|
|
507
|
+
"OR3_MARKETPLACE_RELEASE_KEYS",
|
|
508
|
+
"OR3_INSTANCE_ID",
|
|
509
|
+
"OR3_MARKETPLACE_MAX_ARTIFACT_BYTES",
|
|
510
|
+
"OR3_MARKETPLACE_RESERVE_BYTES",
|
|
511
|
+
"OR3_BACKGROUND_STREAMING_ENABLED",
|
|
512
|
+
"OR3_BACKGROUND_STREAMING_PROVIDER",
|
|
513
|
+
"OR3_BACKGROUND_MAX_JOBS",
|
|
514
|
+
"OR3_BACKGROUND_MAX_JOBS_PER_USER",
|
|
515
|
+
"OR3_BACKGROUND_JOB_TIMEOUT",
|
|
516
|
+
"NUXT_BACKGROUND_JOBS_ENABLED",
|
|
517
|
+
"NUXT_PUBLIC_BACKGROUND_STREAMING_ENABLED",
|
|
518
|
+
"NUXT_BACKGROUND_JOBS_STORAGE_PROVIDER",
|
|
519
|
+
"NUXT_BACKGROUND_JOBS_MAX_CONCURRENT_JOBS",
|
|
520
|
+
"NUXT_BACKGROUND_JOBS_MAX_CONCURRENT_JOBS_PER_USER",
|
|
521
|
+
"NUXT_BACKGROUND_JOBS_JOB_TIMEOUT_MS",
|
|
522
|
+
"OR3_PLUGIN_ALLOWED_MODELS",
|
|
523
|
+
"OR3_PLUGIN_MODEL_PRICES",
|
|
524
|
+
"NUXT_ADMIN_PLUGIN_ALLOWED_MODELS",
|
|
525
|
+
"NUXT_ADMIN_PLUGIN_MODEL_PRICES"
|
|
526
|
+
];
|
|
372
527
|
var ALLOWED_ENV_KEYS = new Set([
|
|
528
|
+
...ADOPTION_SECRET_KEYS,
|
|
529
|
+
...ADOPTION_RUNTIME_KEYS,
|
|
373
530
|
"SSR_AUTH_ENABLED",
|
|
374
531
|
"NUXT_SECRETS_PROVIDER",
|
|
375
532
|
"NUXT_SECRETS_NAMESPACE",
|
|
@@ -386,6 +543,7 @@ var ALLOWED_ENV_KEYS = new Set([
|
|
|
386
543
|
"OR3_BASIC_AUTH_REFRESH_SECRET",
|
|
387
544
|
"OR3_BASIC_AUTH_ACCESS_TTL_SECONDS",
|
|
388
545
|
"OR3_BASIC_AUTH_REFRESH_TTL_SECONDS",
|
|
546
|
+
"OR3_SQLITE_DRIVER",
|
|
389
547
|
"OR3_BASIC_AUTH_DB_PATH",
|
|
390
548
|
"OR3_BASIC_AUTH_BOOTSTRAP_EMAIL",
|
|
391
549
|
"OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
|
|
@@ -550,7 +708,9 @@ function buildEnv(input) {
|
|
|
550
708
|
return values;
|
|
551
709
|
}
|
|
552
710
|
function secretValues(env) {
|
|
553
|
-
|
|
711
|
+
const known = new Set(SECRET_KEYS);
|
|
712
|
+
const secretName = /(?:^|_)(?:PASSWORD|SECRET|TOKEN|PRIVATE_KEY|API_KEY|ACCESS_KEY|AUTH_KEY)(?:_|$)/i;
|
|
713
|
+
return Object.entries(env).filter(([key, value]) => Boolean(value) && (known.has(key) || secretName.test(key))).map(([, value]) => value);
|
|
554
714
|
}
|
|
555
715
|
|
|
556
716
|
// src/deployment/state-store.ts
|
|
@@ -844,6 +1004,12 @@ import { dirname as dirname2, join as join5 } from "node:path";
|
|
|
844
1004
|
var execFile = promisify(execFileCallback);
|
|
845
1005
|
var COMMAND_TIMEOUT_MS = 180000;
|
|
846
1006
|
var STREAM_COMMAND_TIMEOUT_MS = 15 * 60000;
|
|
1007
|
+
var MAX_STREAM_COMMAND_TIMEOUT_MS = 4 * 60 * 60000;
|
|
1008
|
+
function streamDeadline(timeoutMs) {
|
|
1009
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_STREAM_COMMAND_TIMEOUT_MS)
|
|
1010
|
+
throw new Error("Invalid archive stream deadline.");
|
|
1011
|
+
return timeoutMs;
|
|
1012
|
+
}
|
|
847
1013
|
var PROCESS_ENV_PASSTHROUGH = [
|
|
848
1014
|
"PATH",
|
|
849
1015
|
"HOME",
|
|
@@ -940,7 +1106,8 @@ function terminateChildProcess(child) {
|
|
|
940
1106
|
}
|
|
941
1107
|
child.kill("SIGKILL");
|
|
942
1108
|
}
|
|
943
|
-
async function streamCommandToFile(command, args, destination, cwd, secrets = []) {
|
|
1109
|
+
async function streamCommandToFile(command, args, destination, cwd, secrets = [], environment, timeoutMs = STREAM_COMMAND_TIMEOUT_MS) {
|
|
1110
|
+
streamDeadline(timeoutMs);
|
|
944
1111
|
if (await fileExists(destination)) {
|
|
945
1112
|
throw new Error(`Refusing to overwrite existing archive ${destination}.`);
|
|
946
1113
|
}
|
|
@@ -948,7 +1115,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
948
1115
|
const temporary = `${destination}.${randomBytes3(8).toString("hex")}.partial`;
|
|
949
1116
|
const child = spawn(command, args, {
|
|
950
1117
|
cwd,
|
|
951
|
-
env: commandEnvironment(command, args, cwd),
|
|
1118
|
+
env: commandEnvironment(command, args, cwd, environment),
|
|
952
1119
|
detached: process.platform !== "win32",
|
|
953
1120
|
stdio: ["ignore", "pipe", "pipe"]
|
|
954
1121
|
});
|
|
@@ -958,7 +1125,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
958
1125
|
const timeout = setTimeout(() => {
|
|
959
1126
|
timedOut = true;
|
|
960
1127
|
terminateChildProcess(child);
|
|
961
|
-
},
|
|
1128
|
+
}, timeoutMs);
|
|
962
1129
|
child.stderr.setEncoding("utf8");
|
|
963
1130
|
child.stderr.on("data", (chunk) => {
|
|
964
1131
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
@@ -974,7 +1141,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
974
1141
|
transfer
|
|
975
1142
|
]);
|
|
976
1143
|
if (hasTimedOut())
|
|
977
|
-
throw new Error(`${command} exceeded the ${
|
|
1144
|
+
throw new Error(`${command} exceeded the ${timeoutMs / 1000}-second archive deadline.`);
|
|
978
1145
|
if (exitCode !== 0) {
|
|
979
1146
|
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
980
1147
|
}
|
|
@@ -998,10 +1165,11 @@ Temporary archive cleanup failed: ${redact(cleanupError instanceof Error ? clean
|
|
|
998
1165
|
clearTimeout(timeout);
|
|
999
1166
|
}
|
|
1000
1167
|
}
|
|
1001
|
-
async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
1168
|
+
async function streamFileToCommand(command, args, source, cwd, secrets = [], environment, timeoutMs = STREAM_COMMAND_TIMEOUT_MS) {
|
|
1169
|
+
streamDeadline(timeoutMs);
|
|
1002
1170
|
const child = spawn(command, args, {
|
|
1003
1171
|
cwd,
|
|
1004
|
-
env: commandEnvironment(command, args, cwd),
|
|
1172
|
+
env: commandEnvironment(command, args, cwd, environment),
|
|
1005
1173
|
detached: process.platform !== "win32",
|
|
1006
1174
|
stdio: ["pipe", "ignore", "pipe"]
|
|
1007
1175
|
});
|
|
@@ -1011,7 +1179,7 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
|
1011
1179
|
const timeout = setTimeout(() => {
|
|
1012
1180
|
timedOut = true;
|
|
1013
1181
|
terminateChildProcess(child);
|
|
1014
|
-
},
|
|
1182
|
+
}, timeoutMs);
|
|
1015
1183
|
child.stderr.setEncoding("utf8");
|
|
1016
1184
|
child.stderr.on("data", (chunk) => {
|
|
1017
1185
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
@@ -1026,13 +1194,13 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
|
1026
1194
|
pipeline(createReadStream2(source), child.stdin)
|
|
1027
1195
|
]);
|
|
1028
1196
|
if (hasTimedOut())
|
|
1029
|
-
throw new Error(`${command} exceeded the ${
|
|
1197
|
+
throw new Error(`${command} exceeded the ${timeoutMs / 1000}-second archive deadline.`);
|
|
1030
1198
|
if (exitCode !== 0) {
|
|
1031
1199
|
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
1032
1200
|
}
|
|
1033
1201
|
} catch (error) {
|
|
1034
1202
|
terminateChildProcess(child);
|
|
1035
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
1203
|
+
const detail = redact(error instanceof Error ? error.message : String(error), secrets);
|
|
1036
1204
|
throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
|
|
1037
1205
|
${redact(stderr, secrets)}` : ""}`);
|
|
1038
1206
|
} finally {
|
|
@@ -1121,6 +1289,31 @@ function parseComposeConfig(text) {
|
|
|
1121
1289
|
throw new Error(`Docker Compose did not return a readable JSON configuration: ${error instanceof Error ? error.message : String(error)}`);
|
|
1122
1290
|
}
|
|
1123
1291
|
}
|
|
1292
|
+
function assertCapturedComposeMounts(service) {
|
|
1293
|
+
const mounts = service.volumes ?? [], tmpfs = service.tmpfs;
|
|
1294
|
+
for (const field of [service.configs, service.secrets, service.volumes_from]) {
|
|
1295
|
+
if (field != null && (!Array.isArray(field) || field.length !== 0))
|
|
1296
|
+
throw new Error("Source has additional Compose mounts that cannot be captured safely.");
|
|
1297
|
+
}
|
|
1298
|
+
if (mounts.some((mount) => {
|
|
1299
|
+
const volume = mount.volume;
|
|
1300
|
+
return volume && typeof volume === "object" && volume.subpath;
|
|
1301
|
+
}))
|
|
1302
|
+
throw new Error("Source volume subpaths cannot be captured safely.");
|
|
1303
|
+
if (tmpfs != null && (!Array.isArray(tmpfs) || tmpfs.some((entry) => typeof entry !== "string" || entry.split(":", 1)[0] !== "/tmp")))
|
|
1304
|
+
throw new Error("Source has an unexpected temporary filesystem.");
|
|
1305
|
+
}
|
|
1306
|
+
function assertCapturedHostMounts(tmpfs, hostMounts) {
|
|
1307
|
+
if (tmpfs != null && (typeof tmpfs !== "object" || Array.isArray(tmpfs) || Object.keys(tmpfs).some((path) => path !== "/tmp")))
|
|
1308
|
+
throw new Error("Source has an unexpected temporary filesystem.");
|
|
1309
|
+
if (hostMounts != null && (!Array.isArray(hostMounts) || hostMounts.some((mount) => {
|
|
1310
|
+
if (!mount || typeof mount !== "object" || Array.isArray(mount))
|
|
1311
|
+
return true;
|
|
1312
|
+
const options = mount.VolumeOptions;
|
|
1313
|
+
return options && typeof options === "object" && options.Subpath;
|
|
1314
|
+
})))
|
|
1315
|
+
throw new Error("Source volume subpaths cannot be captured safely.");
|
|
1316
|
+
}
|
|
1124
1317
|
function composeEnvironmentValue(service, key) {
|
|
1125
1318
|
const environment = service.environment;
|
|
1126
1319
|
if (Array.isArray(environment)) {
|
|
@@ -1171,6 +1364,41 @@ async function resolvedComposeConfig(directory, mode, deadline = performance.now
|
|
|
1171
1364
|
${result.stderr}`);
|
|
1172
1365
|
return result.stdout;
|
|
1173
1366
|
}
|
|
1367
|
+
async function assertLegacyComposeProfile(directory, mode, env, deadline) {
|
|
1368
|
+
assertLegacyManagedProfile(env);
|
|
1369
|
+
let resolved;
|
|
1370
|
+
try {
|
|
1371
|
+
resolved = await resolvedComposeConfig(directory, mode, deadline);
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
throw new Error(redact(error instanceof Error ? error.message : String(error), secretValues(env)));
|
|
1374
|
+
}
|
|
1375
|
+
let service;
|
|
1376
|
+
try {
|
|
1377
|
+
service = parseComposeConfig(resolved).services?.or3;
|
|
1378
|
+
} catch {
|
|
1379
|
+
throw new Error("Resolved Compose provider configuration is unreadable.");
|
|
1380
|
+
}
|
|
1381
|
+
if (!service || !service.environment || typeof service.environment !== "object")
|
|
1382
|
+
throw new Error("Resolved Compose provider environment is missing.");
|
|
1383
|
+
const entries = Array.isArray(service.environment) ? service.environment.map((entry) => {
|
|
1384
|
+
if (typeof entry !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*=/.test(entry))
|
|
1385
|
+
throw new Error("Resolved Compose provider environment is ambiguous.");
|
|
1386
|
+
const index = entry.indexOf("=");
|
|
1387
|
+
return [entry.slice(0, index), entry.slice(index + 1)];
|
|
1388
|
+
}) : Object.entries(service.environment);
|
|
1389
|
+
const effective = {};
|
|
1390
|
+
const seen = new Set;
|
|
1391
|
+
for (const [key, value] of entries) {
|
|
1392
|
+
if (typeof key !== "string" || typeof value !== "string" || seen.has(key))
|
|
1393
|
+
throw new Error("Resolved Compose provider environment is ambiguous.");
|
|
1394
|
+
seen.add(key);
|
|
1395
|
+
effective[key] = value;
|
|
1396
|
+
}
|
|
1397
|
+
assertLegacyManagedProfile(effective);
|
|
1398
|
+
if ((effective.OR3_AUTH_PROVIDER || effective.AUTH_PROVIDER) !== "basic-auth" || effective.SSR_AUTH_ENABLED !== "true" || (effective.OR3_CLOUD_SYNC_ENABLED || effective.OR3_SYNC_ENABLED) !== "true" || effective.OR3_SYNC_PROVIDER !== "sqlite" || (effective.OR3_CLOUD_STORAGE_ENABLED || effective.OR3_STORAGE_ENABLED) !== "true" || (effective.OR3_STORAGE_PROVIDER || effective.NUXT_PUBLIC_STORAGE_PROVIDER) !== "fs") {
|
|
1399
|
+
throw new Error("Resolved Compose provider profile is missing required Basic/SQLite/filesystem settings.");
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1174
1402
|
async function assertSafeComposeBinding(directory, mode, env, deadline) {
|
|
1175
1403
|
const config = await resolvedComposeConfig(directory, mode, deadline);
|
|
1176
1404
|
const parsed = parseComposeConfig(config);
|
|
@@ -1570,8 +1798,14 @@ async function restoreVolumeArchive(directory, mode, env, backupPath) {
|
|
|
1570
1798
|
], join7(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1571
1799
|
}
|
|
1572
1800
|
async function validateVolumeArchive(directory, _mode, env, backupPath, archivedStorageRoot) {
|
|
1801
|
+
const auth = env.AUTH_PROVIDER ?? env.OR3_AUTH_PROVIDER ?? "basic-auth";
|
|
1802
|
+
const sync = env.OR3_SYNC_PROVIDER ?? "sqlite";
|
|
1803
|
+
const storage = env.NUXT_PUBLIC_STORAGE_PROVIDER ?? "fs";
|
|
1804
|
+
if (env.AUTH_PROVIDER && env.OR3_AUTH_PROVIDER && env.AUTH_PROVIDER !== env.OR3_AUTH_PROVIDER || sync !== "sqlite" || !(auth === "basic-auth" && storage === "fs" || auth === "clerk" && storage === "s3"))
|
|
1805
|
+
throw new Error("Unsupported backup archive provider profile.");
|
|
1806
|
+
const requireAuth = auth === "basic-auth";
|
|
1573
1807
|
const storageRoot = posix.relative("/data", archivedStorageRoot ?? env.OR3_STORAGE_FS_ROOT ?? "/data/storage");
|
|
1574
|
-
const storagePrefix = storageRoot === ".." || storageRoot.startsWith("../") || posix.isAbsolute(storageRoot) ? "" : `${storageRoot ? `${storageRoot}/` : ""}workspaces/`;
|
|
1808
|
+
const storagePrefix = storage !== "fs" || storageRoot === ".." || storageRoot.startsWith("../") || posix.isAbsolute(storageRoot) ? "" : `${storageRoot ? `${storageRoot}/` : ""}workspaces/`;
|
|
1575
1809
|
await streamFileToCommand("docker", [
|
|
1576
1810
|
"run",
|
|
1577
1811
|
"--rm",
|
|
@@ -1587,7 +1821,7 @@ async function validateVolumeArchive(directory, _mode, env, backupPath, archived
|
|
|
1587
1821
|
"sh",
|
|
1588
1822
|
env.OR3_IMAGE,
|
|
1589
1823
|
"-c",
|
|
1590
|
-
`{ tar tzf -; printf '\\nOR3_TAR_RESULT:%s\\n' "$?"; } | awk -v prefix="$1" '
|
|
1824
|
+
`{ tar tzf -; printf '\\nOR3_TAR_RESULT:%s\\n' "$?"; } | awk -v prefix="$1" -v require_auth="$2" '
|
|
1591
1825
|
{
|
|
1592
1826
|
entry = $0
|
|
1593
1827
|
sub(/^\\.\\//, "", entry)
|
|
@@ -1612,8 +1846,8 @@ async function validateVolumeArchive(directory, _mode, env, backupPath, archived
|
|
|
1612
1846
|
}
|
|
1613
1847
|
END {
|
|
1614
1848
|
if (!seen_status || tar_status != "0") exit 1
|
|
1615
|
-
if (!
|
|
1616
|
-
print "Backup archive is missing required managed database payloads
|
|
1849
|
+
if (!sync || (require_auth == "1" && !auth)) {
|
|
1850
|
+
print "Backup archive is missing required managed database payloads for its provider profile." > "/dev/stderr"
|
|
1617
1851
|
exit 1
|
|
1618
1852
|
}
|
|
1619
1853
|
for (entry in metadata) {
|
|
@@ -1626,14 +1860,15 @@ END {
|
|
|
1626
1860
|
}
|
|
1627
1861
|
}'`,
|
|
1628
1862
|
"or3-archive-validation",
|
|
1629
|
-
storagePrefix
|
|
1863
|
+
storagePrefix,
|
|
1864
|
+
requireAuth ? "1" : "0"
|
|
1630
1865
|
], join7(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1631
1866
|
}
|
|
1632
1867
|
|
|
1633
1868
|
// src/backup/manifests.ts
|
|
1634
|
-
import { createHmac, randomBytes as randomBytes5, timingSafeEqual } from "node:crypto";
|
|
1635
|
-
import { lstat, readdir as readdir2 } from "node:fs/promises";
|
|
1636
|
-
import { isAbsolute, join as
|
|
1869
|
+
import { createHash as createHash3, createHmac, randomBytes as randomBytes5, timingSafeEqual } from "node:crypto";
|
|
1870
|
+
import { lstat as lstat3, readdir as readdir2 } from "node:fs/promises";
|
|
1871
|
+
import { isAbsolute as isAbsolute3, join as join12, resolve as resolve4 } from "node:path";
|
|
1637
1872
|
|
|
1638
1873
|
// src/deployment/assets.ts
|
|
1639
1874
|
import { randomBytes as randomBytes4 } from "node:crypto";
|
|
@@ -1793,6 +2028,572 @@ function assertRestorableManagedAssets(manifest2) {
|
|
|
1793
2028
|
}
|
|
1794
2029
|
}
|
|
1795
2030
|
|
|
2031
|
+
// src/backup/remote.ts
|
|
2032
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2033
|
+
import { readFile as readFile3, rm as rm4, stat as stat2 } from "node:fs/promises";
|
|
2034
|
+
import { join as join10 } from "node:path";
|
|
2035
|
+
|
|
2036
|
+
// src/backup/helper.ts
|
|
2037
|
+
import { randomUUID } from "node:crypto";
|
|
2038
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2039
|
+
import { isAbsolute } from "node:path";
|
|
2040
|
+
var INSPECTOR = "/app/scripts/docker/backup-inspect.mjs";
|
|
2041
|
+
var NODE = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
|
|
2042
|
+
var S3_ENV = new Set([
|
|
2043
|
+
"OR3_STORAGE_S3_ENDPOINT",
|
|
2044
|
+
"OR3_STORAGE_S3_REGION",
|
|
2045
|
+
"OR3_STORAGE_S3_BUCKET",
|
|
2046
|
+
"OR3_STORAGE_S3_ACCESS_KEY_ID",
|
|
2047
|
+
"OR3_STORAGE_S3_SECRET_ACCESS_KEY",
|
|
2048
|
+
"OR3_STORAGE_S3_SESSION_TOKEN",
|
|
2049
|
+
"OR3_STORAGE_S3_FORCE_PATH_STYLE",
|
|
2050
|
+
"OR3_STORAGE_S3_KEY_PREFIX",
|
|
2051
|
+
"OR3_STORAGE_S3_ALLOW_INSECURE_HTTP",
|
|
2052
|
+
"OR3_BACKUP_S3_ENDPOINT_ORIGIN",
|
|
2053
|
+
"OR3_BACKUP_S3_BUCKET",
|
|
2054
|
+
"OR3_BACKUP_S3_KEY_PREFIX",
|
|
2055
|
+
"OR3_BACKUP_ARCHIVE_SHA256"
|
|
2056
|
+
]);
|
|
2057
|
+
var S3_SECRETS = new Set(["OR3_STORAGE_S3_ACCESS_KEY_ID", "OR3_STORAGE_S3_SECRET_ACCESS_KEY", "OR3_STORAGE_S3_SESSION_TOKEN"]);
|
|
2058
|
+
async function withBackupHelper(directory, image, env, options, body) {
|
|
2059
|
+
const name = `or3-backup-inspection-${randomUUID()}`;
|
|
2060
|
+
const names = options.passEnv ?? [];
|
|
2061
|
+
if (names.some((key) => !S3_ENV.has(key) || options.network === "none" && S3_SECRETS.has(key)))
|
|
2062
|
+
throw new Error("Invalid backup helper environment selection.");
|
|
2063
|
+
const lifetime = options.lifetimeSeconds ?? (options.network === "none" ? 240 : 14400);
|
|
2064
|
+
if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 14400)
|
|
2065
|
+
throw new Error("Invalid backup helper lifetime.");
|
|
2066
|
+
const streamTimeout = options.network === "egress" || options.lifetimeSeconds !== undefined ? lifetime * 1000 : undefined;
|
|
2067
|
+
let archiveMount = [];
|
|
2068
|
+
let helperUid = 65532, helperGid = 65532;
|
|
2069
|
+
if (options.archiveFile !== undefined) {
|
|
2070
|
+
if (!isAbsolute(options.archiveFile) || /[,\r\n]/.test(options.archiveFile))
|
|
2071
|
+
throw new Error("Invalid recovery archive path.");
|
|
2072
|
+
const info = await lstat(options.archiveFile);
|
|
2073
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || (info.mode & 63) !== 0 || typeof process.getuid === "function" && info.uid !== process.getuid())
|
|
2074
|
+
throw new Error("Recovery archive must be a private regular file owned by the operator.");
|
|
2075
|
+
const canonical = await realpath(options.archiveFile);
|
|
2076
|
+
if (/[,\r\n]/.test(canonical))
|
|
2077
|
+
throw new Error("Invalid recovery archive path.");
|
|
2078
|
+
archiveMount = ["--mount", `type=bind,src=${canonical},dst=/or3-backup.tar,readonly`];
|
|
2079
|
+
helperUid = info.uid;
|
|
2080
|
+
helperGid = info.gid;
|
|
2081
|
+
}
|
|
2082
|
+
const environment = { ...process.env };
|
|
2083
|
+
for (const key of S3_ENV)
|
|
2084
|
+
delete environment[key];
|
|
2085
|
+
for (const key of names) {
|
|
2086
|
+
if (env[key] !== undefined)
|
|
2087
|
+
environment[key] = env[key];
|
|
2088
|
+
}
|
|
2089
|
+
const secrets = secretValues(env);
|
|
2090
|
+
const checked = async (args) => {
|
|
2091
|
+
const result = await run("docker", args, directory, environment);
|
|
2092
|
+
if (!result.ok)
|
|
2093
|
+
throw new Error(`Offline backup reference inspection failed or is unsupported. ${redact(result.stderr, secrets)}`);
|
|
2094
|
+
return result.stdout.trim();
|
|
2095
|
+
};
|
|
2096
|
+
let primary;
|
|
2097
|
+
let value;
|
|
2098
|
+
try {
|
|
2099
|
+
await checked([
|
|
2100
|
+
"run",
|
|
2101
|
+
"--pull",
|
|
2102
|
+
"never",
|
|
2103
|
+
"--rm",
|
|
2104
|
+
"--detach",
|
|
2105
|
+
"--name",
|
|
2106
|
+
name,
|
|
2107
|
+
"--network",
|
|
2108
|
+
options.network === "none" ? "none" : "bridge",
|
|
2109
|
+
"--read-only",
|
|
2110
|
+
"--security-opt",
|
|
2111
|
+
"no-new-privileges:true",
|
|
2112
|
+
"--cap-drop",
|
|
2113
|
+
"ALL",
|
|
2114
|
+
"--memory",
|
|
2115
|
+
"256m",
|
|
2116
|
+
"--cpus",
|
|
2117
|
+
"0.5",
|
|
2118
|
+
"--pids-limit",
|
|
2119
|
+
"32",
|
|
2120
|
+
...options.archiveFile ? ["--user", `${helperUid}:${helperGid}`] : [],
|
|
2121
|
+
"--tmpfs",
|
|
2122
|
+
`/scratch:rw,noexec,nosuid,nodev,size=512m,mode=700,uid=${helperUid},gid=${helperGid}`,
|
|
2123
|
+
...archiveMount,
|
|
2124
|
+
...names.filter((key) => env[key] !== undefined).flatMap((key) => ["--env", key]),
|
|
2125
|
+
"--entrypoint",
|
|
2126
|
+
"sh",
|
|
2127
|
+
image,
|
|
2128
|
+
"-c",
|
|
2129
|
+
`sleep ${lifetime}`
|
|
2130
|
+
]);
|
|
2131
|
+
const helper = {
|
|
2132
|
+
exec: (args) => checked(["exec", name, ...args]),
|
|
2133
|
+
execFromFile: async (args, file) => streamFileToCommand("docker", ["exec", "-i", name, ...args], file, directory, secrets, environment, streamTimeout),
|
|
2134
|
+
execToFile: async (args, file) => streamCommandToFile("docker", ["exec", name, ...args], file, directory, secrets, environment, streamTimeout),
|
|
2135
|
+
async loadSqlite(dataTgz) {
|
|
2136
|
+
await this.execFromFile(["sh", "-c", "ulimit -f 16384; tar tvzf - > /scratch/inventory"], dataTgz);
|
|
2137
|
+
const inventory = JSON.parse(await this.exec(["sh", "-c", NODE, "or3-backup-inspection", INSPECTOR, "inventory"]));
|
|
2138
|
+
if (!Array.isArray(inventory.selected) || !inventory.selected.includes("./sync.sqlite") && !inventory.selected.includes("sync.sqlite") || inventory.selected.some((item) => !/^(?:\.\/)?sync[.]sqlite(?:-wal)?$/.test(item)) || inventory.selected.length > 2 || !Number.isSafeInteger(inventory.bytes) || inventory.bytes < 0)
|
|
2139
|
+
throw new Error("Invalid offline backup inspection inventory response.");
|
|
2140
|
+
const freeKiB = Number(await this.exec(["sh", "-c", "df -Pk /scratch | awk 'NR == 2 { print $4 }'"]));
|
|
2141
|
+
if (!Number.isFinite(freeKiB) || freeKiB < 0)
|
|
2142
|
+
throw new Error("Could not verify offline backup scratch capacity.");
|
|
2143
|
+
assertEnoughFreeSpace(freeKiB * 1024, inventory.bytes, "Offline backup inspection scratch");
|
|
2144
|
+
await this.execFromFile(["tar", "-xz", "-f", "-", "-C", "/scratch", ...inventory.selected], dataTgz);
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
value = await body(helper);
|
|
2148
|
+
} catch (error) {
|
|
2149
|
+
primary = error;
|
|
2150
|
+
}
|
|
2151
|
+
const cleanup = await run("docker", ["rm", "--force", name], directory, environment);
|
|
2152
|
+
if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
|
|
2153
|
+
throw new AggregateError(primary ? [primary] : [], `Offline backup inspection helper cleanup failed. ${redact(cleanup.stderr, secrets)}`);
|
|
2154
|
+
}
|
|
2155
|
+
if (primary)
|
|
2156
|
+
throw primary;
|
|
2157
|
+
return value;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// src/backup/remote.ts
|
|
2161
|
+
var INSPECTOR2 = "/app/scripts/docker/backup-inspect.mjs";
|
|
2162
|
+
var REMOTE = "/app/scripts/docker/remote-objects.mjs";
|
|
2163
|
+
var NODE2 = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
|
|
2164
|
+
var INPUT_NODE = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@" < /or3-backup.tar; else exec node "$@" < /or3-backup.tar; fi';
|
|
2165
|
+
var SOURCE_ENV = ["OR3_STORAGE_S3_ENDPOINT", "OR3_STORAGE_S3_REGION", "OR3_STORAGE_S3_BUCKET", "OR3_STORAGE_S3_KEY_PREFIX", "OR3_STORAGE_S3_ALLOW_INSECURE_HTTP"];
|
|
2166
|
+
var TRANSFER_ENV = [...SOURCE_ENV, "OR3_STORAGE_S3_ACCESS_KEY_ID", "OR3_STORAGE_S3_SECRET_ACCESS_KEY", "OR3_STORAGE_S3_SESSION_TOKEN", "OR3_STORAGE_S3_FORCE_PATH_STYLE"];
|
|
2167
|
+
function remoteSource(env) {
|
|
2168
|
+
if (env.OR3_SYNC_PROVIDER !== "sqlite" || env.NUXT_PUBLIC_STORAGE_PROVIDER !== "s3" || env.OR3_SQLITE_DRIVER && env.OR3_SQLITE_DRIVER !== "better-sqlite3")
|
|
2169
|
+
throw new Error("Unsupported offline S3 backup provider profile.");
|
|
2170
|
+
let endpoint;
|
|
2171
|
+
try {
|
|
2172
|
+
endpoint = new URL((env.OR3_STORAGE_S3_ENDPOINT ?? "").trim());
|
|
2173
|
+
} catch {
|
|
2174
|
+
throw new Error("Invalid S3 recovery configuration.");
|
|
2175
|
+
}
|
|
2176
|
+
if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.pathname !== "/" || endpoint.search || endpoint.hash || endpoint.protocol === "http:" && env.OR3_STORAGE_S3_ALLOW_INSECURE_HTTP !== "true")
|
|
2177
|
+
throw new Error("Invalid S3 recovery configuration.");
|
|
2178
|
+
const bucket = (env.OR3_STORAGE_S3_BUCKET ?? "").trim();
|
|
2179
|
+
const prefix = (env.OR3_STORAGE_S3_KEY_PREFIX ?? "").trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
2180
|
+
if (!bucket || bucket.length > 255 || /[\x00-\x20\x7f/\\]/.test(bucket) || prefix.length > 511 || /[\x00-\x1f\\]/.test(prefix) || prefix && prefix.split("/").some((part) => !part || part === "." || part === ".."))
|
|
2181
|
+
throw new Error("Invalid S3 recovery configuration.");
|
|
2182
|
+
return { endpointOrigin: endpoint.origin, bucket, keyPrefix: prefix ? `${prefix}/` : "" };
|
|
2183
|
+
}
|
|
2184
|
+
function assertRemoteTransferConfiguration(env) {
|
|
2185
|
+
const source = remoteSource(env);
|
|
2186
|
+
if (!env.OR3_STORAGE_S3_REGION?.trim() || !env.OR3_STORAGE_S3_ACCESS_KEY_ID?.trim() || !env.OR3_STORAGE_S3_SECRET_ACCESS_KEY?.trim())
|
|
2187
|
+
throw new Error("Invalid S3 recovery configuration.");
|
|
2188
|
+
return source;
|
|
2189
|
+
}
|
|
2190
|
+
async function preflightRemoteRestore(directory, manifest2, target, backupDir) {
|
|
2191
|
+
if (manifest2.remote.kind === "s3-copy-v1") {
|
|
2192
|
+
try {
|
|
2193
|
+
return await reconcileRemoteTarget(directory, manifest2, target, backupDir, manifest2.remote);
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
const detail = redact(error instanceof Error ? error.message : String(error), secretValues(target));
|
|
2196
|
+
throw new Error(`S3 reconciliation did not complete; additive objects may remain in the target. ${detail}`, { cause: error });
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
const result = await checkRemoteTarget(directory, manifest2, target, backupDir, manifest2.remote);
|
|
2200
|
+
if (result.missing || result.mismatched)
|
|
2201
|
+
throw new Error("S3 reference target has missing or mismatched objects.");
|
|
2202
|
+
return result;
|
|
2203
|
+
}
|
|
2204
|
+
function checkedCounts(result, expected) {
|
|
2205
|
+
if (result.checked !== expected || !Array.isArray(result.missing) || !Array.isArray(result.mismatched) || result.missing.length + result.mismatched.length > result.checked || [...result.missing, ...result.mismatched].some((value) => typeof value !== "string" || value.length > 1024))
|
|
2206
|
+
throw new Error("Invalid S3 reference check receipt.");
|
|
2207
|
+
return { checked: result.checked, missing: result.missing.length, mismatched: result.mismatched.length };
|
|
2208
|
+
}
|
|
2209
|
+
function sourceEnvironment(source) {
|
|
2210
|
+
const env = { OR3_SYNC_PROVIDER: "sqlite", NUXT_PUBLIC_STORAGE_PROVIDER: "s3", OR3_STORAGE_S3_ENDPOINT: source.endpointOrigin, OR3_STORAGE_S3_BUCKET: source.bucket, OR3_STORAGE_S3_KEY_PREFIX: source.keyPrefix, OR3_STORAGE_S3_ALLOW_INSECURE_HTTP: source.endpointOrigin.startsWith("http:") ? "true" : "false" };
|
|
2211
|
+
const normalized = remoteSource(env);
|
|
2212
|
+
if (normalized.endpointOrigin !== source.endpointOrigin || normalized.bucket !== source.bucket || normalized.keyPrefix !== source.keyPrefix)
|
|
2213
|
+
throw new Error("Invalid S3 backup source.");
|
|
2214
|
+
return env;
|
|
2215
|
+
}
|
|
2216
|
+
async function assertArchive(backupDir, remote) {
|
|
2217
|
+
const unvalidated = remote;
|
|
2218
|
+
if (unvalidated.kind !== "s3-copy-v1" || unvalidated.file !== "remote-objects.tar" || !/^[a-f0-9]{64}$/.test(remote.sha256) || !Number.isSafeInteger(remote.bytes) || remote.bytes < 1)
|
|
2219
|
+
throw new Error("Invalid S3 backup archive metadata.");
|
|
2220
|
+
const archive = join10(backupDir, remote.file);
|
|
2221
|
+
if ((await stat2(archive)).size !== remote.bytes || await sha256File(archive) !== remote.sha256)
|
|
2222
|
+
throw new Error("S3 backup archive checksum or size mismatch.");
|
|
2223
|
+
return archive;
|
|
2224
|
+
}
|
|
2225
|
+
async function verifyRemoteSnapshot(directory, image, backupDir, remote) {
|
|
2226
|
+
const env = sourceEnvironment(remote.source);
|
|
2227
|
+
const archive = await assertArchive(backupDir, remote);
|
|
2228
|
+
const receipt = join10(deploymentPaths(directory).operations, `.s3-verify-${randomUUID2()}.json`);
|
|
2229
|
+
let verified;
|
|
2230
|
+
try {
|
|
2231
|
+
verified = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), env, { network: "none", passEnv: SOURCE_ENV, archiveFile: archive, lifetimeSeconds: 14400 }, async (helper) => {
|
|
2232
|
+
await helper.loadSqlite(join10(backupDir, "data.tgz"));
|
|
2233
|
+
await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-verify", REMOTE, "verify", "/scratch/sync.sqlite"], receipt);
|
|
2234
|
+
if ((await stat2(receipt)).size > 64 * 1024)
|
|
2235
|
+
throw new Error("Invalid S3 backup verification receipt.");
|
|
2236
|
+
return JSON.parse(await readFile3(receipt, "utf8"));
|
|
2237
|
+
});
|
|
2238
|
+
} finally {
|
|
2239
|
+
await rm4(receipt, { force: true });
|
|
2240
|
+
}
|
|
2241
|
+
if (verified.contract !== 2 || verified.kind !== "s3-copy-v1" || verified.objectCount !== remote.objectCount || verified.payloadBytes !== remote.payloadBytes || verified.inventorySha256 !== remote.inventorySha256)
|
|
2242
|
+
throw new Error("S3 backup verification did not match the authenticated manifest.");
|
|
2243
|
+
await assertArchive(backupDir, remote);
|
|
2244
|
+
}
|
|
2245
|
+
async function reconcileRemoteTarget(directory, image, target, backupDir, remote) {
|
|
2246
|
+
const destination = assertRemoteTransferConfiguration(target);
|
|
2247
|
+
if (destination.keyPrefix !== remote.source.keyPrefix)
|
|
2248
|
+
throw new Error("S3 backup and target key prefix mismatch.");
|
|
2249
|
+
await verifyRemoteSnapshot(directory, image, backupDir, remote);
|
|
2250
|
+
const archive = await assertArchive(backupDir, remote);
|
|
2251
|
+
const env = { ...target, OR3_BACKUP_S3_ENDPOINT_ORIGIN: remote.source.endpointOrigin, OR3_BACKUP_S3_BUCKET: remote.source.bucket, OR3_BACKUP_S3_KEY_PREFIX: remote.source.keyPrefix, OR3_BACKUP_ARCHIVE_SHA256: remote.sha256 };
|
|
2252
|
+
const receipt = join10(deploymentPaths(directory).operations, `.s3-reconcile-${randomUUID2()}.json`);
|
|
2253
|
+
let result;
|
|
2254
|
+
try {
|
|
2255
|
+
result = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), env, { network: "egress", passEnv: [...TRANSFER_ENV, "OR3_BACKUP_S3_ENDPOINT_ORIGIN", "OR3_BACKUP_S3_BUCKET", "OR3_BACKUP_S3_KEY_PREFIX", "OR3_BACKUP_ARCHIVE_SHA256"], archiveFile: archive, lifetimeSeconds: 14400 }, async (helper) => {
|
|
2256
|
+
await helper.loadSqlite(join10(backupDir, "data.tgz"));
|
|
2257
|
+
await helper.execToFile(["sh", "-c", NODE2, "or3-s3-reconcile", REMOTE, "reconcile", "/scratch/sync.sqlite", "/or3-backup.tar"], receipt);
|
|
2258
|
+
if ((await stat2(receipt)).size > 64 * 1024)
|
|
2259
|
+
throw new Error("Invalid S3 reconciliation receipt.");
|
|
2260
|
+
return JSON.parse(await readFile3(receipt, "utf8"));
|
|
2261
|
+
});
|
|
2262
|
+
} finally {
|
|
2263
|
+
await rm4(receipt, { force: true });
|
|
2264
|
+
}
|
|
2265
|
+
if (!Number.isSafeInteger(result.uploaded) || result.uploaded < 0 || result.uploaded > remote.objectCount || !Number.isSafeInteger(result.markersCreated) || result.markersCreated < 0 || result.markersCreated > remote.objectCount)
|
|
2266
|
+
throw new Error("Invalid S3 reconciliation receipt.");
|
|
2267
|
+
return { uploaded: result.uploaded, markersCreated: result.markersCreated };
|
|
2268
|
+
}
|
|
2269
|
+
async function checkRemoteCopyTarget(directory, image, target, backupDir, remote) {
|
|
2270
|
+
const destination = assertRemoteTransferConfiguration(target);
|
|
2271
|
+
if (destination.keyPrefix !== remote.source.keyPrefix)
|
|
2272
|
+
throw new Error("S3 backup and target key prefix mismatch.");
|
|
2273
|
+
await verifyRemoteSnapshot(directory, image, backupDir, remote);
|
|
2274
|
+
const archive = await assertArchive(backupDir, remote);
|
|
2275
|
+
const env = { ...target, OR3_BACKUP_S3_ENDPOINT_ORIGIN: remote.source.endpointOrigin, OR3_BACKUP_S3_BUCKET: remote.source.bucket, OR3_BACKUP_S3_KEY_PREFIX: remote.source.keyPrefix, OR3_BACKUP_ARCHIVE_SHA256: remote.sha256 };
|
|
2276
|
+
const receipt = join10(deploymentPaths(directory).operations, `.s3-check-copy-${randomUUID2()}.json`);
|
|
2277
|
+
try {
|
|
2278
|
+
const result = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), env, { network: "egress", passEnv: [...TRANSFER_ENV, "OR3_BACKUP_S3_ENDPOINT_ORIGIN", "OR3_BACKUP_S3_BUCKET", "OR3_BACKUP_S3_KEY_PREFIX", "OR3_BACKUP_ARCHIVE_SHA256"], archiveFile: archive, lifetimeSeconds: 14400 }, async (helper) => {
|
|
2279
|
+
await helper.loadSqlite(join10(backupDir, "data.tgz"));
|
|
2280
|
+
await helper.execToFile(["sh", "-c", NODE2, "or3-s3-check-copy", REMOTE, "check-copy", "/scratch/sync.sqlite", "/or3-backup.tar"], receipt);
|
|
2281
|
+
if ((await stat2(receipt)).size > 64 * 1024 * 1024)
|
|
2282
|
+
throw new Error("Invalid S3 reference check receipt.");
|
|
2283
|
+
return JSON.parse(await readFile3(receipt, "utf8"));
|
|
2284
|
+
});
|
|
2285
|
+
return checkedCounts(result, remote.objectCount);
|
|
2286
|
+
} finally {
|
|
2287
|
+
await rm4(receipt, { force: true });
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
async function checkRemoteTarget(directory, image, target, backupDir, remote) {
|
|
2291
|
+
const source = assertRemoteTransferConfiguration(target);
|
|
2292
|
+
if (source.endpointOrigin !== remote.source.endpointOrigin || source.bucket !== remote.source.bucket || source.keyPrefix !== remote.source.keyPrefix)
|
|
2293
|
+
throw new Error("S3 reference snapshot can only use its original source target.");
|
|
2294
|
+
const unvalidated = remote;
|
|
2295
|
+
if (unvalidated.kind !== "s3-reference-v1" || unvalidated.file !== "remote-inventory.json" || !/^[a-f0-9]{64}$/.test(remote.sha256) || remote.inventorySha256 !== remote.sha256 || !Number.isSafeInteger(remote.bytes) || remote.bytes < 1 || remote.bytes > 64 * 1024 * 1024)
|
|
2296
|
+
throw new Error("Invalid S3 backup reference inventory.");
|
|
2297
|
+
const inventory = join10(backupDir, remote.file);
|
|
2298
|
+
const assertInventory = async () => {
|
|
2299
|
+
if ((await stat2(inventory)).size !== remote.bytes || await sha256File(inventory) !== remote.sha256)
|
|
2300
|
+
throw new Error("S3 backup inventory checksum or size mismatch.");
|
|
2301
|
+
};
|
|
2302
|
+
await assertInventory();
|
|
2303
|
+
const offlineEnv = sourceEnvironment(remote.source);
|
|
2304
|
+
const receipt = join10(deploymentPaths(directory).operations, `.s3-check-${randomUUID2()}.json`);
|
|
2305
|
+
try {
|
|
2306
|
+
const verified = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), offlineEnv, { network: "none", passEnv: SOURCE_ENV, archiveFile: inventory, lifetimeSeconds: 14400 }, async (helper) => {
|
|
2307
|
+
await helper.loadSqlite(join10(backupDir, "data.tgz"));
|
|
2308
|
+
await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-verify-inventory", REMOTE, "verify-inventory", "/scratch/sync.sqlite"], receipt);
|
|
2309
|
+
if ((await stat2(receipt)).size > 64 * 1024)
|
|
2310
|
+
throw new Error("Invalid S3 backup verification receipt.");
|
|
2311
|
+
return JSON.parse(await readFile3(receipt, "utf8"));
|
|
2312
|
+
});
|
|
2313
|
+
if (verified.contract !== 2 || verified.kind !== "s3-reference-v1" || verified.objectCount !== remote.objectCount || verified.payloadBytes !== remote.payloadBytes || verified.inventorySha256 !== remote.inventorySha256)
|
|
2314
|
+
throw new Error("S3 inventory verification did not match the authenticated manifest.");
|
|
2315
|
+
await rm4(receipt, { force: true });
|
|
2316
|
+
await assertInventory();
|
|
2317
|
+
const result = await withBackupHelper(directory, imageAtDigest(image.image, image.imageDigest), target, { network: "egress", passEnv: TRANSFER_ENV, archiveFile: inventory, lifetimeSeconds: 14400 }, async (helper) => {
|
|
2318
|
+
await helper.loadSqlite(join10(backupDir, "data.tgz"));
|
|
2319
|
+
await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-check", REMOTE, "check", "/scratch/sync.sqlite"], receipt);
|
|
2320
|
+
if ((await stat2(receipt)).size > 64 * 1024 * 1024)
|
|
2321
|
+
throw new Error("Invalid S3 reference check receipt.");
|
|
2322
|
+
return JSON.parse(await readFile3(receipt, "utf8"));
|
|
2323
|
+
});
|
|
2324
|
+
return checkedCounts(result, remote.objectCount);
|
|
2325
|
+
} finally {
|
|
2326
|
+
await rm4(receipt, { force: true });
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
function assertSummary(summary) {
|
|
2330
|
+
if (summary.contract !== 2 || !Number.isSafeInteger(summary.references) || summary.references < 0 || summary.references > 1e5 || !Number.isSafeInteger(summary.payloadBytes) || summary.payloadBytes < 0 || summary.payloadBytes > summary.references * 2 ** 30)
|
|
2331
|
+
throw new Error("Invalid S3 backup reference summary.");
|
|
2332
|
+
}
|
|
2333
|
+
async function captureRemoteInventory(directory, state, env, backupDir) {
|
|
2334
|
+
const source = remoteSource(env);
|
|
2335
|
+
const image = imageAtDigest(state.image, state.imageDigest);
|
|
2336
|
+
const data = join10(backupDir, "data.tgz");
|
|
2337
|
+
const inventory = join10(backupDir, "remote-inventory.json");
|
|
2338
|
+
const summary = await withBackupHelper(directory, image, env, { network: "none", passEnv: SOURCE_ENV, lifetimeSeconds: 14400 }, async (helper) => {
|
|
2339
|
+
await helper.loadSqlite(data);
|
|
2340
|
+
const result = JSON.parse(await helper.exec(["sh", "-c", NODE2, "or3-s3-summary", INSPECTOR2, "s3-summary", source.keyPrefix]));
|
|
2341
|
+
assertSummary(result);
|
|
2342
|
+
await helper.execToFile(["sh", "-c", NODE2, "or3-s3-inventory", REMOTE, "inventory", "/scratch/sync.sqlite"], inventory);
|
|
2343
|
+
return result;
|
|
2344
|
+
});
|
|
2345
|
+
const bytes = (await stat2(inventory)).size;
|
|
2346
|
+
if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > 64 * 1024 * 1024)
|
|
2347
|
+
throw new Error("Invalid S3 backup reference inventory.");
|
|
2348
|
+
const before = await sha256File(inventory);
|
|
2349
|
+
const receipt = join10(backupDir, `.s3-inventory-verification-${randomUUID2()}.json`);
|
|
2350
|
+
let verified;
|
|
2351
|
+
try {
|
|
2352
|
+
verified = await withBackupHelper(directory, image, env, { network: "none", passEnv: SOURCE_ENV, archiveFile: inventory, lifetimeSeconds: 14400 }, async (helper) => {
|
|
2353
|
+
await helper.loadSqlite(data);
|
|
2354
|
+
await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-verify-inventory", REMOTE, "verify-inventory", "/scratch/sync.sqlite"], receipt);
|
|
2355
|
+
if ((await stat2(receipt)).size > 64 * 1024)
|
|
2356
|
+
throw new Error("Invalid S3 backup verification receipt.");
|
|
2357
|
+
return JSON.parse(await readFile3(receipt, "utf8"));
|
|
2358
|
+
});
|
|
2359
|
+
} finally {
|
|
2360
|
+
await rm4(receipt, { force: true });
|
|
2361
|
+
}
|
|
2362
|
+
if (verified.contract !== 2 || verified.kind !== "s3-reference-v1" || verified.objectCount !== summary.references || verified.payloadBytes !== summary.payloadBytes || verified.inventorySha256 !== before)
|
|
2363
|
+
throw new Error("S3 backup verification did not match the canonical reference summary.");
|
|
2364
|
+
if (await sha256File(inventory) !== before || (await stat2(inventory)).size !== bytes)
|
|
2365
|
+
throw new Error("S3 backup inventory changed during verification.");
|
|
2366
|
+
return { kind: "s3-reference-v1", file: "remote-inventory.json", sha256: before, bytes, inventorySha256: verified.inventorySha256, objectCount: verified.objectCount, payloadBytes: verified.payloadBytes, source, providerVersion: "0.0.15" };
|
|
2367
|
+
}
|
|
2368
|
+
async function captureRemoteSnapshot(directory, state, env, backupDir) {
|
|
2369
|
+
const source = assertRemoteTransferConfiguration(env);
|
|
2370
|
+
const image = imageAtDigest(state.image, state.imageDigest);
|
|
2371
|
+
const data = join10(backupDir, "data.tgz");
|
|
2372
|
+
const archive = join10(backupDir, "remote-objects.tar");
|
|
2373
|
+
const summary = await withBackupHelper(directory, image, env, { network: "none", passEnv: SOURCE_ENV }, async (helper) => {
|
|
2374
|
+
await helper.loadSqlite(data);
|
|
2375
|
+
return JSON.parse(await helper.exec(["sh", "-c", NODE2, "or3-s3-summary", INSPECTOR2, "s3-summary", source.keyPrefix]));
|
|
2376
|
+
});
|
|
2377
|
+
assertSummary(summary);
|
|
2378
|
+
const required = summary.payloadBytes + summary.references * (64 * 1024 + 4096) + 64 * 1024 * 1024;
|
|
2379
|
+
await assertFreeSpaceForArchive(archive, required, "S3 backup");
|
|
2380
|
+
await withBackupHelper(directory, image, env, { network: "egress", passEnv: TRANSFER_ENV }, async (helper) => {
|
|
2381
|
+
await helper.loadSqlite(data);
|
|
2382
|
+
await helper.execToFile(["sh", "-c", NODE2, "or3-s3-capture", REMOTE, "capture", "/scratch/sync.sqlite"], archive);
|
|
2383
|
+
});
|
|
2384
|
+
const before = await sha256File(archive);
|
|
2385
|
+
const receipt = join10(backupDir, `.s3-verification-${randomUUID2()}.json`);
|
|
2386
|
+
let verified;
|
|
2387
|
+
try {
|
|
2388
|
+
verified = await withBackupHelper(directory, image, env, { network: "none", passEnv: SOURCE_ENV, archiveFile: archive, lifetimeSeconds: 14400 }, async (helper) => {
|
|
2389
|
+
await helper.loadSqlite(data);
|
|
2390
|
+
await helper.execToFile(["sh", "-c", INPUT_NODE, "or3-s3-verify", REMOTE, "verify", "/scratch/sync.sqlite"], receipt);
|
|
2391
|
+
const size = (await stat2(receipt)).size;
|
|
2392
|
+
if (size > 64 * 1024)
|
|
2393
|
+
throw new Error("Invalid S3 backup verification receipt.");
|
|
2394
|
+
return JSON.parse(await readFile3(receipt, "utf8"));
|
|
2395
|
+
});
|
|
2396
|
+
} finally {
|
|
2397
|
+
await rm4(receipt, { force: true });
|
|
2398
|
+
}
|
|
2399
|
+
if (verified.contract !== 2 || verified.kind !== "s3-copy-v1" || verified.objectCount !== summary.references || verified.payloadBytes !== summary.payloadBytes || !/^[a-f0-9]{64}$/.test(verified.inventorySha256))
|
|
2400
|
+
throw new Error("S3 backup verification did not match the canonical reference summary.");
|
|
2401
|
+
const bytes = (await stat2(archive)).size;
|
|
2402
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || await sha256File(archive) !== before)
|
|
2403
|
+
throw new Error("S3 backup archive changed during verification.");
|
|
2404
|
+
return { kind: "s3-copy-v1", file: "remote-objects.tar", sha256: before, bytes, inventorySha256: verified.inventorySha256, objectCount: verified.objectCount, payloadBytes: verified.payloadBytes, source, providerVersion: "0.0.15" };
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// src/backup/vault.ts
|
|
2408
|
+
import { createHash as createHash2, randomUUID as randomUUID3 } from "node:crypto";
|
|
2409
|
+
import { constants } from "node:fs";
|
|
2410
|
+
import { lstat as lstat2, open as open2, readFile as readFile4 } from "node:fs/promises";
|
|
2411
|
+
import { isAbsolute as isAbsolute2, join as join11 } from "node:path";
|
|
2412
|
+
async function readAuthenticatedVaultId(path, expectedSha256) {
|
|
2413
|
+
if (!/^[a-f0-9]{64}$/.test(expectedSha256))
|
|
2414
|
+
throw new Error("Invalid vault backup checksum.");
|
|
2415
|
+
const file = await open2(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
2416
|
+
try {
|
|
2417
|
+
const before = await file.stat({ bigint: true });
|
|
2418
|
+
const identity = (info) => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(":");
|
|
2419
|
+
const pathInfo = await lstat2(path, { bigint: true });
|
|
2420
|
+
if (!before.isFile() || !pathInfo.isFile() || pathInfo.isSymbolicLink() || before.nlink !== 1n || before.size < 113n || before.size > BigInt(2 ** 30 + 112) || identity(before) !== identity(pathInfo))
|
|
2421
|
+
throw new Error("invalid vault backup");
|
|
2422
|
+
const hash = createHash2("sha256"), buffer = Buffer.alloc(1024 * 1024), header = Buffer.alloc(96);
|
|
2423
|
+
let offset = 0;
|
|
2424
|
+
while (offset < Number(before.size)) {
|
|
2425
|
+
const count = (await file.read(buffer, 0, Math.min(buffer.length, Number(before.size) - offset), offset)).bytesRead;
|
|
2426
|
+
if (count === 0)
|
|
2427
|
+
throw new Error("truncated vault backup");
|
|
2428
|
+
if (offset < header.length)
|
|
2429
|
+
buffer.copy(header, offset, 0, Math.min(count, header.length - offset));
|
|
2430
|
+
hash.update(buffer.subarray(0, count));
|
|
2431
|
+
offset += count;
|
|
2432
|
+
}
|
|
2433
|
+
if (hash.digest("hex") !== expectedSha256 || identity(before) !== identity(await file.stat({ bigint: true })) || identity(before) !== identity(await lstat2(path, { bigint: true })))
|
|
2434
|
+
throw new Error("vault backup changed");
|
|
2435
|
+
const vaultId = header.subarray(8, 40).toString("latin1");
|
|
2436
|
+
if (!header.subarray(0, 8).equals(Buffer.from("OR3BAK01")) || !/^[a-f0-9]{32}$/.test(vaultId) || header.readBigUInt64BE(84) + 112n !== before.size || header.subarray(92, 96).some((byte) => byte !== 0))
|
|
2437
|
+
throw new Error("invalid vault header");
|
|
2438
|
+
return vaultId;
|
|
2439
|
+
} catch {
|
|
2440
|
+
throw new Error("Vault backup checksum, header, or file identity is invalid.");
|
|
2441
|
+
} finally {
|
|
2442
|
+
await file.close();
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
function backupHasVaultCoverage(manifest2, env, archivedConfig) {
|
|
2446
|
+
if (Object.keys(env).some((key) => /^(?:RUNTIME_CONFIG|NUXT_SECRETS|NITRO_ENV_(?:PREFIX|EXPANSION)|NITRO_SECRETS(?:_.*)?)$/.test(key)))
|
|
2447
|
+
return false;
|
|
2448
|
+
const provider = env.NUXT_SECRETS_PROVIDER;
|
|
2449
|
+
if (provider === undefined && manifest2.appVersion === "0.1.90" && manifest2.image === "ghcr.io/saluana/or3-chat:0.1.90" && manifest2.imageDigest === "sha256:d50b70d6522574798f1a3081894b5bf8fe9eca7aa5f183ad5a562de2e4f41c12" && archivedConfig !== undefined && createHash2("sha256").update(archivedConfig).digest("hex") === manifest2.configSha256) {
|
|
2450
|
+
const keys = new Set;
|
|
2451
|
+
for (const line of archivedConfig.split(/\r?\n/)) {
|
|
2452
|
+
const text = line.trim();
|
|
2453
|
+
if (!text || text.startsWith("#"))
|
|
2454
|
+
continue;
|
|
2455
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(text);
|
|
2456
|
+
if (!match || keys.has(match[1]) || !/^(?:'[^'\r\n]*'|"[^"$\\\r\n]*"|[^'"$\\\s]*)$/.test(match[2]))
|
|
2457
|
+
return false;
|
|
2458
|
+
keys.add(match[1]);
|
|
2459
|
+
}
|
|
2460
|
+
return !keys.has("NUXT_SECRETS_PROVIDER");
|
|
2461
|
+
}
|
|
2462
|
+
return provider === "" || (provider === undefined || provider === "or3-secrets") && Boolean(manifest2.vaultSha256);
|
|
2463
|
+
}
|
|
2464
|
+
async function assertVaultVolumesExist(directory, state) {
|
|
2465
|
+
const dataVolume = `${state.volumeName}-vault-data`;
|
|
2466
|
+
const keyVolume = `${state.volumeName}-vault-key`;
|
|
2467
|
+
for (const volume of [dataVolume, keyVolume]) {
|
|
2468
|
+
const result = await run("docker", ["volume", "inspect", volume], directory);
|
|
2469
|
+
if (!result.ok)
|
|
2470
|
+
throw new Error("Vault custody is missing or cannot be inspected. The operation is refused; preserve the encrypted backup and recover the vault key and volumes first.");
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
async function assertExistingVaultCustody(directory, state, manifest2, backupPath, backupEnv) {
|
|
2474
|
+
const archivedConfig = await readFile4(join11(backupPath, "config.env"), "utf8");
|
|
2475
|
+
if (!backupHasVaultCoverage(manifest2, backupEnv, archivedConfig))
|
|
2476
|
+
throw new Error("Backup lacks the vault archive required by its saved configuration. Preserve it and use a separately reviewed recovery procedure.");
|
|
2477
|
+
if (!manifest2.vaultSha256)
|
|
2478
|
+
return;
|
|
2479
|
+
const expectedVaultId = await readAuthenticatedVaultId(join11(backupPath, "vault.backup"), manifest2.vaultSha256);
|
|
2480
|
+
const dataVolume = `${state.volumeName}-vault-data`;
|
|
2481
|
+
const keyVolume = `${state.volumeName}-vault-key`;
|
|
2482
|
+
await assertVaultVolumesExist(directory, state);
|
|
2483
|
+
const script = `
|
|
2484
|
+
const fs = require('node:fs');
|
|
2485
|
+
const { spawnSync } = require('node:child_process');
|
|
2486
|
+
for (const path of ['/vault-data/live/complete','/vault-key/master.key','/vault-key/operator.token']) {
|
|
2487
|
+
const st = fs.lstatSync(path);
|
|
2488
|
+
if (!st.isFile() || st.isSymbolicLink() || st.uid !== 1000 || (st.mode & 0o777) !== 0o600 || st.nlink !== 1 || st.size < 1) process.exit(1);
|
|
2489
|
+
}
|
|
2490
|
+
if (!/^[a-f0-9]{32}$/.test(fs.readFileSync('/vault-data/live/complete', 'utf8').trim())) process.exit(1);
|
|
2491
|
+
// Never open the running vault database for maintenance. Copy a stable view to
|
|
2492
|
+
// private tmpfs and verify that copy with the existing vault's master key.
|
|
2493
|
+
const db = '/vault-data/live/vault.sqlite';
|
|
2494
|
+
const journal = db + '-journal';
|
|
2495
|
+
const identity = (s) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].join(':');
|
|
2496
|
+
const journalIdentity = () => {
|
|
2497
|
+
let input;
|
|
2498
|
+
try { input = fs.openSync(journal, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); }
|
|
2499
|
+
catch (e) { if (e.code === 'ENOENT') return 'absent'; throw e; }
|
|
2500
|
+
try {
|
|
2501
|
+
const before = fs.fstatSync(input, { bigint: true });
|
|
2502
|
+
if (!before.isFile() || before.uid !== 1000n || (before.mode & 0o777n) !== 0o600n || before.nlink !== 1n) process.exit(1);
|
|
2503
|
+
if (before.size !== 0n) {
|
|
2504
|
+
if (before.size < 8n) process.exit(1);
|
|
2505
|
+
const header = Buffer.alloc(8);
|
|
2506
|
+
if (fs.readSync(input, header, 0, 8, 0) !== 8 || !header.every(byte => byte === 0)) process.exit(1);
|
|
2507
|
+
}
|
|
2508
|
+
if (identity(before) !== identity(fs.fstatSync(input, { bigint: true })) || identity(before) !== identity(fs.lstatSync(journal, { bigint: true }))) process.exit(1);
|
|
2509
|
+
return identity(before);
|
|
2510
|
+
} finally { fs.closeSync(input); }
|
|
2511
|
+
};
|
|
2512
|
+
const copy = (source, destination, maxSize) => {
|
|
2513
|
+
const input = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
2514
|
+
try {
|
|
2515
|
+
const before = fs.fstatSync(input, { bigint: true });
|
|
2516
|
+
if (!before.isFile() || before.uid !== 1000n || (before.mode & 0o777n) !== 0o600n || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maxSize)) process.exit(1);
|
|
2517
|
+
const output = fs.openSync(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
2518
|
+
try {
|
|
2519
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
2520
|
+
let remaining = Number(before.size);
|
|
2521
|
+
while (remaining > 0) {
|
|
2522
|
+
const n = fs.readSync(input, buffer, 0, Math.min(buffer.length, remaining), null);
|
|
2523
|
+
if (n === 0) process.exit(1);
|
|
2524
|
+
let offset = 0;
|
|
2525
|
+
while (offset < n) offset += fs.writeSync(output, buffer, offset, n - offset);
|
|
2526
|
+
remaining -= n;
|
|
2527
|
+
}
|
|
2528
|
+
} finally { fs.closeSync(output); }
|
|
2529
|
+
if (identity(before) !== identity(fs.fstatSync(input, { bigint: true })) || identity(before) !== identity(fs.lstatSync(source, { bigint: true }))) process.exit(1);
|
|
2530
|
+
return identity(before);
|
|
2531
|
+
} finally { fs.closeSync(input); }
|
|
2532
|
+
};
|
|
2533
|
+
const journalBefore = journalIdentity();
|
|
2534
|
+
fs.mkdirSync('/scratch/vault', { mode: 0o700 });
|
|
2535
|
+
const dbIdentity = copy(db, '/scratch/vault/vault.sqlite', 128 * 1024 * 1024);
|
|
2536
|
+
const markerIdentity = copy('/vault-data/live/complete', '/scratch/vault/complete', 128);
|
|
2537
|
+
const keyIdentity = copy('/vault-key/master.key', '/scratch/master.key', 4096);
|
|
2538
|
+
if (fs.readFileSync('/scratch/vault/complete', 'utf8').trim() !== process.env.OR3_EXPECTED_VAULT_ID) process.exit(1);
|
|
2539
|
+
if (journalIdentity() !== journalBefore) process.exit(1);
|
|
2540
|
+
const audit = '/app/vault/cli.js audit verify'.split(' ');
|
|
2541
|
+
const checked = spawnSync('/usr/local/bin/bun', ['--no-env-file', ...audit, '--vault-dir', '/scratch/vault', '--key-file', '/scratch/master.key'], { stdio: 'ignore', timeout: 120000 });
|
|
2542
|
+
if (checked.status !== 0 || checked.error) process.exit(1);
|
|
2543
|
+
if (journalIdentity() !== journalBefore) process.exit(1);
|
|
2544
|
+
if (dbIdentity !== identity(fs.lstatSync(db, { bigint: true })) || markerIdentity !== identity(fs.lstatSync('/vault-data/live/complete', { bigint: true })) || keyIdentity !== identity(fs.lstatSync('/vault-key/master.key', { bigint: true }))) process.exit(1);
|
|
2545
|
+
`;
|
|
2546
|
+
const name = `or3-vault-inspection-${randomUUID3()}`;
|
|
2547
|
+
let failure;
|
|
2548
|
+
try {
|
|
2549
|
+
const result = await run("docker", [
|
|
2550
|
+
"run",
|
|
2551
|
+
"--rm",
|
|
2552
|
+
"--name",
|
|
2553
|
+
name,
|
|
2554
|
+
"--pull",
|
|
2555
|
+
"never",
|
|
2556
|
+
"--log-driver",
|
|
2557
|
+
"none",
|
|
2558
|
+
"--network",
|
|
2559
|
+
"none",
|
|
2560
|
+
"--read-only",
|
|
2561
|
+
"--user",
|
|
2562
|
+
"1000:65532",
|
|
2563
|
+
"--security-opt",
|
|
2564
|
+
"no-new-privileges:true",
|
|
2565
|
+
"--cap-drop",
|
|
2566
|
+
"ALL",
|
|
2567
|
+
"--memory",
|
|
2568
|
+
"256m",
|
|
2569
|
+
"--pids-limit",
|
|
2570
|
+
"32",
|
|
2571
|
+
"--tmpfs",
|
|
2572
|
+
"/scratch:rw,noexec,nosuid,nodev,size=192m,mode=700,uid=1000,gid=65532",
|
|
2573
|
+
"--env",
|
|
2574
|
+
`OR3_EXPECTED_VAULT_ID=${expectedVaultId}`,
|
|
2575
|
+
"--mount",
|
|
2576
|
+
`type=volume,src=${dataVolume},dst=/vault-data,readonly`,
|
|
2577
|
+
"--mount",
|
|
2578
|
+
`type=volume,src=${keyVolume},dst=/vault-key,readonly`,
|
|
2579
|
+
"--entrypoint",
|
|
2580
|
+
"/nodejs/bin/node",
|
|
2581
|
+
imageAtDigest(manifest2.image, manifest2.imageDigest),
|
|
2582
|
+
"-e",
|
|
2583
|
+
script
|
|
2584
|
+
], directory);
|
|
2585
|
+
if (!result.ok)
|
|
2586
|
+
throw new Error("Vault custody is empty, incomplete, or unreadable. Restore is refused; preserve the encrypted backup and recover the vault key and volumes first.");
|
|
2587
|
+
} catch (error) {
|
|
2588
|
+
failure = error;
|
|
2589
|
+
}
|
|
2590
|
+
const cleanup = await run("docker", ["rm", "--force", name], directory);
|
|
2591
|
+
if (!cleanup.ok && !/No such container/i.test(cleanup.stderr))
|
|
2592
|
+
throw new AggregateError(failure ? [failure] : [], "Vault custody inspection cleanup failed. Inspect the named helper before retrying.");
|
|
2593
|
+
if (failure)
|
|
2594
|
+
throw failure;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
1796
2597
|
// src/backup/manifests.ts
|
|
1797
2598
|
var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
|
|
1798
2599
|
async function backupAuthenticationKey(directory, create = false) {
|
|
@@ -1820,14 +2621,14 @@ function backupAuthenticationTag(key, manifestContents) {
|
|
|
1820
2621
|
}
|
|
1821
2622
|
async function writeBackupAuthentication(directory, backupPath, manifestContents) {
|
|
1822
2623
|
const key = await backupAuthenticationKey(directory, true);
|
|
1823
|
-
await writeSecure(
|
|
2624
|
+
await writeSecure(join12(backupPath, "manifest.auth"), `${backupAuthenticationTag(key, manifestContents)}
|
|
1824
2625
|
`);
|
|
1825
2626
|
}
|
|
1826
2627
|
async function assertBackupAuthentication(directory, backupPath, manifestContents) {
|
|
1827
2628
|
const key = await backupAuthenticationKey(directory);
|
|
1828
2629
|
let provided;
|
|
1829
2630
|
try {
|
|
1830
|
-
const value = (await readText(
|
|
2631
|
+
const value = (await readText(join12(backupPath, "manifest.auth"))).trim();
|
|
1831
2632
|
if (!/^[0-9a-f]{64}$/i.test(value))
|
|
1832
2633
|
throw new Error("invalid authentication tag");
|
|
1833
2634
|
provided = Buffer.from(value, "hex");
|
|
@@ -1856,10 +2657,10 @@ function classifyBackupError(error) {
|
|
|
1856
2657
|
return { code: "backup-entry-invalid", message };
|
|
1857
2658
|
}
|
|
1858
2659
|
async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
1859
|
-
const path =
|
|
2660
|
+
const path = join12(backupsRoot, entry);
|
|
1860
2661
|
let info;
|
|
1861
2662
|
try {
|
|
1862
|
-
info = await
|
|
2663
|
+
info = await lstat3(path);
|
|
1863
2664
|
} catch (error) {
|
|
1864
2665
|
return { kind: "unreadable", entryName: entry, ...classifyBackupError(error) };
|
|
1865
2666
|
}
|
|
@@ -1888,7 +2689,7 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
|
1888
2689
|
};
|
|
1889
2690
|
}
|
|
1890
2691
|
try {
|
|
1891
|
-
await
|
|
2692
|
+
await lstat3(join12(path, "manifest.auth"));
|
|
1892
2693
|
} catch (error) {
|
|
1893
2694
|
if (error.code === "ENOENT") {
|
|
1894
2695
|
return {
|
|
@@ -1901,8 +2702,8 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
|
1901
2702
|
return { kind: "unreadable", entryName: entry, ...classifyBackupError(error) };
|
|
1902
2703
|
}
|
|
1903
2704
|
try {
|
|
1904
|
-
const raw = JSON.parse(await readText(
|
|
1905
|
-
if (typeof raw.schemaVersion === "number" && raw.schemaVersion >
|
|
2705
|
+
const raw = JSON.parse(await readText(join12(path, "manifest.json")));
|
|
2706
|
+
if (typeof raw.schemaVersion === "number" && raw.schemaVersion > 2) {
|
|
1906
2707
|
return {
|
|
1907
2708
|
kind: "unsupported",
|
|
1908
2709
|
entryName: entry,
|
|
@@ -1932,9 +2733,13 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
|
1932
2733
|
};
|
|
1933
2734
|
}
|
|
1934
2735
|
let bytes = 0;
|
|
1935
|
-
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : []]) {
|
|
1936
|
-
bytes += (await
|
|
2736
|
+
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : [], ...manifest2.schemaVersion === 2 ? [manifest2.remote.file] : []]) {
|
|
2737
|
+
bytes += (await lstat3(join12(path, file))).size;
|
|
1937
2738
|
}
|
|
2739
|
+
const savedConfig = await readText(join12(path, "config.env"));
|
|
2740
|
+
if (createHash3("sha256").update(savedConfig).digest("hex") !== manifest2.configSha256)
|
|
2741
|
+
throw new Error(`Backup configuration changed during inventory for ${manifest2.backupId}.`);
|
|
2742
|
+
const recoveryArtifactsComplete = Boolean(manifest2.managedAssetSha256) && backupHasVaultCoverage(manifest2, parseEnv(savedConfig), savedConfig);
|
|
1938
2743
|
return {
|
|
1939
2744
|
kind: "verified",
|
|
1940
2745
|
backup: {
|
|
@@ -1943,7 +2748,11 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
|
1943
2748
|
appVersion: manifest2.appVersion,
|
|
1944
2749
|
path,
|
|
1945
2750
|
bytes,
|
|
1946
|
-
dataSha256: manifest2.dataSha256
|
|
2751
|
+
dataSha256: manifest2.dataSha256,
|
|
2752
|
+
schemaVersion: manifest2.schemaVersion,
|
|
2753
|
+
recoveryArtifactsComplete,
|
|
2754
|
+
...manifest2.snapshotKind ? { snapshotKind: manifest2.snapshotKind } : {},
|
|
2755
|
+
...manifest2.schemaVersion === 2 ? { remoteKind: manifest2.remote.kind } : {}
|
|
1947
2756
|
}
|
|
1948
2757
|
};
|
|
1949
2758
|
} catch (error) {
|
|
@@ -1989,15 +2798,15 @@ async function inventoryBackups(directory) {
|
|
|
1989
2798
|
});
|
|
1990
2799
|
return { entries, storeErrors: [] };
|
|
1991
2800
|
}
|
|
1992
|
-
async function enumerateBackups(directory) {
|
|
1993
|
-
const inventory = await inventoryBackups(directory);
|
|
1994
|
-
return inventory.entries.filter((entry) => entry.kind === "verified").map((entry) => entry.backup).sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
|
|
1995
|
-
}
|
|
1996
2801
|
function assertBackupMatchesDeployment(manifest2, backupEnv, state, env) {
|
|
1997
2802
|
if (manifest2.mode !== state.mode || manifest2.appVersion === undefined || manifest2.image === undefined) {
|
|
1998
2803
|
throw new Error(`Backup ${manifest2.backupId} does not match the managed deployment mode.`);
|
|
1999
2804
|
}
|
|
2000
2805
|
assertDeploymentIdentity(state, env);
|
|
2806
|
+
if (managedProviderProfile(backupEnv) !== managedProviderProfile(env)) {
|
|
2807
|
+
throw new Error("backup-profile-mismatch: backup and deployment use different managed provider profiles.");
|
|
2808
|
+
}
|
|
2809
|
+
restoreStorageConfiguration(backupEnv, env);
|
|
2001
2810
|
const expected = {
|
|
2002
2811
|
OR3_COMPOSE_PROJECT: state.composeProject,
|
|
2003
2812
|
OR3_VOLUME_NAME: state.volumeName,
|
|
@@ -2040,32 +2849,53 @@ function assertBackupMatchesDeployment(manifest2, backupEnv, state, env) {
|
|
|
2040
2849
|
}
|
|
2041
2850
|
async function readManifest(backupPath, authenticatedForDirectory) {
|
|
2042
2851
|
await lifecycleFaults.beforeArchiveRead?.();
|
|
2043
|
-
const manifestContents = await readText(
|
|
2852
|
+
const manifestContents = await readText(join12(backupPath, "manifest.json"));
|
|
2044
2853
|
if (authenticatedForDirectory)
|
|
2045
2854
|
await assertBackupAuthentication(authenticatedForDirectory, backupPath, manifestContents);
|
|
2046
2855
|
const manifest2 = JSON.parse(manifestContents);
|
|
2047
|
-
|
|
2856
|
+
const unvalidated = manifest2;
|
|
2857
|
+
if (unvalidated.schemaVersion !== 1 && unvalidated.schemaVersion !== 2 || !BACKUP_ID_PATTERN.test(manifest2.backupId) || unvalidated.snapshotKind !== undefined && unvalidated.snapshotKind !== "full" && unvalidated.snapshotKind !== "safety" || !Number.isFinite(Date.parse(manifest2.createdAt)) || !isVersion(manifest2.appVersion) || !manifest2.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest2.imageDigest) || !/^[0-9a-f]{64}$/i.test(manifest2.dataSha256) || !/^[0-9a-f]{64}$/i.test(manifest2.configSha256 ?? "") || manifest2.vaultSha256 !== undefined && !/^[0-9a-f]{64}$/i.test(manifest2.vaultSha256) || manifest2.dataBytes !== undefined && (!Number.isSafeInteger(manifest2.dataBytes) || manifest2.dataBytes < 0)) {
|
|
2048
2858
|
throw new Error(`Invalid backup manifest at ${backupPath}.`);
|
|
2049
2859
|
}
|
|
2050
|
-
const actual = await sha256File(
|
|
2860
|
+
const actual = await sha256File(join12(backupPath, "data.tgz"));
|
|
2051
2861
|
if (actual !== manifest2.dataSha256)
|
|
2052
2862
|
throw new Error(`Backup checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${actual}.`);
|
|
2053
2863
|
if (manifest2.configSha256) {
|
|
2054
|
-
const configActual = await sha256File(
|
|
2864
|
+
const configActual = await sha256File(join12(backupPath, "config.env"));
|
|
2055
2865
|
if (configActual !== manifest2.configSha256)
|
|
2056
2866
|
throw new Error(`Backup configuration checksum mismatch for ${manifest2.backupId}.`);
|
|
2057
2867
|
}
|
|
2058
|
-
|
|
2868
|
+
const config = parseEnv(await readText(join12(backupPath, "config.env")));
|
|
2869
|
+
const storage = (config.NUXT_PUBLIC_STORAGE_PROVIDER ?? "").trim().toLowerCase();
|
|
2870
|
+
if (manifest2.schemaVersion === 1 && storage === "s3") {
|
|
2871
|
+
throw new Error(`Invalid backup manifest for ${manifest2.backupId}: schema-1 S3 snapshots do not contain recoverable remote objects.`);
|
|
2872
|
+
}
|
|
2873
|
+
if (manifest2.schemaVersion === 2) {
|
|
2874
|
+
const remote = manifest2.remote;
|
|
2875
|
+
if (unvalidated.storage !== "s3" || storage !== "s3" || !remote || !(remote.kind === "s3-copy-v1" && remote.file === "remote-objects.tar" || remote.kind === "s3-reference-v1" && remote.file === "remote-inventory.json") || !/^[a-f0-9]{64}$/.test(remote.sha256) || !/^[a-f0-9]{64}$/.test(remote.inventorySha256) || !Number.isSafeInteger(remote.bytes) || remote.bytes < 1 || !Number.isSafeInteger(remote.objectCount) || remote.objectCount < 0 || remote.objectCount > 1e5 || !Number.isSafeInteger(remote.payloadBytes) || remote.payloadBytes < 0 || remote.payloadBytes > remote.objectCount * 2 ** 30 || remote.providerVersion !== "0.0.15" || remote.kind === "s3-reference-v1" && (remote.bytes > 64 * 1024 * 1024 || remote.inventorySha256 !== remote.sha256)) {
|
|
2876
|
+
throw new Error(`Invalid backup manifest for ${manifest2.backupId}: invalid S3 remote snapshot.`);
|
|
2877
|
+
}
|
|
2878
|
+
const source = remoteSource(config);
|
|
2879
|
+
if (!remote.source || remote.source.endpointOrigin !== source.endpointOrigin || remote.source.bucket !== source.bucket || remote.source.keyPrefix !== source.keyPrefix) {
|
|
2880
|
+
throw new Error(`Invalid backup manifest for ${manifest2.backupId}: S3 source differs from the authenticated configuration.`);
|
|
2881
|
+
}
|
|
2882
|
+
const file = join12(backupPath, remote.file);
|
|
2883
|
+
const info = await lstat3(file);
|
|
2884
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size !== remote.bytes || await sha256File(file) !== remote.sha256) {
|
|
2885
|
+
throw new Error(`Backup remote checksum mismatch or size mismatch for ${manifest2.backupId}.`);
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
if (manifest2.vaultSha256 && await sha256File(join12(backupPath, "vault.backup")) !== manifest2.vaultSha256)
|
|
2059
2889
|
throw new Error(`Vault backup checksum mismatch for ${manifest2.backupId}.`);
|
|
2060
2890
|
await verifiedManagedAssetContents(backupPath, manifest2);
|
|
2061
2891
|
return manifest2;
|
|
2062
2892
|
}
|
|
2063
2893
|
async function resolveBackup(directory, value) {
|
|
2064
|
-
if (!
|
|
2894
|
+
if (!isAbsolute3(value) && !BACKUP_ID_PATTERN.test(value)) {
|
|
2065
2895
|
throw new Error(`Backup ID "${value}" is invalid. Use an OR3-generated backup ID or an absolute external backup path.`);
|
|
2066
2896
|
}
|
|
2067
|
-
const candidate =
|
|
2068
|
-
if (!await fileExists(
|
|
2897
|
+
const candidate = isAbsolute3(value) ? value : backupDirectory(directory, value);
|
|
2898
|
+
if (!await fileExists(join12(candidate, "manifest.json")))
|
|
2069
2899
|
throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
|
|
2070
2900
|
return resolve4(candidate);
|
|
2071
2901
|
}
|
|
@@ -2074,7 +2904,7 @@ async function recordedBackupPath(directory, pending) {
|
|
|
2074
2904
|
const path = previous ? pending.previousBackupPath : pending.backupPath;
|
|
2075
2905
|
const backupId = previous ? pending.previousBackupId : pending.backupId;
|
|
2076
2906
|
const resolved = path ? resolve4(path) : backupId ? await resolveBackup(directory, backupId) : undefined;
|
|
2077
|
-
if (!resolved || !await fileExists(
|
|
2907
|
+
if (!resolved || !await fileExists(join12(resolved, "manifest.json"))) {
|
|
2078
2908
|
throw new Error(`The incomplete ${pending.operation} has no readable ${previous ? "pre-mutation" : "target"} backup source.`);
|
|
2079
2909
|
}
|
|
2080
2910
|
const manifest2 = await readManifest(resolved, directory);
|
|
@@ -2216,16 +3046,21 @@ async function maskedQuestion(question) {
|
|
|
2216
3046
|
|
|
2217
3047
|
// src/deployment/adoption-source.ts
|
|
2218
3048
|
import { existsSync as existsSync2 } from "node:fs";
|
|
2219
|
-
import { basename as basename2, join as
|
|
3049
|
+
import { basename as basename2, join as join13 } from "node:path";
|
|
2220
3050
|
|
|
2221
3051
|
// src/runtime/health.ts
|
|
2222
3052
|
var DEEP_HEALTH_TIMEOUT_MS = 180000;
|
|
2223
|
-
var HEALTH_SCRIPT = "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);
|
|
3053
|
+
var HEALTH_SCRIPT = "const fs=require('node:fs');try{const auth=process.env.AUTH_PROVIDER||process.env.OR3_AUTH_PROVIDER||'basic-auth',storage=process.env.NUXT_PUBLIC_STORAGE_PROVIDER||'fs',sync=process.env.OR3_SYNC_PROVIDER||'sqlite';if(sync!=='sqlite'||!(auth==='basic-auth'&&storage==='fs'||auth==='clerk'&&storage==='s3')||process.env.AUTH_PROVIDER&&process.env.OR3_AUTH_PROVIDER&&process.env.AUTH_PROVIDER!==process.env.OR3_AUTH_PROVIDER)throw Error('profile');fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);const paths=auth==='basic-auth'?[process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH]:[process.env.OR3_SQLITE_DB_PATH];if(paths.some(p=>!p))throw Error('database path');for(const path of paths){const fd=fs.openSync(path,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok'||body.providers?.auth?.provider!==(process.env.AUTH_PROVIDER||process.env.OR3_AUTH_PROVIDER||'basic-auth')||body.providers?.sync?.provider!=='sqlite'||body.providers?.storage?.provider!==(process.env.NUXT_PUBLIC_STORAGE_PROVIDER||'fs'))process.exit(1)}).catch(()=>process.exit(1))";
|
|
2224
3054
|
var VERIFY_DATABASES_SCRIPT = `
|
|
2225
3055
|
const fs = require('node:fs');
|
|
2226
3056
|
const Database = require('/app/.output/server/node_modules/better-sqlite3');
|
|
2227
3057
|
const results = [];
|
|
2228
|
-
|
|
3058
|
+
const auth = process.env.AUTH_PROVIDER || process.env.OR3_AUTH_PROVIDER || 'basic-auth';
|
|
3059
|
+
const storage = process.env.NUXT_PUBLIC_STORAGE_PROVIDER || 'fs', sync = process.env.OR3_SYNC_PROVIDER || 'sqlite';
|
|
3060
|
+
if (sync !== 'sqlite' || !(auth === 'basic-auth' && storage === 'fs' || auth === 'clerk' && storage === 's3') || process.env.AUTH_PROVIDER && process.env.OR3_AUTH_PROVIDER && process.env.AUTH_PROVIDER !== process.env.OR3_AUTH_PROVIDER) throw new Error('Unsupported auth profile');
|
|
3061
|
+
const paths = auth === 'basic-auth' ? [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH] : [process.env.OR3_SQLITE_DB_PATH];
|
|
3062
|
+
if (paths.some(path => !path)) throw new Error('Missing managed database path');
|
|
3063
|
+
for (const path of paths) {
|
|
2229
3064
|
const info = fs.statSync(path);
|
|
2230
3065
|
if (info.uid !== 65532 || info.gid !== 65532) throw new Error(path + ' must be owned by 65532:65532');
|
|
2231
3066
|
const db = new Database(path, { readonly: true, fileMustExist: true });
|
|
@@ -2298,15 +3133,112 @@ async function sourceServiceRunning(directory, composeFiles) {
|
|
|
2298
3133
|
throw new Error(`Could not determine whether the V1 OR3 service is running. ${result.stderr.trim()}`);
|
|
2299
3134
|
return Boolean(result.stdout.trim());
|
|
2300
3135
|
}
|
|
3136
|
+
async function assertSourceContainer(directory, composeFiles, expectedImage, configText, sourceVolume, expectedPort, sourceEnv) {
|
|
3137
|
+
const listed = await run("docker", [...sourceComposeArgs(directory, composeFiles), "ps", "-aq", "or3"], directory);
|
|
3138
|
+
const ids = listed.stdout.trim().split(/\s+/).filter(Boolean);
|
|
3139
|
+
if (!listed.ok || ids.length !== 1)
|
|
3140
|
+
throw new Error("Could not identify exactly one existing source container.");
|
|
3141
|
+
const inspect = await run("docker", ["inspect", "--format", `{{json .Image}}
|
|
3142
|
+
{{json .Config.Env}}
|
|
3143
|
+
{{json .Mounts}}
|
|
3144
|
+
{{json .Config.Labels}}
|
|
3145
|
+
{{json .Config.Entrypoint}}
|
|
3146
|
+
{{json .Config.Cmd}}
|
|
3147
|
+
{{json .Config.WorkingDir}}
|
|
3148
|
+
{{json .HostConfig.ReadonlyRootfs}}
|
|
3149
|
+
{{json .HostConfig.Tmpfs}}
|
|
3150
|
+
{{json .HostConfig.Mounts}}`, ids[0]], directory);
|
|
3151
|
+
const expected = await run("docker", ["image", "inspect", "--format", `{{.Id}}
|
|
3152
|
+
{{json .Config.Entrypoint}}
|
|
3153
|
+
{{json .Config.Cmd}}
|
|
3154
|
+
{{json .Config.WorkingDir}}
|
|
3155
|
+
{{json .Config.Env}}`, expectedImage], directory);
|
|
3156
|
+
if (!inspect.ok || !expected.ok || !expected.stdout.trim())
|
|
3157
|
+
throw new Error("Could not inspect the existing source container or its authenticated image.");
|
|
3158
|
+
let image, env, mounts, labels, entrypoint, command, cwd, readonly, tmpfs, hostMounts, imageId, defaultEntrypoint, defaultCommand, defaultCwd, defaultEnv;
|
|
3159
|
+
try {
|
|
3160
|
+
const lines = inspect.stdout.trimEnd().split(`
|
|
3161
|
+
`);
|
|
3162
|
+
const expectedLines = expected.stdout.trimEnd().split(`
|
|
3163
|
+
`);
|
|
3164
|
+
if (lines.length !== 10 || expectedLines.length !== 5)
|
|
3165
|
+
throw new Error("invalid inspection");
|
|
3166
|
+
[image, env, mounts, labels, entrypoint, command, cwd, readonly, tmpfs, hostMounts] = lines.map((line) => JSON.parse(line));
|
|
3167
|
+
imageId = expectedLines[0];
|
|
3168
|
+
defaultEntrypoint = JSON.parse(expectedLines[1]);
|
|
3169
|
+
defaultCommand = JSON.parse(expectedLines[2]);
|
|
3170
|
+
defaultCwd = JSON.parse(expectedLines[3]);
|
|
3171
|
+
defaultEnv = JSON.parse(expectedLines[4]);
|
|
3172
|
+
} catch {
|
|
3173
|
+
throw new Error("The source container inspection is incomplete or malformed.");
|
|
3174
|
+
}
|
|
3175
|
+
if (image !== imageId)
|
|
3176
|
+
throw new Error("The source container image does not match the authenticated release image.");
|
|
3177
|
+
if (readonly !== true)
|
|
3178
|
+
throw new Error("The source container root filesystem is not read-only; its code cannot be qualified for adoption.");
|
|
3179
|
+
assertCapturedHostMounts(tmpfs, hostMounts);
|
|
3180
|
+
const diff = await run("docker", ["diff", ids[0]], directory);
|
|
3181
|
+
if (!diff.ok || diff.stdout.trim())
|
|
3182
|
+
throw new Error("The source container writable layer is changed or cannot be inspected; adoption is refused.");
|
|
3183
|
+
const validCommand = (value) => value === null || Array.isArray(value) && value.every((part) => typeof part === "string");
|
|
3184
|
+
if (![entrypoint, command, defaultEntrypoint, defaultCommand].every(validCommand) || JSON.stringify(entrypoint) !== JSON.stringify(defaultEntrypoint) || JSON.stringify(command) !== JSON.stringify(defaultCommand)) {
|
|
3185
|
+
throw new Error("The source container changes the authenticated image entrypoint or command.");
|
|
3186
|
+
}
|
|
3187
|
+
if (typeof cwd !== "string" || cwd !== defaultCwd)
|
|
3188
|
+
throw new Error("The source container changes the authenticated image working directory.");
|
|
3189
|
+
const config = parseComposeConfig(configText);
|
|
3190
|
+
if (!config.name || !labels || typeof labels !== "object" || Array.isArray(labels) || labels["com.docker.compose.project"] !== config.name || labels["com.docker.compose.service"] !== "or3") {
|
|
3191
|
+
throw new Error("The source container Compose labels do not match the selected project and service.");
|
|
3192
|
+
}
|
|
3193
|
+
const parseInspectionEnv = (value) => {
|
|
3194
|
+
if (value === null)
|
|
3195
|
+
return {};
|
|
3196
|
+
if (!Array.isArray(value) || !value.every((line) => typeof line === "string" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(line)))
|
|
3197
|
+
throw new Error("The inspected environment is incomplete or malformed.");
|
|
3198
|
+
const result = {};
|
|
3199
|
+
for (const line of value) {
|
|
3200
|
+
const split = line.indexOf("=");
|
|
3201
|
+
const key = line.slice(0, split);
|
|
3202
|
+
if (Object.hasOwn(result, key))
|
|
3203
|
+
throw new Error(`The inspected environment contains a duplicate ${key} entry.`);
|
|
3204
|
+
result[key] = line.slice(split + 1);
|
|
3205
|
+
}
|
|
3206
|
+
return result;
|
|
3207
|
+
};
|
|
3208
|
+
const actualEnv = parseInspectionEnv(env), imageEnv = parseInspectionEnv(defaultEnv);
|
|
3209
|
+
const executionKey = /^(?:PATH|NODE_.+|BUN_.+|LD_.+|DYLD_.+)$/;
|
|
3210
|
+
for (const key of new Set([...Object.keys(actualEnv), ...Object.keys(imageEnv)])) {
|
|
3211
|
+
if (executionKey.test(key) && actualEnv[key] !== imageEnv[key])
|
|
3212
|
+
throw new Error(`The source container changes execution environment ${key}.`);
|
|
3213
|
+
}
|
|
3214
|
+
if (!Array.isArray(mounts) || !mounts.every((mount) => mount && typeof mount === "object" && typeof mount.Type === "string" && typeof mount.Destination === "string")) {
|
|
3215
|
+
throw new Error("The source container mounts are incomplete or malformed.");
|
|
3216
|
+
}
|
|
3217
|
+
const actualMounts = mounts;
|
|
3218
|
+
if (new Set(actualMounts.map((mount) => mount.Destination)).size !== actualMounts.length)
|
|
3219
|
+
throw new Error("The source container has ambiguous duplicate mount destinations.");
|
|
3220
|
+
if (actualMounts.length !== 1)
|
|
3221
|
+
throw new Error("The source container has additional mounts that adoption cannot safely migrate.");
|
|
3222
|
+
const actualDataMount = actualMounts.find((mount) => mount.Destination === "/data");
|
|
3223
|
+
if (!actualDataMount || actualDataMount.Type !== "volume" || actualDataMount.Name !== sourceVolume) {
|
|
3224
|
+
throw new Error("The source container data mount does not match the selected Docker volume.");
|
|
3225
|
+
}
|
|
3226
|
+
const actualService = config.services?.or3;
|
|
3227
|
+
if (!actualService)
|
|
3228
|
+
throw new Error("V1 Compose configuration does not define an or3 service.");
|
|
3229
|
+
actualService.environment = actualEnv;
|
|
3230
|
+
actualService.volumes = actualMounts.map((mount) => ({ type: mount.Type, source: mount.Name, target: mount.Destination }));
|
|
3231
|
+
assertSupportedSourceCompose(JSON.stringify(config), sourceVolume, expectedPort, sourceEnv, [actualService.image ?? ""]);
|
|
3232
|
+
}
|
|
2301
3233
|
async function readSourceVolume(sourceDirectory) {
|
|
2302
3234
|
const composeResult = await run("docker", [
|
|
2303
3235
|
"compose",
|
|
2304
3236
|
"--project-directory",
|
|
2305
3237
|
sourceDirectory,
|
|
2306
3238
|
"--env-file",
|
|
2307
|
-
|
|
3239
|
+
join13(sourceDirectory, ".env"),
|
|
2308
3240
|
"-f",
|
|
2309
|
-
|
|
3241
|
+
join13(sourceDirectory, "compose.yaml"),
|
|
2310
3242
|
"ps",
|
|
2311
3243
|
"-aq",
|
|
2312
3244
|
"or3"
|
|
@@ -2327,7 +3259,7 @@ async function readSourceVolume(sourceDirectory) {
|
|
|
2327
3259
|
}
|
|
2328
3260
|
function sourceMode(sourceDirectory, sourceEnv) {
|
|
2329
3261
|
const publicDeployment = sourceEnv.OR3_PUBLIC_DOMAIN && sourceEnv.OR3_PUBLIC_DOMAIN !== "localhost" && sourceEnv.OR3_FORCE_HTTPS !== "false";
|
|
2330
|
-
return publicDeployment && existsSync2(
|
|
3262
|
+
return publicDeployment && existsSync2(join13(sourceDirectory, "compose.public.yaml")) ? "public" : "local";
|
|
2331
3263
|
}
|
|
2332
3264
|
function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
|
|
2333
3265
|
return [
|
|
@@ -2335,7 +3267,7 @@ function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
|
|
|
2335
3267
|
"--project-directory",
|
|
2336
3268
|
sourceDirectory,
|
|
2337
3269
|
"--env-file",
|
|
2338
|
-
|
|
3270
|
+
join13(sourceDirectory, ".env"),
|
|
2339
3271
|
...sourceComposeFiles
|
|
2340
3272
|
];
|
|
2341
3273
|
}
|
|
@@ -2348,8 +3280,8 @@ async function restartSource(sourceDirectory, sourceComposeFiles, secrets) {
|
|
|
2348
3280
|
}
|
|
2349
3281
|
function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
|
|
2350
3282
|
const values = [sourceEnv.AUTH_PROVIDER, sourceEnv.OR3_AUTH_PROVIDER].filter(Boolean);
|
|
2351
|
-
if (
|
|
2352
|
-
throw new Error(`V1 project uses ${values.join(", ") || "an unknown auth provider"}, not Basic Auth.`);
|
|
3283
|
+
if (values.length === 0 || values.some((value) => value !== "basic-auth"))
|
|
3284
|
+
throw new Error(`V1 project uses ${values.join(", ") || "an unknown auth provider"}, not the supported Basic Auth profile.`);
|
|
2353
3285
|
if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
|
|
2354
3286
|
throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
|
|
2355
3287
|
}
|
|
@@ -2374,21 +3306,94 @@ function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
|
|
|
2374
3306
|
throw new Error("V1 project is missing OR3 version metadata.");
|
|
2375
3307
|
}
|
|
2376
3308
|
}
|
|
2377
|
-
function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
|
|
3309
|
+
function assertSupportedSourceCompose(configText, sourceVolume, expectedPort, sourceEnv, allowedImages) {
|
|
2378
3310
|
const config = parseComposeConfig(configText);
|
|
2379
3311
|
const service = config.services?.or3;
|
|
2380
3312
|
if (!service)
|
|
2381
3313
|
throw new Error("V1 Compose configuration does not define an or3 service.");
|
|
3314
|
+
assertCapturedComposeMounts(service);
|
|
3315
|
+
if (!service.image || !allowedImages.includes(service.image))
|
|
3316
|
+
throw new Error("V1 Compose image does not match the authenticated release image.");
|
|
3317
|
+
const vault = composeEnvironmentValue(service, "NUXT_SECRETS_PROVIDER");
|
|
3318
|
+
const vaultMount = (service.volumes ?? []).some((mount) => typeof mount.target === "string" && /^\/(?:run\/or3-secrets(?:-client)?|vault-data|vault-key)(?:\/|$)/.test(mount.target));
|
|
3319
|
+
if (sourceEnv.NUXT_SECRETS_PROVIDER || vault !== "" || vaultMount || Object.keys(config.services ?? {}).some((name) => /vault/i.test(name))) {
|
|
3320
|
+
throw new Error("V1 source may use a secret vault; adoption cannot migrate vault custody. Preserve the source and use a separately reviewed migration.");
|
|
3321
|
+
}
|
|
3322
|
+
if (Object.keys(config.services ?? {}).some((name) => name !== "or3")) {
|
|
3323
|
+
throw new Error("V1 Compose has additional services that adoption cannot safely migrate.");
|
|
3324
|
+
}
|
|
3325
|
+
const selected = (key) => composeEnvironmentValue(service, key);
|
|
3326
|
+
const regenerated = new Set(["OR3_BASIC_AUTH_DB_PATH", "OR3_SQLITE_DB_PATH", "OR3_STORAGE_FS_ROOT", "OR3_PUBLIC_DOMAIN", "OR3_ALLOWED_ORIGINS", "OR3_FORCE_HTTPS", "OR3_TRUST_PROXY"]);
|
|
3327
|
+
for (const key of ALLOWED_ENV_KEYS) {
|
|
3328
|
+
if (regenerated.has(key))
|
|
3329
|
+
continue;
|
|
3330
|
+
if (selected(key) !== sourceEnv[key])
|
|
3331
|
+
throw new Error(`V1 Compose ${key} differs from its source environment; adoption refuses configuration drift.`);
|
|
3332
|
+
}
|
|
3333
|
+
if (!sourceEnv.OR3_INSTANCE_ID?.trim())
|
|
3334
|
+
throw new Error("V1 source needs an explicitly configured stable acquisition identity (OR3_INSTANCE_ID) before adoption.");
|
|
3335
|
+
for (const key of ["NUXT_ADMIN_AUTH_USERNAME", "NUXT_ADMIN_AUTH_PASSWORD"]) {
|
|
3336
|
+
if (selected(key) !== undefined || sourceEnv[key] !== undefined)
|
|
3337
|
+
throw new Error(`V1 Compose ${key} cannot be safely provisioned during adoption.`);
|
|
3338
|
+
}
|
|
3339
|
+
const effectiveKeys = Array.isArray(service.environment) ? service.environment.map((entry) => entry.split("=", 1)[0]) : Object.keys(service.environment ?? {});
|
|
3340
|
+
const ambiguous = /^(?:RUNTIME_CONFIG$|NUXT_(?:ADMIN|ADMIN_AUTH|AUTH|AUTH_INVITE|SYNC|STORAGE|SECRETS|PUBLIC|BACKGROUND_JOBS|CONNECT|CONNECT_CLOUDFLARE|WEBHOOKS)$|NITRO_ENV_(?:PREFIX|EXPANSION)$|NITRO_(?:ADMIN|AUTH|SYNC|STORAGE|SECRETS|PUBLIC|BACKGROUND_JOBS|CONNECT|WEBHOOKS)(?:_|$))/;
|
|
3341
|
+
if (effectiveKeys.some((key) => ambiguous.test(key)))
|
|
3342
|
+
throw new Error("V1 Compose contains an unsupported aggregate or Nitro runtime profile override.");
|
|
3343
|
+
const allowedOverrides = {
|
|
3344
|
+
OR3_STORAGE_PROVIDER: "fs",
|
|
3345
|
+
NUXT_AUTH_ENABLED: "true",
|
|
3346
|
+
NUXT_AUTH_PROVIDER: "basic-auth",
|
|
3347
|
+
NUXT_PUBLIC_SSR_AUTH_ENABLED: "true",
|
|
3348
|
+
NUXT_PUBLIC_AUTH_PROVIDER: "basic-auth",
|
|
3349
|
+
NUXT_PUBLIC_GUEST_ACCESS_ENABLED: "false",
|
|
3350
|
+
NUXT_AUTH_REGISTRATION_MODE: "invite_only",
|
|
3351
|
+
NUXT_AUTH_AUTO_PROVISION: "false",
|
|
3352
|
+
NUXT_SYNC_ENABLED: "true",
|
|
3353
|
+
NUXT_PUBLIC_SYNC_ENABLED: "true",
|
|
3354
|
+
NUXT_SYNC_PROVIDER: "sqlite",
|
|
3355
|
+
NUXT_PUBLIC_SYNC_PROVIDER: "sqlite",
|
|
3356
|
+
NUXT_STORAGE_ENABLED: "true",
|
|
3357
|
+
NUXT_PUBLIC_STORAGE_ENABLED: "true",
|
|
3358
|
+
NUXT_STORAGE_PROVIDER: "fs"
|
|
3359
|
+
};
|
|
3360
|
+
for (const [key, expected] of Object.entries(allowedOverrides)) {
|
|
3361
|
+
const value = selected(key);
|
|
3362
|
+
if (value !== undefined && value !== expected)
|
|
3363
|
+
throw new Error(`V1 Compose ${key} selects an unsupported runtime profile.`);
|
|
3364
|
+
}
|
|
3365
|
+
if (selected("OR3_SQLITE_DRIVER") && selected("OR3_SQLITE_DRIVER") !== "better-sqlite3") {
|
|
3366
|
+
throw new Error("V1 Compose selects an unsupported SQLite driver. Adoption requires better-sqlite3.");
|
|
3367
|
+
}
|
|
3368
|
+
const managedOnly = new Set(["OR3_VERSION", "OR3_IMAGE", "OR3_PORT", "OR3_COMPOSE_PROJECT", "OR3_VOLUME_NAME", "OR3_CADDY_DATA_VOLUME", "OR3_CADDY_CONFIG_VOLUME", "OR3_DEPLOYMENT_ID", "OR3_ADMIN_DATA_DIR", "OR3_EXTENSIONS_ROOT"]);
|
|
3369
|
+
for (const key of effectiveKeys) {
|
|
3370
|
+
if (/^(?:OR3|NUXT|NITRO)_/.test(key) && !ALLOWED_ENV_KEYS.has(key) && !managedOnly.has(key) && !(key in allowedOverrides)) {
|
|
3371
|
+
throw new Error(`V1 Compose has unsupported application setting ${key}; adoption refuses to drop it.`);
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
const auth = [selected("AUTH_PROVIDER"), selected("OR3_AUTH_PROVIDER")].filter((value) => Boolean(value));
|
|
3375
|
+
const guest = selected("OR3_GUEST_ACCESS_ENABLED");
|
|
3376
|
+
if (!auth.length || auth.some((value) => value !== "basic-auth") || selected("OR3_SYNC_PROVIDER") !== "sqlite" || selected("NUXT_PUBLIC_STORAGE_PROVIDER") !== "fs" || selected("OR3_AUTH_REGISTRATION_MODE") !== "invite_only" || selected("OR3_AUTH_AUTO_PROVISION") !== "false" || selected("SSR_AUTH_ENABLED") !== "true" || guest && guest !== "false" || (selected("OR3_CLOUD_SYNC_ENABLED") || selected("OR3_SYNC_ENABLED")) !== "true" || (selected("OR3_CLOUD_STORAGE_ENABLED") || selected("OR3_STORAGE_ENABLED")) !== "true") {
|
|
3377
|
+
throw new Error("V1 Compose resolves to an unsupported authentication, sync, storage or registration profile.");
|
|
3378
|
+
}
|
|
3379
|
+
for (const key of ["AUTH_PROVIDER", "OR3_AUTH_PROVIDER", "OR3_SYNC_PROVIDER", "NUXT_PUBLIC_STORAGE_PROVIDER", "OR3_AUTH_REGISTRATION_MODE", "OR3_AUTH_AUTO_PROVISION", "SSR_AUTH_ENABLED", "OR3_GUEST_ACCESS_ENABLED", "OR3_SYNC_ENABLED", "OR3_CLOUD_SYNC_ENABLED", "OR3_STORAGE_ENABLED", "OR3_CLOUD_STORAGE_ENABLED"]) {
|
|
3380
|
+
if (sourceEnv[key] !== undefined && selected(key) !== sourceEnv[key])
|
|
3381
|
+
throw new Error(`V1 Compose resolves ${key} differently from its source environment; adoption refuses profile drift.`);
|
|
3382
|
+
}
|
|
2382
3383
|
for (const [key, expected] of [
|
|
2383
3384
|
["OR3_BASIC_AUTH_DB_PATH", "/data/auth.sqlite"],
|
|
2384
3385
|
["OR3_SQLITE_DB_PATH", "/data/sync.sqlite"],
|
|
2385
|
-
["OR3_STORAGE_FS_ROOT", "/data/storage"]
|
|
3386
|
+
["OR3_STORAGE_FS_ROOT", "/data/storage"],
|
|
3387
|
+
["OR3_ADMIN_DATA_DIR", "/data/admin"],
|
|
3388
|
+
["OR3_EXTENSIONS_ROOT", "/data/extensions"]
|
|
2386
3389
|
]) {
|
|
2387
3390
|
if (composeEnvironmentValue(service, key) !== expected) {
|
|
2388
3391
|
throw new Error(`V1 Compose configuration does not resolve ${key} to ${expected}. Adoption refuses custom data layouts.`);
|
|
2389
3392
|
}
|
|
2390
3393
|
}
|
|
2391
3394
|
const mounts = service.volumes ?? [];
|
|
3395
|
+
if (mounts.length !== 1)
|
|
3396
|
+
throw new Error("V1 Compose has additional mounts that adoption cannot safely migrate.");
|
|
2392
3397
|
const dataMount = mounts.find((mount) => mount.target === "/data");
|
|
2393
3398
|
if (!dataMount || dataMount.type !== "volume") {
|
|
2394
3399
|
throw new Error("V1 Compose configuration must mount one named volume at /data.");
|
|
@@ -2408,8 +3413,8 @@ function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
|
|
|
2408
3413
|
|
|
2409
3414
|
// src/runtime/dashboard-operator.ts
|
|
2410
3415
|
import { randomBytes as randomBytes6 } from "node:crypto";
|
|
2411
|
-
import { chmod as chmod4, mkdir as mkdir4, rm as
|
|
2412
|
-
import { isAbsolute as
|
|
3416
|
+
import { chmod as chmod4, mkdir as mkdir4, rm as rm5, stat as stat3 } from "node:fs/promises";
|
|
3417
|
+
import { isAbsolute as isAbsolute4, join as join14, resolve as resolve6 } from "node:path";
|
|
2413
3418
|
|
|
2414
3419
|
// src/runtime/images.ts
|
|
2415
3420
|
async function pullImage(image, expectedDigest2) {
|
|
@@ -2551,13 +3556,13 @@ async function dashboardOperatorEnv(directory, version) {
|
|
|
2551
3556
|
if (configured && !configured.startsWith("unix://"))
|
|
2552
3557
|
return;
|
|
2553
3558
|
const socket = configured ? configured.slice("unix://".length) : "/var/run/docker.sock";
|
|
2554
|
-
if (!
|
|
3559
|
+
if (!isAbsolute4(socket))
|
|
2555
3560
|
return;
|
|
2556
3561
|
let socketStat;
|
|
2557
3562
|
let uid;
|
|
2558
3563
|
let gid;
|
|
2559
3564
|
try {
|
|
2560
|
-
socketStat = await
|
|
3565
|
+
socketStat = await stat3(socket);
|
|
2561
3566
|
if (!socketStat.isSocket())
|
|
2562
3567
|
return;
|
|
2563
3568
|
uid = process.getuid();
|
|
@@ -2635,7 +3640,7 @@ async function prepareVerifiedDashboardOperator(directory, version) {
|
|
|
2635
3640
|
if (await verifyDashboardOperatorBridge(directory, candidate))
|
|
2636
3641
|
return candidate;
|
|
2637
3642
|
} catch {}
|
|
2638
|
-
await
|
|
3643
|
+
await rm5(deploymentPaths(directory).operatorIpc, { recursive: true, force: true }).catch(() => {
|
|
2639
3644
|
return;
|
|
2640
3645
|
});
|
|
2641
3646
|
console.warn("Dashboard updates are unavailable on this Docker setup; the deployment will remain host-CLI managed.");
|
|
@@ -2643,7 +3648,7 @@ async function prepareVerifiedDashboardOperator(directory, version) {
|
|
|
2643
3648
|
}
|
|
2644
3649
|
function dashboardOperatorHandoffArgs(directory, env, jobId) {
|
|
2645
3650
|
const project = env.OR3_COMPOSE_PROJECT;
|
|
2646
|
-
if (!
|
|
3651
|
+
if (!isAbsolute4(directory) || !DASHBOARD_JOB_ID_PATTERN.test(jobId) || !COMPOSE_PROJECT_PATTERN.test(project ?? "") || !/^\d+$/.test(env.OR3_OPERATOR_UID ?? "") || !/^\d+$/.test(env.OR3_OPERATOR_GID ?? "") || !/^\d+$/.test(env.OR3_DOCKER_GID ?? "") || !isAbsolute4(env.OR3_DOCKER_SOCKET ?? "") || !/@sha256:[0-9a-f]{64}$/i.test(env.OR3_OPERATOR_IMAGE ?? "")) {
|
|
2647
3652
|
throw new Error("Refusing to schedule an invalid dashboard operator handoff.");
|
|
2648
3653
|
}
|
|
2649
3654
|
return [
|
|
@@ -2674,7 +3679,7 @@ function dashboardOperatorHandoffArgs(directory, env, jobId) {
|
|
|
2674
3679
|
"--entrypoint",
|
|
2675
3680
|
"/usr/local/bin/node",
|
|
2676
3681
|
env.OR3_OPERATOR_IMAGE,
|
|
2677
|
-
|
|
3682
|
+
join14(directory, "dashboard-operator.mjs"),
|
|
2678
3683
|
"--complete-handoff",
|
|
2679
3684
|
jobId,
|
|
2680
3685
|
project
|
|
@@ -2760,10 +3765,10 @@ async function checkPublicPrerequisites(domain) {
|
|
|
2760
3765
|
|
|
2761
3766
|
// src/runtime/project.ts
|
|
2762
3767
|
import { readFileSync as readFileSync5 } from "node:fs";
|
|
2763
|
-
import { join as
|
|
3768
|
+
import { join as join15 } from "node:path";
|
|
2764
3769
|
function managedVaultEnabled(directory, env) {
|
|
2765
|
-
const selected = env ?? parseEnv(readFileSync5(
|
|
2766
|
-
return selected.SSR_AUTH_ENABLED === "true" && (selected.NUXT_SECRETS_PROVIDER ?? "or3-secrets") === "or3-secrets" && readFileSync5(
|
|
3770
|
+
const selected = env ?? parseEnv(readFileSync5(join15(directory, ".env"), "utf8"));
|
|
3771
|
+
return selected.SSR_AUTH_ENABLED === "true" && (selected.NUXT_SECRETS_PROVIDER ?? "or3-secrets") === "or3-secrets" && readFileSync5(join15(directory, "compose.yaml"), "utf8").includes(`
|
|
2767
3772
|
or3-vault:
|
|
2768
3773
|
`);
|
|
2769
3774
|
}
|
|
@@ -2785,11 +3790,11 @@ async function startProject(directory, mode, env) {
|
|
|
2785
3790
|
}
|
|
2786
3791
|
|
|
2787
3792
|
// src/runtime/verification.ts
|
|
2788
|
-
import { createHash as
|
|
2789
|
-
function validateVerificationHealth(value) {
|
|
3793
|
+
import { createHash as createHash4, randomBytes as randomBytes7, randomUUID as randomUUID4 } from "node:crypto";
|
|
3794
|
+
function validateVerificationHealth(value, profile = "basic-sqlite-fs") {
|
|
2790
3795
|
const health = value;
|
|
2791
|
-
if (!health || health.status !== "ok" || health.providers?.auth?.provider !== "basic-auth" || health.providers.sync?.provider !== "sqlite" || health.providers.storage?.provider !== "fs") {
|
|
2792
|
-
throw new Error(
|
|
3796
|
+
if (!health || health.status !== "ok" || health.providers?.auth?.provider !== (profile === "basic-sqlite-fs" ? "basic-auth" : "clerk") || health.providers.sync?.provider !== "sqlite" || health.providers.storage?.provider !== (profile === "basic-sqlite-fs" ? "fs" : "s3")) {
|
|
3797
|
+
throw new Error(`Public deep health does not report the managed ${profile === "basic-sqlite-fs" ? "Basic Auth + SQLite + filesystem" : "Clerk + SQLite + S3"} profile.`);
|
|
2793
3798
|
}
|
|
2794
3799
|
return health;
|
|
2795
3800
|
}
|
|
@@ -2884,7 +3889,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
|
|
|
2884
3889
|
Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489", "hex"),
|
|
2885
3890
|
randomBytes7(8)
|
|
2886
3891
|
]);
|
|
2887
|
-
const hash = `sha256:${
|
|
3892
|
+
const hash = `sha256:${createHash4("sha256").update(probeBytes).digest("hex")}`;
|
|
2888
3893
|
let storageId;
|
|
2889
3894
|
let metadataAttempted = false;
|
|
2890
3895
|
let physicalCleanupDeferred = false;
|
|
@@ -2926,7 +3931,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
|
|
|
2926
3931
|
}
|
|
2927
3932
|
});
|
|
2928
3933
|
const metadataCreatedAt = Date.now();
|
|
2929
|
-
const metadataOpId =
|
|
3934
|
+
const metadataOpId = randomUUID4();
|
|
2930
3935
|
const pushed = await verificationJson(baseUrl, "/api/sync/push", {
|
|
2931
3936
|
cookie,
|
|
2932
3937
|
body: {
|
|
@@ -2976,7 +3981,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
|
|
|
2976
3981
|
if (storageId) {
|
|
2977
3982
|
if (metadataAttempted) {
|
|
2978
3983
|
const metadataDeleteAt = Date.now();
|
|
2979
|
-
const metadataDeleteOpId =
|
|
3984
|
+
const metadataDeleteOpId = randomUUID4();
|
|
2980
3985
|
const deleted = await verificationJson(baseUrl, "/api/sync/push", {
|
|
2981
3986
|
cookie,
|
|
2982
3987
|
body: {
|
|
@@ -3076,31 +4081,31 @@ async function adoptCommand(positionals, flags) {
|
|
|
3076
4081
|
if (targetDirectory === sourceDirectory)
|
|
3077
4082
|
throw new Error("Choose a separate target directory for adoption.");
|
|
3078
4083
|
await readDirectoryEmpty(targetDirectory);
|
|
3079
|
-
if (!await fileExists(
|
|
4084
|
+
if (!await fileExists(join16(sourceDirectory, "or3-release.json")))
|
|
3080
4085
|
throw new Error(`V1 release metadata is missing at ${sourceDirectory}/or3-release.json.`);
|
|
3081
|
-
const release = JSON.parse(await readText(
|
|
4086
|
+
const release = JSON.parse(await readText(join16(sourceDirectory, "or3-release.json")));
|
|
3082
4087
|
if (!release.or3Version || !isVersion(release.or3Version))
|
|
3083
4088
|
throw new Error("V1 release metadata does not contain a usable OR3 version.");
|
|
3084
4089
|
if (release.or3Version !== PACKAGE_VERSION) {
|
|
3085
4090
|
throw new Error(`V1 is on OR3 ${release.or3Version}, but this CLI ships managed assets for ${PACKAGE_VERSION}. Run the matching @or3/cloud version or perform a qualified host CLI update before adoption.`);
|
|
3086
4091
|
}
|
|
3087
|
-
const sourceEnv = parseEnv(await readText(
|
|
3088
|
-
const providers = await readText(
|
|
4092
|
+
const sourceEnv = parseEnv(await readText(join16(sourceDirectory, ".env")));
|
|
4093
|
+
const providers = await readText(join16(sourceDirectory, "or3.providers.generated.ts"));
|
|
3089
4094
|
assertSupportedSource(sourceDirectory, sourceEnv, providers);
|
|
3090
4095
|
const mode = sourceMode(sourceDirectory, sourceEnv);
|
|
3091
4096
|
const domain = mode === "public" ? sourceEnv.OR3_PUBLIC_DOMAIN : undefined;
|
|
3092
4097
|
if (domain)
|
|
3093
4098
|
await checkPublicPrerequisites(domain);
|
|
3094
|
-
const sourceComposeFiles = ["-f",
|
|
4099
|
+
const sourceComposeFiles = ["-f", join16(sourceDirectory, "compose.yaml")];
|
|
3095
4100
|
if (mode === "public")
|
|
3096
|
-
sourceComposeFiles.push("-f",
|
|
4101
|
+
sourceComposeFiles.push("-f", join16(sourceDirectory, "compose.public.yaml"));
|
|
3097
4102
|
await ensureDocker();
|
|
3098
4103
|
const sourceConfig = await run("docker", [
|
|
3099
4104
|
"compose",
|
|
3100
4105
|
"--project-directory",
|
|
3101
4106
|
sourceDirectory,
|
|
3102
4107
|
"--env-file",
|
|
3103
|
-
|
|
4108
|
+
join16(sourceDirectory, ".env"),
|
|
3104
4109
|
...sourceComposeFiles,
|
|
3105
4110
|
"config",
|
|
3106
4111
|
"--format",
|
|
@@ -3129,11 +4134,12 @@ async function adoptCommand(positionals, flags) {
|
|
|
3129
4134
|
throw new Error("V1 project has an invalid OR3 port.");
|
|
3130
4135
|
if (mode === "public" && [80, 443].includes(sourcePort))
|
|
3131
4136
|
throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
|
|
3132
|
-
assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort);
|
|
4137
|
+
assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort, sourceEnv, [sourceImage, image]);
|
|
3133
4138
|
const sourceInitiallyRunning = await sourceServiceRunning(sourceDirectory, sourceComposeFiles);
|
|
4139
|
+
await assertSourceContainer(sourceDirectory, sourceComposeFiles, image, sourceConfig.stdout, sourceVolume, sourcePort, sourceEnv);
|
|
3134
4140
|
const targetNames = composeProjectNames(targetDirectory);
|
|
3135
4141
|
await assertDockerProjectAbsent(targetNames.project);
|
|
3136
|
-
for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
|
|
4142
|
+
for (const volume of [targetNames.volume, ...managedVaultVolumeNames(targetNames.volume), ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
|
|
3137
4143
|
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
3138
4144
|
if (existing.ok)
|
|
3139
4145
|
throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
|
|
@@ -3187,7 +4193,7 @@ ${existing.stderr}`)) {
|
|
|
3187
4193
|
OR3_GUEST_ACCESS_ENABLED: "false"
|
|
3188
4194
|
});
|
|
3189
4195
|
await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
|
|
3190
|
-
await writeSecure(
|
|
4196
|
+
await writeSecure(join16(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
3191
4197
|
bootstrapEmail: email,
|
|
3192
4198
|
bootstrapPassword: password,
|
|
3193
4199
|
adminUsername: targetEnv.OR3_ADMIN_USERNAME,
|
|
@@ -3208,6 +4214,7 @@ ${existing.stderr}`)) {
|
|
|
3208
4214
|
phase: "prepared"
|
|
3209
4215
|
});
|
|
3210
4216
|
let sourceStopAttempted = false;
|
|
4217
|
+
let targetMayHaveStarted = false;
|
|
3211
4218
|
try {
|
|
3212
4219
|
sourceStopAttempted = true;
|
|
3213
4220
|
const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
|
|
@@ -3223,7 +4230,7 @@ ${existing.stderr}`)) {
|
|
|
3223
4230
|
}
|
|
3224
4231
|
}
|
|
3225
4232
|
await mkdir5(sourceBackupDir, { recursive: true, mode: 448 });
|
|
3226
|
-
await copySecure(
|
|
4233
|
+
await copySecure(join16(sourceDirectory, ".env"), join16(sourceBackupDir, "config.env"));
|
|
3227
4234
|
await archiveExternalVolume(sourceImage, sourceVolume, sourceBackupDir);
|
|
3228
4235
|
const sourceManifest = {
|
|
3229
4236
|
schemaVersion: 1,
|
|
@@ -3232,8 +4239,8 @@ ${existing.stderr}`)) {
|
|
|
3232
4239
|
appVersion: release.or3Version,
|
|
3233
4240
|
image,
|
|
3234
4241
|
imageDigest: digest,
|
|
3235
|
-
dataSha256: await sha256File(
|
|
3236
|
-
configSha256: await sha256File(
|
|
4242
|
+
dataSha256: await sha256File(join16(sourceBackupDir, "data.tgz")),
|
|
4243
|
+
configSha256: await sha256File(join16(sourceBackupDir, "config.env")),
|
|
3237
4244
|
mode,
|
|
3238
4245
|
domain,
|
|
3239
4246
|
composeProject: sourceEnv.OR3_COMPOSE_PROJECT ?? sanitizeName(basename3(sourceDirectory)),
|
|
@@ -3244,11 +4251,13 @@ ${existing.stderr}`)) {
|
|
|
3244
4251
|
};
|
|
3245
4252
|
const sourceManifestContents = `${JSON.stringify(sourceManifest, null, 2)}
|
|
3246
4253
|
`;
|
|
3247
|
-
await writeSecure(
|
|
4254
|
+
await writeSecure(join16(sourceBackupDir, "manifest.json"), sourceManifestContents);
|
|
3248
4255
|
await writeBackupAuthentication(targetDirectory, sourceBackupDir, sourceManifestContents);
|
|
3249
4256
|
await readManifest(sourceBackupDir, targetDirectory);
|
|
3250
4257
|
await updatePending(targetDirectory, state, { phase: "snapshot-created" });
|
|
4258
|
+
await updatePending(targetDirectory, state, { phase: "target-mutating" });
|
|
3251
4259
|
await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
|
|
4260
|
+
targetMayHaveStarted = true;
|
|
3252
4261
|
await startProject(targetDirectory, mode, targetEnv);
|
|
3253
4262
|
await provisionManagedCredentials(mode === "public" ? new URL(`https://${domain}`) : new URL(`http://127.0.0.1:${sourcePort}`), email, password, targetEnv.OR3_ADMIN_USERNAME, targetEnv.OR3_ADMIN_PASSWORD);
|
|
3254
4263
|
const runtimeEnv = withoutProvisioningCredentials(targetEnv);
|
|
@@ -3260,11 +4269,12 @@ ${existing.stderr}`)) {
|
|
|
3260
4269
|
console.log(`Source backup: ${sourceBackupDir}`);
|
|
3261
4270
|
console.log(`The original deployment is preserved and stopped. Verify sign-in, chat, and file access before removing anything.`);
|
|
3262
4271
|
} catch (error) {
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
4272
|
+
if (!targetMayHaveStarted)
|
|
4273
|
+
await compose(targetDirectory, mode, ["down"]).catch(() => {
|
|
4274
|
+
return;
|
|
4275
|
+
});
|
|
3266
4276
|
let sourceRecoveryError = "";
|
|
3267
|
-
if (sourceStopAttempted && sourceInitiallyRunning) {
|
|
4277
|
+
if (!targetMayHaveStarted && sourceStopAttempted && sourceInitiallyRunning) {
|
|
3268
4278
|
try {
|
|
3269
4279
|
await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
|
|
3270
4280
|
} catch (recovery) {
|
|
@@ -3273,88 +4283,297 @@ ${existing.stderr}`)) {
|
|
|
3273
4283
|
}
|
|
3274
4284
|
state.lastError = redact(`${error instanceof Error ? error.message : String(error)}${sourceRecoveryError}`, secretValues(targetEnv));
|
|
3275
4285
|
await writeState(targetDirectory, state);
|
|
4286
|
+
const recoveryDetail = targetMayHaveStarted ? "The target may contain new writes. Both deployments were left as found; inspect the operation before deciding which data to retain. " : sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : sourceInitiallyRunning ? "The original V1 deployment was restarted and deeply healthy. " : "The original V1 deployment remains stopped. ";
|
|
3276
4287
|
throw new Error(`${state.lastError}
|
|
3277
|
-
${
|
|
4288
|
+
${recoveryDetail}Managed files remain at ${targetDirectory}.${sourceBackupDir ? ` Source backup: ${sourceBackupDir}.` : ""}`);
|
|
3278
4289
|
}
|
|
3279
4290
|
}
|
|
3280
4291
|
|
|
3281
4292
|
// src/backup/create.ts
|
|
3282
|
-
import { chmod as chmod6, lstat as
|
|
3283
|
-
import { join as
|
|
4293
|
+
import { chmod as chmod6, lstat as lstat4, mkdir as mkdir6 } from "node:fs/promises";
|
|
4294
|
+
import { join as join19, resolve as resolve10 } from "node:path";
|
|
3284
4295
|
|
|
3285
4296
|
// src/backup/references.ts
|
|
3286
|
-
import {
|
|
3287
|
-
|
|
3288
|
-
var
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
4297
|
+
import { join as join17, resolve as resolve8 } from "node:path";
|
|
4298
|
+
var INSPECTOR3 = "/app/scripts/docker/backup-inspect.mjs";
|
|
4299
|
+
var NODE3 = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
|
|
4300
|
+
function assertBackupOperatorMount(directory, enabled, composeMounts, actualMounts) {
|
|
4301
|
+
const expected = resolve8(directory, ".or3-cloud/operator-ipc");
|
|
4302
|
+
const configured = composeMounts.filter((mount) => mount.target === "/run/or3-operator");
|
|
4303
|
+
const actual = actualMounts.filter((mount) => mount.Destination === "/run/or3-operator");
|
|
4304
|
+
if (!enabled && configured.length === 0 && actual.length === 0)
|
|
4305
|
+
return;
|
|
4306
|
+
if (!enabled || configured.length !== 1 || actual.length !== 1 || configured.at(0)?.type !== "bind" || configured.at(0)?.source !== expected || configured.at(0)?.read_only !== true || actual.at(0)?.Type !== "bind" || actual.at(0)?.Source !== expected || actual.at(0)?.RW !== false) {
|
|
4307
|
+
throw new Error("Backup source operator mount does not match the managed read-only socket.");
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
function assertBackupContainerDiff(diff, mounts) {
|
|
4311
|
+
const allowed = new Set(["/data", "/run/or3-operator", "/run/or3-secrets", "/run/or3-secrets-client", "/vault-data", "/vault-key"]);
|
|
4312
|
+
const targets = new Set(mounts.map((mount) => mount.Destination).filter((value) => typeof value === "string" && allowed.has(value)));
|
|
4313
|
+
const permitted = new Set([...targets].map((target) => `A ${target}`));
|
|
4314
|
+
if ([...targets].some((target) => target.startsWith("/run/")))
|
|
4315
|
+
permitted.add("C /run");
|
|
4316
|
+
if (diff.trim() && diff.trim().split(`
|
|
4317
|
+
`).some((line) => !permitted.has(line)))
|
|
4318
|
+
throw new Error("Backup source container writable layer is changed or cannot be inspected.");
|
|
4319
|
+
}
|
|
4320
|
+
function assertSupportedBackupProfile(env) {
|
|
4321
|
+
let profile;
|
|
4322
|
+
try {
|
|
4323
|
+
profile = managedProviderProfile(env);
|
|
4324
|
+
} catch {}
|
|
4325
|
+
const expectedOverrides = {
|
|
4326
|
+
OR3_STORAGE_PROVIDER: "fs",
|
|
4327
|
+
NUXT_AUTH_ENABLED: "true",
|
|
4328
|
+
NUXT_AUTH_PROVIDER: "basic-auth",
|
|
4329
|
+
NUXT_PUBLIC_AUTH_PROVIDER: "basic-auth",
|
|
4330
|
+
NUXT_PUBLIC_SSR_AUTH_ENABLED: "true",
|
|
4331
|
+
NUXT_SYNC_ENABLED: "true",
|
|
4332
|
+
NUXT_PUBLIC_SYNC_ENABLED: "true",
|
|
4333
|
+
NUXT_SYNC_PROVIDER: "sqlite",
|
|
4334
|
+
NUXT_PUBLIC_SYNC_PROVIDER: "sqlite",
|
|
4335
|
+
NUXT_STORAGE_ENABLED: "true",
|
|
4336
|
+
NUXT_PUBLIC_STORAGE_ENABLED: "true",
|
|
4337
|
+
NUXT_STORAGE_PROVIDER: "fs"
|
|
4338
|
+
};
|
|
4339
|
+
const ambiguous = /^(?:RUNTIME_CONFIG$|NUXT_(?:AUTH|SYNC|STORAGE|SECRETS|PUBLIC)$|NITRO_ENV_(?:PREFIX|EXPANSION)$|NITRO_(?:AUTH|SYNC|STORAGE|SECRETS|PUBLIC)(?:_|$))/;
|
|
4340
|
+
if (profile !== "basic-sqlite-fs" || !(env.AUTH_PROVIDER || env.OR3_AUTH_PROVIDER) || env.OR3_SYNC_PROVIDER !== "sqlite" || env.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs" || env.OR3_SQLITE_DRIVER && env.OR3_SQLITE_DRIVER !== "better-sqlite3" || env.SSR_AUTH_ENABLED !== "true" || (env.OR3_CLOUD_SYNC_ENABLED ?? env.OR3_SYNC_ENABLED) !== "true" || (env.OR3_CLOUD_STORAGE_ENABLED ?? env.OR3_STORAGE_ENABLED) !== "true" || Object.entries(expectedOverrides).some(([key, expected]) => env[key] !== undefined && env[key] !== expected) || Object.keys(env).some((key) => ambiguous.test(key))) {
|
|
3292
4341
|
throw new Error("Unsupported offline backup inspection provider profile; SQLite/FS is required.");
|
|
3293
4342
|
}
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
4343
|
+
}
|
|
4344
|
+
async function assertBackupSourceProfile(directory, state, env) {
|
|
4345
|
+
assertSupportedBackupProfile(env);
|
|
4346
|
+
if (state.image !== env.OR3_IMAGE || state.volumeName !== env.OR3_VOLUME_NAME)
|
|
4347
|
+
throw new Error("Backup source identity differs from managed state.");
|
|
4348
|
+
await assertSafeComposeBinding(directory, state.mode, env);
|
|
4349
|
+
const config = parseComposeConfig(await resolvedComposeConfig(directory, state.mode));
|
|
4350
|
+
const service = config.services?.or3;
|
|
4351
|
+
if (!service)
|
|
4352
|
+
throw new Error("Backup source Compose service is missing.");
|
|
4353
|
+
const readEnv = (input2) => {
|
|
4354
|
+
const values = {};
|
|
4355
|
+
const entries = Array.isArray(input2) ? input2.map((line) => {
|
|
4356
|
+
if (typeof line !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*=/.test(line))
|
|
4357
|
+
throw new Error("Backup source environment is malformed.");
|
|
4358
|
+
const index = line.indexOf("=");
|
|
4359
|
+
return [line.slice(0, index), line.slice(index + 1)];
|
|
4360
|
+
}) : input2 && typeof input2 === "object" ? Object.entries(input2) : [];
|
|
4361
|
+
for (const [key, value] of entries) {
|
|
4362
|
+
if (typeof key !== "string" || typeof value !== "string" || Object.hasOwn(values, key))
|
|
4363
|
+
throw new Error("Backup source environment is incomplete or ambiguous.");
|
|
4364
|
+
values[key] = value;
|
|
4365
|
+
}
|
|
4366
|
+
return values;
|
|
4367
|
+
};
|
|
4368
|
+
const resolved = readEnv(service.environment);
|
|
4369
|
+
assertSupportedBackupProfile(resolved);
|
|
4370
|
+
if (service.entrypoint != null || service.command != null || service.working_dir != null || service.read_only !== true)
|
|
4371
|
+
throw new Error("Backup source Compose changes the managed application startup.");
|
|
4372
|
+
const matches = (candidate) => {
|
|
4373
|
+
assertSupportedBackupProfile(candidate);
|
|
4374
|
+
for (const [key, value] of [["OR3_BASIC_AUTH_DB_PATH", "/data/auth.sqlite"], ["OR3_SQLITE_DB_PATH", "/data/sync.sqlite"], ["OR3_STORAGE_FS_ROOT", "/data/storage"]]) {
|
|
4375
|
+
if (env[key] !== value || resolved[key] !== value || candidate[key] !== value)
|
|
4376
|
+
throw new Error(`Backup source ${key} does not use the captured managed path.`);
|
|
4377
|
+
}
|
|
4378
|
+
for (const [key, value] of [["OR3_ADMIN_DATA_DIR", "/data/admin"], ["OR3_EXTENSIONS_ROOT", "/data/extensions"]]) {
|
|
4379
|
+
if (env[key] !== undefined && env[key] !== value || resolved[key] !== value || candidate[key] !== value)
|
|
4380
|
+
throw new Error(`Backup source ${key} does not use the captured managed path.`);
|
|
4381
|
+
}
|
|
4382
|
+
const profileKey = /^(?:AUTH_PROVIDER|SSR_AUTH_ENABLED|OR3_(?:AUTH_PROVIDER|SYNC_(?:ENABLED|PROVIDER)|CLOUD_SYNC_ENABLED|STORAGE_(?:ENABLED|PROVIDER|FS_ROOT)|CLOUD_STORAGE_ENABLED|SQLITE_(?:DB_PATH|DRIVER)|BASIC_AUTH_DB_PATH)|NUXT_(?:AUTH|SYNC|STORAGE|PUBLIC)(?:_.*)?|NITRO_(?:AUTH|SYNC|STORAGE|PUBLIC|ENV)(?:_.*)?|RUNTIME_CONFIG)$/;
|
|
4383
|
+
for (const key of new Set([...Object.keys(env), ...Object.keys(resolved), ...Object.keys(candidate)])) {
|
|
4384
|
+
if (profileKey.test(key) && (resolved[key] !== env[key] || candidate[key] !== resolved[key]))
|
|
4385
|
+
throw new Error(`Backup source profile configuration differs for ${key}.`);
|
|
4386
|
+
}
|
|
4387
|
+
};
|
|
4388
|
+
matches(resolved);
|
|
4389
|
+
if ((service.volumes ?? []).some((mount) => typeof mount.target === "string" && mount.target.startsWith("/data/")))
|
|
4390
|
+
throw new Error("Backup source Compose has an uncaptured mount inside /data.");
|
|
4391
|
+
const listed = await run("docker", composeArgs(directory, state.mode, ["ps", "-aq", "or3"]), directory);
|
|
4392
|
+
const ids = listed.stdout.trim().split(/\s+/).filter(Boolean);
|
|
4393
|
+
if (!listed.ok || ids.length !== 1)
|
|
4394
|
+
throw new Error("Backup requires exactly one existing source container, running or stopped.");
|
|
4395
|
+
const inspect = await run("docker", ["inspect", "--format", `{{json .Config.Env}}
|
|
4396
|
+
{{json .Image}}
|
|
4397
|
+
{{json .Mounts}}
|
|
4398
|
+
{{json .Config.Labels}}`, ids[0]], directory);
|
|
4399
|
+
const image = await run("docker", ["image", "inspect", "--format", "{{.Id}}", env.OR3_IMAGE], directory);
|
|
4400
|
+
if (!inspect.ok || !image.ok || !image.stdout.trim())
|
|
4401
|
+
throw new Error("Backup source container or image cannot be inspected.");
|
|
4402
|
+
let actual, imageId, mounts, labels;
|
|
3302
4403
|
try {
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
"
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
"
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
const
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
4404
|
+
const lines = inspect.stdout.trimEnd().split(`
|
|
4405
|
+
`);
|
|
4406
|
+
if (lines.length !== 4)
|
|
4407
|
+
throw new Error("incomplete");
|
|
4408
|
+
[actual, imageId, mounts, labels] = lines.map((line) => JSON.parse(line));
|
|
4409
|
+
} catch {
|
|
4410
|
+
throw new Error("Backup source container inspection is incomplete or malformed.");
|
|
4411
|
+
}
|
|
4412
|
+
if (imageId !== image.stdout.trim() || !labels || typeof labels !== "object" || Array.isArray(labels) || labels["com.docker.compose.project"] !== state.composeProject || labels["com.docker.compose.service"] !== "or3")
|
|
4413
|
+
throw new Error("Backup source container identity does not match the managed deployment.");
|
|
4414
|
+
if (!Array.isArray(mounts))
|
|
4415
|
+
throw new Error("Backup source container mounts are unavailable.");
|
|
4416
|
+
const onlyMounts = (items, property, allowed) => {
|
|
4417
|
+
if (items.some((item) => !item || typeof item !== "object" || Array.isArray(item) || !allowed.includes(item[property])))
|
|
4418
|
+
throw new Error("Backup source has an unexpected mount.");
|
|
4419
|
+
};
|
|
4420
|
+
onlyMounts(service.volumes ?? [], "target", ["/data", "/run/or3-secrets", "/run/or3-secrets-client", "/run/or3-operator"]);
|
|
4421
|
+
assertCapturedComposeMounts(service);
|
|
4422
|
+
onlyMounts(mounts, "Destination", ["/data", "/run/or3-secrets", "/run/or3-secrets-client", "/run/or3-operator"]);
|
|
4423
|
+
assertBackupOperatorMount(directory, dashboardUpdatesEnabled(directory), service.volumes ?? [], mounts);
|
|
4424
|
+
const data = mounts.filter((mount) => mount && typeof mount === "object" && mount.Destination === "/data");
|
|
4425
|
+
if (data.length !== 1 || data[0].Type !== "volume" || data[0].Name !== state.volumeName || mounts.some((mount) => typeof mount?.Destination === "string" && mount.Destination.startsWith("/data/")))
|
|
4426
|
+
throw new Error("Backup source container does not use the captured data volume exclusively.");
|
|
4427
|
+
matches(readEnv(actual));
|
|
4428
|
+
const actualEnv = readEnv(actual);
|
|
4429
|
+
const imageExecution = await run("docker", ["image", "inspect", "--format", `{{json .Config.Entrypoint}}
|
|
4430
|
+
{{json .Config.Cmd}}
|
|
4431
|
+
{{json .Config.WorkingDir}}
|
|
4432
|
+
{{json .Config.Env}}`, env.OR3_IMAGE], directory);
|
|
4433
|
+
if (!imageExecution.ok)
|
|
4434
|
+
throw new Error("Backup source image startup cannot be inspected.");
|
|
4435
|
+
let imageEntry, imageCommand, imageWorkingDir, imageEnvironment;
|
|
4436
|
+
try {
|
|
4437
|
+
const lines = imageExecution.stdout.trimEnd().split(`
|
|
4438
|
+
`);
|
|
4439
|
+
if (lines.length !== 4)
|
|
4440
|
+
throw new Error("incomplete");
|
|
4441
|
+
[imageEntry, imageCommand, imageWorkingDir, imageEnvironment] = lines.map((line) => JSON.parse(line));
|
|
4442
|
+
} catch {
|
|
4443
|
+
throw new Error("Backup source image startup inspection is malformed.");
|
|
4444
|
+
}
|
|
4445
|
+
const defaultEnv = readEnv(imageEnvironment);
|
|
4446
|
+
const executionKey = /^(?:PATH|NODE_.+|BUN_.+|LD_.+|DYLD_.+)$/;
|
|
4447
|
+
const checkExecutionEnv = (source, isResolved) => {
|
|
4448
|
+
for (const key of new Set([...Object.keys(source), ...Object.keys(defaultEnv)])) {
|
|
4449
|
+
if (executionKey.test(key) && (isResolved ? source[key] ?? defaultEnv[key] : source[key]) !== defaultEnv[key])
|
|
4450
|
+
throw new Error(`Backup source changes execution environment ${key}.`);
|
|
4451
|
+
}
|
|
4452
|
+
};
|
|
4453
|
+
checkExecutionEnv(actualEnv, false);
|
|
4454
|
+
checkExecutionEnv(resolved, true);
|
|
4455
|
+
const checkExecution = async (container, expectedEntry, expectedCommand, containerMounts) => {
|
|
4456
|
+
const result = await run("docker", ["inspect", "--format", `{{json .Config.Entrypoint}}
|
|
4457
|
+
{{json .Config.Cmd}}
|
|
4458
|
+
{{json .Config.WorkingDir}}
|
|
4459
|
+
{{json .HostConfig.ReadonlyRootfs}}
|
|
4460
|
+
{{json .HostConfig.Tmpfs}}
|
|
4461
|
+
{{json .HostConfig.Mounts}}`, container], directory);
|
|
4462
|
+
let entry, command, cwd, readonly, tmpfs, hostMounts;
|
|
4463
|
+
try {
|
|
4464
|
+
const lines = result.stdout.trimEnd().split(`
|
|
4465
|
+
`);
|
|
4466
|
+
if (!result.ok || lines.length !== 6)
|
|
4467
|
+
throw new Error("incomplete");
|
|
4468
|
+
[entry, command, cwd, readonly, tmpfs, hostMounts] = lines.map((line) => JSON.parse(line));
|
|
4469
|
+
} catch {
|
|
4470
|
+
throw new Error("Backup source startup inspection is incomplete.");
|
|
4471
|
+
}
|
|
4472
|
+
if (JSON.stringify(entry) !== JSON.stringify(expectedEntry) || JSON.stringify(command) !== JSON.stringify(expectedCommand) || cwd !== imageWorkingDir || readonly !== true)
|
|
4473
|
+
throw new Error("Backup source container changes the managed startup or writable root.");
|
|
4474
|
+
assertCapturedHostMounts(tmpfs, hostMounts);
|
|
4475
|
+
const diff = await run("docker", ["diff", container], directory);
|
|
4476
|
+
if (!diff.ok)
|
|
4477
|
+
throw new Error("Backup source container writable layer is changed or cannot be inspected.");
|
|
4478
|
+
assertBackupContainerDiff(diff.stdout, containerMounts);
|
|
4479
|
+
};
|
|
4480
|
+
await checkExecution(ids[0], imageEntry, imageCommand, mounts);
|
|
4481
|
+
const vaultEnabled = managedVaultEnabled(directory, env);
|
|
4482
|
+
const legacyWithoutVault = !vaultEnabled && !config.services?.["or3-vault"] && [env, resolved, actualEnv].every((values) => !Object.keys(values).some((key) => key.startsWith("NUXT_SECRETS_"))) && !(service.volumes ?? []).some((mount) => typeof mount.target === "string" && mount.target.startsWith("/run/or3-secrets")) && !mounts.some((mount) => typeof mount?.Destination === "string" && mount.Destination.startsWith("/run/or3-secrets"));
|
|
4483
|
+
const provider = env.NUXT_SECRETS_PROVIDER ?? "or3-secrets";
|
|
4484
|
+
if (!legacyWithoutVault && (provider !== "" && provider !== "or3-secrets" || resolved.NUXT_SECRETS_PROVIDER !== provider || actualEnv.NUXT_SECRETS_PROVIDER !== provider || provider === "or3-secrets" !== vaultEnabled)) {
|
|
4485
|
+
throw new Error("Backup source secret provider differs from managed vault custody.");
|
|
4486
|
+
}
|
|
4487
|
+
if (vaultEnabled) {
|
|
4488
|
+
for (const [key, value] of [["NUXT_SECRETS_SOCKET_PATH", "/run/or3-secrets/api.sock"], ["NUXT_SECRETS_TOKEN_FILE", "/run/or3-secrets-client/service.token"], ["OR3_MANAGED_VAULT_CONFIG", "/run/or3-secrets-client/config.json"]]) {
|
|
4489
|
+
if (resolved[key] !== value || actualEnv[key] !== value)
|
|
4490
|
+
throw new Error("Backup source vault connection is not the managed connection.");
|
|
4491
|
+
}
|
|
4492
|
+
for (const key of new Set([...Object.keys(resolved), ...Object.keys(actualEnv)])) {
|
|
4493
|
+
if (key.startsWith("NUXT_SECRETS_") && resolved[key] !== actualEnv[key])
|
|
4494
|
+
throw new Error(`Backup source vault configuration differs for ${key}.`);
|
|
4495
|
+
}
|
|
4496
|
+
const expected = [
|
|
4497
|
+
["/vault-data", `${state.volumeName}-vault-data`],
|
|
4498
|
+
["/vault-key", `${state.volumeName}-vault-key`],
|
|
4499
|
+
["/run/or3-secrets", `${state.volumeName}-vault-run`],
|
|
4500
|
+
["/run/or3-secrets-client", `${state.volumeName}-vault-client`]
|
|
4501
|
+
];
|
|
4502
|
+
const checkComposeMounts = (mountsToCheck, required) => {
|
|
4503
|
+
for (const [target, volume] of required) {
|
|
4504
|
+
const matches2 = mountsToCheck.filter((mount) => mount.target === target);
|
|
4505
|
+
const source = matches2[0]?.source;
|
|
4506
|
+
if (matches2.length !== 1 || matches2[0]?.type !== "volume" || typeof source !== "string" || (config.volumes?.[source]?.name ?? source) !== volume || mountsToCheck.some((mount) => typeof mount.target === "string" && mount.target.startsWith(`${target}/`)))
|
|
4507
|
+
throw new Error("Backup source Compose vault mounts do not match managed custody.");
|
|
4508
|
+
}
|
|
4509
|
+
};
|
|
4510
|
+
const checkActualMounts = (mountsToCheck, required) => {
|
|
4511
|
+
if (!mountsToCheck.every((mount) => mount && typeof mount === "object" && !Array.isArray(mount)))
|
|
4512
|
+
throw new Error("Backup source container vault mounts are malformed.");
|
|
4513
|
+
const typed = mountsToCheck;
|
|
4514
|
+
for (const [target, volume] of required) {
|
|
4515
|
+
const matches2 = typed.filter((mount) => mount.Destination === target);
|
|
4516
|
+
if (matches2.length !== 1 || matches2.at(0)?.Type !== "volume" || matches2.at(0)?.Name !== volume || typed.some((mount) => typeof mount.Destination === "string" && mount.Destination.startsWith(`${target}/`)))
|
|
4517
|
+
throw new Error("Backup source container vault mounts do not match managed custody.");
|
|
4518
|
+
}
|
|
4519
|
+
};
|
|
4520
|
+
checkComposeMounts(service.volumes ?? [], expected.slice(2));
|
|
4521
|
+
checkActualMounts(mounts, expected.slice(2));
|
|
4522
|
+
const vaultService = config.services?.["or3-vault"];
|
|
4523
|
+
if (!vaultService || vaultService.image !== env.OR3_IMAGE)
|
|
4524
|
+
throw new Error("Backup source vault service does not use the managed image.");
|
|
4525
|
+
const vaultResolved = readEnv(vaultService.environment);
|
|
4526
|
+
checkExecutionEnv(vaultResolved, true);
|
|
4527
|
+
if (JSON.stringify(vaultService.entrypoint) !== JSON.stringify(["/nodejs/bin/node", "/app/vault/entrypoint.mjs"]) || vaultService.command != null || vaultService.working_dir != null || vaultService.read_only !== true)
|
|
4528
|
+
throw new Error("Backup source Compose changes the managed vault startup.");
|
|
4529
|
+
onlyMounts(vaultService.volumes ?? [], "target", expected.map(([target]) => target));
|
|
4530
|
+
assertCapturedComposeMounts(vaultService);
|
|
4531
|
+
checkComposeMounts(vaultService.volumes ?? [], expected);
|
|
4532
|
+
const vaultListed = await run("docker", composeArgs(directory, state.mode, ["ps", "-aq", "or3-vault"]), directory);
|
|
4533
|
+
const vaultIds = vaultListed.stdout.trim().split(/\s+/).filter(Boolean);
|
|
4534
|
+
if (!vaultListed.ok || vaultIds.length !== 1)
|
|
4535
|
+
throw new Error("Backup requires exactly one existing source vault container.");
|
|
4536
|
+
const vaultInspect = await run("docker", ["inspect", "--format", `{{json .Config.Env}}
|
|
4537
|
+
{{json .Image}}
|
|
4538
|
+
{{json .Mounts}}
|
|
4539
|
+
{{json .Config.Labels}}`, vaultIds[0]], directory);
|
|
4540
|
+
if (!vaultInspect.ok)
|
|
4541
|
+
throw new Error("Backup source vault container cannot be inspected.");
|
|
4542
|
+
let vaultEnv, vaultImage, vaultMounts, vaultLabels;
|
|
4543
|
+
try {
|
|
4544
|
+
const lines = vaultInspect.stdout.trimEnd().split(`
|
|
4545
|
+
`);
|
|
4546
|
+
if (lines.length !== 4)
|
|
4547
|
+
throw new Error("incomplete");
|
|
4548
|
+
[vaultEnv, vaultImage, vaultMounts, vaultLabels] = lines.map((line) => JSON.parse(line));
|
|
4549
|
+
} catch {
|
|
4550
|
+
throw new Error("Backup source vault inspection is incomplete or malformed.");
|
|
4551
|
+
}
|
|
4552
|
+
if (vaultImage !== image.stdout.trim() || !vaultLabels || typeof vaultLabels !== "object" || Array.isArray(vaultLabels) || vaultLabels["com.docker.compose.project"] !== state.composeProject || vaultLabels["com.docker.compose.service"] !== "or3-vault" || !Array.isArray(vaultMounts)) {
|
|
4553
|
+
throw new Error("Backup source vault container identity does not match the managed deployment.");
|
|
4554
|
+
}
|
|
4555
|
+
checkActualMounts(vaultMounts, expected);
|
|
4556
|
+
const actualVaultEnv = readEnv(vaultEnv);
|
|
4557
|
+
checkExecutionEnv(actualVaultEnv, false);
|
|
4558
|
+
if (vaultResolved.OR3_VAULT_NAMESPACE !== (resolved.NUXT_SECRETS_NAMESPACE || "or3") || actualVaultEnv.OR3_VAULT_NAMESPACE !== vaultResolved.OR3_VAULT_NAMESPACE)
|
|
4559
|
+
throw new Error("Backup source vault namespace differs from the managed connection.");
|
|
4560
|
+
onlyMounts(vaultMounts, "Destination", [...expected.map(([target]) => target), "/data"]);
|
|
4561
|
+
await checkExecution(vaultIds[0], ["/nodejs/bin/node", "/app/vault/entrypoint.mjs"], null, vaultMounts);
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
async function validateCanonicalBackupReferences(directory, env, backupPath) {
|
|
4565
|
+
assertSupportedBackupProfile(env);
|
|
4566
|
+
await withBackupHelper(directory, env.OR3_IMAGE, env, { network: "none" }, async (helper) => {
|
|
4567
|
+
await helper.loadSqlite(join17(backupPath, "data.tgz"));
|
|
4568
|
+
const result = JSON.parse(await helper.exec(["sh", "-c", NODE3, "or3-backup-inspection", INSPECTOR3, "references", env.OR3_STORAGE_FS_ROOT ?? "/data/storage"]));
|
|
3342
4569
|
if (result.contract !== 1 || result.complete !== true || !Number.isSafeInteger(result.liveAttachments) || result.liveAttachments < 0)
|
|
3343
4570
|
throw new Error("Offline canonical backup reference inspection did not complete.");
|
|
3344
|
-
}
|
|
3345
|
-
primary = error;
|
|
3346
|
-
}
|
|
3347
|
-
const cleanup = await run("docker", ["rm", "--force", helper], directory);
|
|
3348
|
-
if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
|
|
3349
|
-
throw new AggregateError(primary ? [primary] : [], `Offline backup inspection helper cleanup failed. ${redact(cleanup.stderr, secretValues(env))}`);
|
|
3350
|
-
}
|
|
3351
|
-
if (primary)
|
|
3352
|
-
throw primary;
|
|
4571
|
+
});
|
|
3353
4572
|
}
|
|
3354
4573
|
|
|
3355
4574
|
// src/backup/retention.ts
|
|
3356
|
-
import { rm as
|
|
3357
|
-
import { basename as basename4, dirname as dirname4, join as
|
|
4575
|
+
import { rm as rm6 } from "node:fs/promises";
|
|
4576
|
+
import { basename as basename4, dirname as dirname4, join as join18, resolve as resolve9 } from "node:path";
|
|
3358
4577
|
var BACKUP_RETENTION_KEEP = 5;
|
|
3359
4578
|
function assertRemovableArtifactName(name) {
|
|
3360
4579
|
if (!BACKUP_ID_PATTERN.test(name)) {
|
|
@@ -3369,18 +4588,18 @@ async function removeNamedBackupArtifact(directory, backupId) {
|
|
|
3369
4588
|
if (dirname4(target) !== deploymentPaths(directory).backups) {
|
|
3370
4589
|
throw new Error(`Refusing to remove a path outside the backups directory: ${target}.`);
|
|
3371
4590
|
}
|
|
3372
|
-
await
|
|
3373
|
-
await
|
|
4591
|
+
await rm6(target, { recursive: true, force: true });
|
|
4592
|
+
await rm6(join18(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
|
|
3374
4593
|
}
|
|
3375
4594
|
async function removeEnumeratedBackupArtifact(directory, backup) {
|
|
3376
4595
|
await lifecycleFaults.beforeArtifactDelete?.();
|
|
3377
|
-
const backupsRoot =
|
|
3378
|
-
const target =
|
|
4596
|
+
const backupsRoot = resolve9(deploymentPaths(directory).backups);
|
|
4597
|
+
const target = resolve9(backup.path);
|
|
3379
4598
|
if (dirname4(target) !== backupsRoot || basename4(target) !== backup.backupId || !BACKUP_ID_PATTERN.test(backup.backupId)) {
|
|
3380
4599
|
throw new Error(`Refusing to prune an untrusted backup path ${backup.path}.`);
|
|
3381
4600
|
}
|
|
3382
|
-
await
|
|
3383
|
-
await
|
|
4601
|
+
await rm6(target, { recursive: true, force: true });
|
|
4602
|
+
await rm6(join18(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
|
|
3384
4603
|
}
|
|
3385
4604
|
function planRetention(entries, keep, protectedIds, options = {}) {
|
|
3386
4605
|
if (!Number.isInteger(keep) || keep < 1)
|
|
@@ -3404,9 +4623,13 @@ function planRetention(entries, keep, protectedIds, options = {}) {
|
|
|
3404
4623
|
if (automatic)
|
|
3405
4624
|
canPrune = false;
|
|
3406
4625
|
}
|
|
3407
|
-
const
|
|
3408
|
-
const
|
|
3409
|
-
const
|
|
4626
|
+
const observedAt = Date.now();
|
|
4627
|
+
const verified = entries.filter((entry) => entry.kind === "verified").map((entry) => entry.backup).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
4628
|
+
const removable = verified.filter((backup) => !protectedIds.has(backup.backupId) && Date.parse(backup.createdAt) <= observedAt);
|
|
4629
|
+
const full = removable.filter((backup) => backup.recoveryArtifactsComplete === true && backup.snapshotKind === "full" && backup.remoteKind !== "s3-reference-v1");
|
|
4630
|
+
const safety = removable.filter((backup) => backup.recoveryArtifactsComplete === true && backup.snapshotKind === "safety" && backup.remoteKind !== "s3-reference-v1");
|
|
4631
|
+
const references = removable.filter((backup) => backup.recoveryArtifactsComplete === true && backup.snapshotKind === "safety" && backup.remoteKind === "s3-reference-v1");
|
|
4632
|
+
const remove = [...full.slice(keep), ...safety.slice(keep), ...references.slice(keep)].map((backup) => backup.backupId);
|
|
3410
4633
|
const removeSet = new Set(remove);
|
|
3411
4634
|
return {
|
|
3412
4635
|
keep: verified.filter((backup) => !removeSet.has(backup.backupId)).map((backup) => backup.backupId),
|
|
@@ -3455,7 +4678,7 @@ async function pruneBackups(directory, state, keep, force, options = {}) {
|
|
|
3455
4678
|
throw new Error(`Retention selected backup ${backupId}, but its verified path disappeared before deletion.`);
|
|
3456
4679
|
const revalidated = await inspectBackupEntry(deploymentPaths(directory).backups, backupId, directory);
|
|
3457
4680
|
const current = revalidated.kind === "verified" ? revalidated.backup : undefined;
|
|
3458
|
-
if (!current || current.path !== backup.path || current.dataSha256 !== backup.dataSha256) {
|
|
4681
|
+
if (!current || current.path !== backup.path || current.dataSha256 !== backup.dataSha256 || current.createdAt !== backup.createdAt || current.schemaVersion !== backup.schemaVersion || current.remoteKind !== backup.remoteKind || current.snapshotKind !== backup.snapshotKind || current.recoveryArtifactsComplete !== backup.recoveryArtifactsComplete) {
|
|
3459
4682
|
throw new Error(`Backup ${backupId} changed while pruning was in progress. Aborting retention before deletion.`);
|
|
3460
4683
|
}
|
|
3461
4684
|
await removeEnumeratedBackupArtifact(directory, current);
|
|
@@ -3470,7 +4693,7 @@ var asError = (error) => error instanceof Error ? error : new Error(String(error
|
|
|
3470
4693
|
var RECOVERY_STEPS = 'Keep the operation record. Inspect the named paths and their permissions, then run "npx @or3/cloud recover --dry-run" and "npx @or3/cloud recover" from the deployment directory. Do not restore an unverified artifact or remove recovery locks manually.';
|
|
3471
4694
|
async function artifactStatus(path) {
|
|
3472
4695
|
try {
|
|
3473
|
-
await
|
|
4696
|
+
await lstat4(path);
|
|
3474
4697
|
return { absent: false, detail: `Still present: ${path}.` };
|
|
3475
4698
|
} catch (error) {
|
|
3476
4699
|
if (error.code === "ENOENT")
|
|
@@ -3487,7 +4710,7 @@ async function cleanupPartialBackup(directory, backupId) {
|
|
|
3487
4710
|
}
|
|
3488
4711
|
const statuses = await Promise.all([
|
|
3489
4712
|
artifactStatus(backupDirectory(directory, backupId)),
|
|
3490
|
-
artifactStatus(
|
|
4713
|
+
artifactStatus(join19(deploymentPaths(directory).exports, `${backupId}.json`))
|
|
3491
4714
|
]);
|
|
3492
4715
|
const removed = statuses.every((status) => status.absent);
|
|
3493
4716
|
const detail = statuses.map((status) => status.detail).join(" ");
|
|
@@ -3496,7 +4719,17 @@ async function cleanupPartialBackup(directory, backupId) {
|
|
|
3496
4719
|
return { error, removed, detail };
|
|
3497
4720
|
}
|
|
3498
4721
|
async function createBackup(directory, state, env, options = {}) {
|
|
4722
|
+
const remoteStorage = env.NUXT_PUBLIC_STORAGE_PROVIDER === "s3";
|
|
4723
|
+
if (!remoteStorage) {
|
|
4724
|
+
assertSupportedBackupProfile(env);
|
|
4725
|
+
await assertBackupSourceProfile(directory, state, env);
|
|
4726
|
+
} else if (options.safetySnapshot)
|
|
4727
|
+
remoteSource(env);
|
|
4728
|
+
else
|
|
4729
|
+
assertRemoteTransferConfiguration(env);
|
|
3499
4730
|
const restartAfter = options.restartAfter ?? true;
|
|
4731
|
+
if (managedVaultEnabled(directory, env))
|
|
4732
|
+
await assertVaultVolumesExist(directory, state);
|
|
3500
4733
|
await requireImageDigest(state.image, state.imageDigest, "Current deployment");
|
|
3501
4734
|
const backupId = options.backupId ?? id("backup");
|
|
3502
4735
|
if (!BACKUP_ID_PATTERN.test(backupId))
|
|
@@ -3536,6 +4769,7 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3536
4769
|
await report("capturing", `Backup capture: OR3 is stopped; writing ${backupDir}.`);
|
|
3537
4770
|
await volumeArchive(directory, state.mode, env, backupDir);
|
|
3538
4771
|
if (managedVaultEnabled(directory, env)) {
|
|
4772
|
+
await assertVaultVolumesExist(directory, state);
|
|
3539
4773
|
await streamCommandToFile("docker", composeArgs(directory, state.mode, [
|
|
3540
4774
|
"run",
|
|
3541
4775
|
"--rm",
|
|
@@ -3546,9 +4780,9 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3546
4780
|
"or3-vault",
|
|
3547
4781
|
"/app/vault/entrypoint.mjs",
|
|
3548
4782
|
"backup"
|
|
3549
|
-
]),
|
|
4783
|
+
]), join19(backupDir, "vault.backup"), directory, secretValues(env));
|
|
3550
4784
|
}
|
|
3551
|
-
await copySecure(deploymentPaths(directory).env,
|
|
4785
|
+
await copySecure(deploymentPaths(directory).env, join19(backupDir, "config.env"));
|
|
3552
4786
|
managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
|
|
3553
4787
|
artifact = "captured";
|
|
3554
4788
|
} catch (error) {
|
|
@@ -3562,6 +4796,8 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3562
4796
|
progressFailures.push(asError(error));
|
|
3563
4797
|
}
|
|
3564
4798
|
try {
|
|
4799
|
+
if (managedVaultEnabled(directory, env))
|
|
4800
|
+
await assertVaultVolumesExist(directory, state);
|
|
3565
4801
|
await startProject(directory, state.mode, env);
|
|
3566
4802
|
service = "healthy";
|
|
3567
4803
|
downtimeMs = Date.now() - stopStartedAt;
|
|
@@ -3571,20 +4807,30 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3571
4807
|
}
|
|
3572
4808
|
}
|
|
3573
4809
|
}
|
|
4810
|
+
if (!primaryFailure && restartFailure && remoteStorage)
|
|
4811
|
+
primaryFailure = new Error("S3 capture requires successful service recovery before remote transfer.");
|
|
3574
4812
|
if (!primaryFailure) {
|
|
3575
4813
|
try {
|
|
4814
|
+
let remote;
|
|
4815
|
+
if (remoteStorage) {
|
|
4816
|
+
await report("remote-capture", options.safetySnapshot ? "Remote inventory: recording and verifying S3 references for rollback." : "Remote capture: copying and verifying the referenced S3 objects.");
|
|
4817
|
+
remote = options.safetySnapshot ? await captureRemoteInventory(directory, state, env, backupDir) : await captureRemoteSnapshot(directory, state, env, backupDir);
|
|
4818
|
+
}
|
|
3576
4819
|
await report("verifying", `Verification: checking the captured backup; ${service === "healthy" ? "OR3 is deeply healthy" : service === "stopped" ? "OR3 remains stopped" : "service recovery is unverified"}.`);
|
|
4820
|
+
if (!options.safetySnapshot && !remoteStorage)
|
|
4821
|
+
await validateCanonicalBackupReferences(directory, { ...env, OR3_IMAGE: imageAtDigest(state.image, state.imageDigest) }, backupDir);
|
|
3577
4822
|
const manifest2 = {
|
|
3578
|
-
schemaVersion: 1,
|
|
4823
|
+
...remote ? { schemaVersion: 2, storage: "s3", remote } : { schemaVersion: 1 },
|
|
3579
4824
|
backupId,
|
|
4825
|
+
snapshotKind: options.safetySnapshot ? "safety" : "full",
|
|
3580
4826
|
createdAt: capturedAt,
|
|
3581
4827
|
appVersion: state.appVersion,
|
|
3582
4828
|
image: state.image,
|
|
3583
4829
|
imageDigest: state.imageDigest,
|
|
3584
|
-
dataSha256: await sha256File(
|
|
3585
|
-
...managedVaultEnabled(directory, env) ? { vaultSha256: await sha256File(
|
|
4830
|
+
dataSha256: await sha256File(join19(backupDir, "data.tgz")),
|
|
4831
|
+
...managedVaultEnabled(directory, env) ? { vaultSha256: await sha256File(join19(backupDir, "vault.backup")) } : {},
|
|
3586
4832
|
dataBytes: volumeSize,
|
|
3587
|
-
configSha256: await sha256File(
|
|
4833
|
+
configSha256: await sha256File(join19(backupDir, "config.env")),
|
|
3588
4834
|
managedAssetSha256,
|
|
3589
4835
|
managedAssetInventoryVersion: managedAssetSha256?.["dashboard-lifecycle.mjs"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256?.["compose.operator.yaml"] ? 3 : managedAssetSha256?.["dashboard-operator.mjs"] ? 2 : undefined,
|
|
3590
4836
|
mode: state.mode,
|
|
@@ -3598,10 +4844,8 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3598
4844
|
};
|
|
3599
4845
|
const manifestContents = `${JSON.stringify(manifest2, null, 2)}
|
|
3600
4846
|
`;
|
|
3601
|
-
await writeSecure(
|
|
4847
|
+
await writeSecure(join19(backupDir, "manifest.json"), manifestContents);
|
|
3602
4848
|
await writeBackupAuthentication(directory, backupDir, manifestContents);
|
|
3603
|
-
if (!options.safetySnapshot)
|
|
3604
|
-
await validateCanonicalBackupReferences(directory, { ...env, OR3_IMAGE: imageAtDigest(state.image, state.imageDigest) }, backupDir);
|
|
3605
4849
|
verifiedManifest = await readManifest(backupDir, directory);
|
|
3606
4850
|
artifact = "verified";
|
|
3607
4851
|
} catch (error) {
|
|
@@ -3650,12 +4894,12 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3650
4894
|
async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath) {
|
|
3651
4895
|
if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
|
|
3652
4896
|
return;
|
|
3653
|
-
const expectedPath =
|
|
3654
|
-
if (
|
|
4897
|
+
const expectedPath = resolve10(backupDirectory(directory, backupId));
|
|
4898
|
+
if (resolve10(backupPath) !== expectedPath)
|
|
3655
4899
|
return;
|
|
3656
4900
|
const statuses = await Promise.all([
|
|
3657
4901
|
artifactStatus(expectedPath),
|
|
3658
|
-
artifactStatus(
|
|
4902
|
+
artifactStatus(join19(deploymentPaths(directory).exports, `${backupId}.json`))
|
|
3659
4903
|
]);
|
|
3660
4904
|
const detail = statuses.map((status) => status.detail).join(" ");
|
|
3661
4905
|
return statuses.every((status) => status.absent) ? detail : `Maintenance warning: backup artifact ownership was not recorded. ${detail} This recovery did not verify or remove these paths. Inspect them with "npx @or3/cloud backup list".`;
|
|
@@ -3663,8 +4907,8 @@ async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath)
|
|
|
3663
4907
|
async function cleanupJournaledPartialBackup(directory, backupId, backupPath) {
|
|
3664
4908
|
if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
|
|
3665
4909
|
return;
|
|
3666
|
-
const expectedPath =
|
|
3667
|
-
if (
|
|
4910
|
+
const expectedPath = resolve10(backupDirectory(directory, backupId));
|
|
4911
|
+
if (resolve10(backupPath) !== expectedPath)
|
|
3668
4912
|
return;
|
|
3669
4913
|
try {
|
|
3670
4914
|
await readManifest(expectedPath, directory);
|
|
@@ -3681,51 +4925,53 @@ async function createPreMutationSnapshot(directory, state, env, backupId) {
|
|
|
3681
4925
|
}
|
|
3682
4926
|
|
|
3683
4927
|
// src/backup/export.ts
|
|
3684
|
-
import { chmod as chmod7, mkdir as mkdir7, realpath, rm as
|
|
3685
|
-
import { basename as basename5, dirname as dirname5, isAbsolute as
|
|
4928
|
+
import { chmod as chmod7, mkdir as mkdir7, realpath as realpath2, rm as rm7, stat as stat4 } from "node:fs/promises";
|
|
4929
|
+
import { basename as basename5, dirname as dirname5, isAbsolute as isAbsolute5, join as join20, resolve as resolve11, sep } from "node:path";
|
|
3686
4930
|
var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
|
|
3687
4931
|
async function exportBackup(directory, backupId, destination) {
|
|
3688
4932
|
const backupPath = await resolveBackup(directory, backupId);
|
|
3689
4933
|
const manifest2 = await readManifest(backupPath, directory);
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
const
|
|
4934
|
+
if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-reference-v1")
|
|
4935
|
+
throw new Error("S3 reference-only snapshots cannot be exported as recovery backups.");
|
|
4936
|
+
const dest = resolve11(destination);
|
|
4937
|
+
const source = resolve11(backupPath);
|
|
4938
|
+
const deploymentRoot = await realpath2(directory);
|
|
3693
4939
|
if (source === dest || dest.startsWith(`${source}${sep}`)) {
|
|
3694
4940
|
throw new Error("Choose a destination directory different from the backup itself.");
|
|
3695
4941
|
}
|
|
3696
4942
|
if (await fileExists(dest))
|
|
3697
4943
|
throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
|
|
3698
4944
|
await mkdir7(dirname5(dest), { recursive: true, mode: 448 });
|
|
3699
|
-
const canonicalDestination =
|
|
4945
|
+
const canonicalDestination = join20(await realpath2(dirname5(dest)), basename5(dest));
|
|
3700
4946
|
if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`)) {
|
|
3701
4947
|
throw new Error("Backup exports must live outside the managed deployment directory so `remove --purge-data` can never delete the remaining copy.");
|
|
3702
4948
|
}
|
|
3703
4949
|
await mkdir7(dest, { mode: 448 });
|
|
3704
4950
|
try {
|
|
3705
|
-
if (await
|
|
4951
|
+
if (await realpath2(dest) !== canonicalDestination) {
|
|
3706
4952
|
throw new Error("Backup export destination changed while it was being created. Refusing to write an export through an unexpected path.");
|
|
3707
4953
|
}
|
|
3708
4954
|
await chmod7(dest, 448);
|
|
3709
|
-
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : []]) {
|
|
3710
|
-
await copySecure(
|
|
4955
|
+
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : [], ...manifest2.schemaVersion === 2 ? [manifest2.remote.file] : []]) {
|
|
4956
|
+
await copySecure(join20(backupPath, file), join20(dest, file));
|
|
3711
4957
|
}
|
|
3712
4958
|
if (manifest2.managedAssetSha256) {
|
|
3713
4959
|
for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
|
|
3714
|
-
await copySecure(
|
|
4960
|
+
await copySecure(join20(backupPath, "managed-assets", name), join20(dest, "managed-assets", name));
|
|
3715
4961
|
}
|
|
3716
4962
|
}
|
|
3717
|
-
const dataSha = await sha256File(
|
|
4963
|
+
const dataSha = await sha256File(join20(dest, "data.tgz"));
|
|
3718
4964
|
if (dataSha !== manifest2.dataSha256) {
|
|
3719
4965
|
throw new Error(`Exported data.tgz checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${dataSha}.`);
|
|
3720
4966
|
}
|
|
3721
4967
|
if (manifest2.configSha256) {
|
|
3722
|
-
const configSha = await sha256File(
|
|
4968
|
+
const configSha = await sha256File(join20(dest, "config.env"));
|
|
3723
4969
|
if (configSha !== manifest2.configSha256) {
|
|
3724
4970
|
throw new Error(`Exported config.env checksum mismatch for ${manifest2.backupId}.`);
|
|
3725
4971
|
}
|
|
3726
4972
|
}
|
|
3727
4973
|
await readManifest(dest, directory);
|
|
3728
|
-
const destinationDevice = (await
|
|
4974
|
+
const destinationDevice = (await stat4(dest)).dev;
|
|
3729
4975
|
await mkdir7(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
|
|
3730
4976
|
const receipt = {
|
|
3731
4977
|
schemaVersion: 1,
|
|
@@ -3736,65 +4982,28 @@ async function exportBackup(directory, backupId, destination) {
|
|
|
3736
4982
|
dataSha256: manifest2.dataSha256,
|
|
3737
4983
|
configSha256: manifest2.configSha256
|
|
3738
4984
|
};
|
|
3739
|
-
await writeSecure(
|
|
4985
|
+
await writeSecure(join20(deploymentPaths(directory).exports, `${manifest2.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
|
|
3740
4986
|
`);
|
|
3741
|
-
let bytes = (manifest2.vaultSha256 ? (await
|
|
4987
|
+
let bytes = (manifest2.vaultSha256 ? (await stat4(join20(dest, "vault.backup"))).size : 0) + (await stat4(join20(dest, "data.tgz"))).size + (await stat4(join20(dest, "config.env"))).size + (await stat4(join20(dest, "manifest.json"))).size + (await stat4(join20(dest, "manifest.auth"))).size;
|
|
4988
|
+
if (manifest2.schemaVersion === 2)
|
|
4989
|
+
bytes += (await stat4(join20(dest, manifest2.remote.file))).size;
|
|
3742
4990
|
if (manifest2.managedAssetSha256) {
|
|
3743
4991
|
for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
|
|
3744
|
-
bytes += (await
|
|
4992
|
+
bytes += (await stat4(join20(dest, "managed-assets", name))).size;
|
|
3745
4993
|
}
|
|
3746
4994
|
}
|
|
3747
4995
|
console.log(`Exported backup ${manifest2.backupId} (${bytes} bytes) to ${dest}`);
|
|
3748
4996
|
console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
|
|
3749
|
-
if ((await
|
|
3750
|
-
console.log("This export is on the same filesystem as the deployment
|
|
4997
|
+
if ((await stat4(backupPath)).dev === destinationDevice) {
|
|
4998
|
+
console.log("This export is on the same filesystem as the deployment. Keep a separate off-host copy; data purge remains disabled until reconstruction is supported.");
|
|
3751
4999
|
} else {
|
|
3752
|
-
console.log("Verified export recorded.
|
|
5000
|
+
console.log("Verified export recorded. Data purge remains disabled until reconstruction from an export is supported.");
|
|
3753
5001
|
}
|
|
3754
5002
|
} catch (error) {
|
|
3755
|
-
await
|
|
5003
|
+
await rm7(dest, { recursive: true, force: true });
|
|
3756
5004
|
throw error;
|
|
3757
5005
|
}
|
|
3758
5006
|
}
|
|
3759
|
-
function assertPurgeBackupFreshness(backups, nowMs) {
|
|
3760
|
-
const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
|
|
3761
|
-
if (!backups.some((backup) => new Date(backup.createdAt).getTime() >= cutoff)) {
|
|
3762
|
-
throw new Error('No backup newer than 24 hours exists for this deployment. Run "npx @or3/cloud backup" and "npx @or3/cloud backup export <backup-id> <destination-dir>" before destroying local data.');
|
|
3763
|
-
}
|
|
3764
|
-
}
|
|
3765
|
-
async function readBackupExportReceipt(directory, backupId) {
|
|
3766
|
-
try {
|
|
3767
|
-
const receipt = JSON.parse(await readText(join17(deploymentPaths(directory).exports, `${backupId}.json`)));
|
|
3768
|
-
if (receipt.schemaVersion !== 1 || receipt.backupId !== backupId || typeof receipt.destination !== "string" || !isAbsolute3(receipt.destination) || typeof receipt.destinationDevice !== "number" || !receipt.dataSha256)
|
|
3769
|
-
return;
|
|
3770
|
-
return receipt;
|
|
3771
|
-
} catch {
|
|
3772
|
-
return;
|
|
3773
|
-
}
|
|
3774
|
-
}
|
|
3775
|
-
async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
|
|
3776
|
-
assertPurgeBackupFreshness(backups, nowMs);
|
|
3777
|
-
const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
|
|
3778
|
-
for (const backup of backups) {
|
|
3779
|
-
if (new Date(backup.createdAt).getTime() < cutoff)
|
|
3780
|
-
continue;
|
|
3781
|
-
const receipt = await readBackupExportReceipt(directory, backup.backupId);
|
|
3782
|
-
if (!receipt || !await fileExists(receipt.destination))
|
|
3783
|
-
continue;
|
|
3784
|
-
const deploymentRoot = await realpath(directory);
|
|
3785
|
-
try {
|
|
3786
|
-
const canonicalDestination = await realpath(receipt.destination);
|
|
3787
|
-
if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`))
|
|
3788
|
-
continue;
|
|
3789
|
-
const exported = await readManifest(receipt.destination, directory);
|
|
3790
|
-
const sourceDevice = (await stat3(backup.path)).dev;
|
|
3791
|
-
const destinationDevice = (await stat3(receipt.destination)).dev;
|
|
3792
|
-
if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
|
|
3793
|
-
return receipt;
|
|
3794
|
-
} catch {}
|
|
3795
|
-
}
|
|
3796
|
-
throw new Error("No fresh checksum-verified backup export on another filesystem is available. Run `npx @or3/cloud backup export <backup-id> <new-directory-on-a-mounted-backup-disk>` before purging local data.");
|
|
3797
|
-
}
|
|
3798
5007
|
|
|
3799
5008
|
// src/commands/backup.ts
|
|
3800
5009
|
function parseKeep(flags) {
|
|
@@ -3808,6 +5017,10 @@ async function backupCreateCommand(directory) {
|
|
|
3808
5017
|
await ensureDocker();
|
|
3809
5018
|
const loaded = await loadManaged(directory);
|
|
3810
5019
|
assertNoPending(loaded.state);
|
|
5020
|
+
assertSupportedBackupProfile(loaded.env);
|
|
5021
|
+
await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
|
|
5022
|
+
if (managedVaultEnabled(loaded.directory, loaded.env))
|
|
5023
|
+
await assertVaultVolumesExist(loaded.directory, loaded.state);
|
|
3811
5024
|
const backupId = id("backup");
|
|
3812
5025
|
const initialAppRunning = await projectServiceRunning(loaded.directory, loaded.state.mode);
|
|
3813
5026
|
const pending = {
|
|
@@ -3882,6 +5095,10 @@ async function backupListCommand(directory, flags = {}) {
|
|
|
3882
5095
|
appVersion: backup.appVersion,
|
|
3883
5096
|
bytes: backup.bytes,
|
|
3884
5097
|
dataSha256: backup.dataSha256,
|
|
5098
|
+
manifestSchemaVersion: backup.schemaVersion,
|
|
5099
|
+
snapshotKind: backup.snapshotKind ?? null,
|
|
5100
|
+
recoveryArtifactsComplete: backup.recoveryArtifactsComplete === true,
|
|
5101
|
+
...backup.remoteKind ? { remoteKind: backup.remoteKind } : {},
|
|
3885
5102
|
trust: "verified"
|
|
3886
5103
|
})),
|
|
3887
5104
|
findings: findings.map((entry) => ({ entryName: entry.entryName, kind: entry.kind, code: entry.code, message: entry.message })),
|
|
@@ -3897,10 +5114,16 @@ async function backupListCommand(directory, flags = {}) {
|
|
|
3897
5114
|
console.log(`No authenticated backups are available for ${loaded.directory}.`);
|
|
3898
5115
|
} else {
|
|
3899
5116
|
console.log(`OR3 Cloud backups for ${loaded.directory} (${verified.length} verified):`);
|
|
3900
|
-
console.log("backupId createdAt version bytes trust checksum");
|
|
5117
|
+
console.log("backupId createdAt version bytes kind snapshot coverage trust checksum");
|
|
3901
5118
|
for (const backup of verified) {
|
|
3902
|
-
|
|
5119
|
+
const kind = backup.remoteKind === "s3-reference-v1" ? "reference-only" : backup.remoteKind === "s3-copy-v1" ? "s3-copy" : "filesystem";
|
|
5120
|
+
const snapshot = backup.snapshotKind ?? "unmarked";
|
|
5121
|
+
const coverage = backup.recoveryArtifactsComplete ? "complete" : "incomplete";
|
|
5122
|
+
console.log(`${backup.backupId.padEnd(30)} ${backup.createdAt.padEnd(30)} ${backup.appVersion.padEnd(8)} ${String(backup.bytes).padStart(9)} ${kind.padEnd(19)} ${snapshot.padEnd(9)} ${coverage.padEnd(11)} ${"verified".padEnd(9)} ${backup.dataSha256.slice(0, 12)}`);
|
|
3903
5123
|
}
|
|
5124
|
+
console.log("Verified checksums and artifact coverage are not proof of a successful recovery.");
|
|
5125
|
+
if (verified.some((backup) => backup.remoteKind === "s3-reference-v1"))
|
|
5126
|
+
console.log("Reference-only snapshots require their original bucket and cannot be exported as recovery copies.");
|
|
3904
5127
|
}
|
|
3905
5128
|
if (findings.length > 0) {
|
|
3906
5129
|
console.log(`
|
|
@@ -3967,8 +5190,8 @@ async function backupCommand(directory, positionals, flags) {
|
|
|
3967
5190
|
}
|
|
3968
5191
|
|
|
3969
5192
|
// src/credentials/reset.ts
|
|
3970
|
-
import { rm as
|
|
3971
|
-
import { join as
|
|
5193
|
+
import { rm as rm8 } from "node:fs/promises";
|
|
5194
|
+
import { join as join21 } from "node:path";
|
|
3972
5195
|
|
|
3973
5196
|
// src/credentials/reset-script.ts
|
|
3974
5197
|
function buildCredentialsResetScript(input2) {
|
|
@@ -4152,7 +5375,10 @@ async function verifyCredentialsInsideContainer(directory, mode, values) {
|
|
|
4152
5375
|
throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
|
|
4153
5376
|
}
|
|
4154
5377
|
async function applyCredentialReset(directory, state, nextEnv) {
|
|
5378
|
+
assertLegacyManagedProfile(nextEnv);
|
|
4155
5379
|
const values = credentialResetValues(nextEnv);
|
|
5380
|
+
if (managedVaultEnabled(directory, nextEnv))
|
|
5381
|
+
await assertVaultVolumesExist(directory, state);
|
|
4156
5382
|
const running = await run("docker", [...composeArgs(directory, state.mode, ["ps", "-q", "or3"])], directory);
|
|
4157
5383
|
if (!running.ok || !running.stdout.trim()) {
|
|
4158
5384
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
|
|
@@ -4161,9 +5387,11 @@ async function applyCredentialReset(directory, state, nextEnv) {
|
|
|
4161
5387
|
await runCredentialsResetScript(directory, state.mode, values);
|
|
4162
5388
|
const runtimeEnv = withoutProvisioningCredentials(nextEnv);
|
|
4163
5389
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
|
|
4164
|
-
const initialCredentials =
|
|
4165
|
-
await
|
|
5390
|
+
const initialCredentials = join21(directory, ".or3-initial-credentials");
|
|
5391
|
+
await rm8(initialCredentials, { force: true });
|
|
4166
5392
|
await stopProject(directory, state.mode);
|
|
5393
|
+
if (managedVaultEnabled(directory, runtimeEnv))
|
|
5394
|
+
await assertVaultVolumesExist(directory, state);
|
|
4167
5395
|
await startProject(directory, state.mode, runtimeEnv);
|
|
4168
5396
|
await verifyCredentialsInsideContainer(directory, state.mode, values);
|
|
4169
5397
|
}
|
|
@@ -4176,6 +5404,10 @@ async function credentialsResetCommand(directory, flags) {
|
|
|
4176
5404
|
const loaded = await loadManaged(directory);
|
|
4177
5405
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
4178
5406
|
assertNoPending(loaded.state);
|
|
5407
|
+
assertLegacyManagedProfile(loaded.env);
|
|
5408
|
+
await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env);
|
|
5409
|
+
if (managedVaultEnabled(loaded.directory, loaded.env))
|
|
5410
|
+
await assertVaultVolumesExist(loaded.directory, loaded.state);
|
|
4179
5411
|
const ownerEmail = loaded.env.OR3_MANAGED_OWNER_EMAIL || loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
4180
5412
|
const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
|
|
4181
5413
|
if (!ownerEmail)
|
|
@@ -4222,10 +5454,10 @@ async function credentialsCommand(directory, positionals, flags) {
|
|
|
4222
5454
|
|
|
4223
5455
|
// src/commands/doctor.ts
|
|
4224
5456
|
import { existsSync as existsSync3 } from "node:fs";
|
|
4225
|
-
import { stat as
|
|
4226
|
-
import { basename as basename6, join as
|
|
5457
|
+
import { stat as stat5 } from "node:fs/promises";
|
|
5458
|
+
import { basename as basename6, join as join22, resolve as resolve12 } from "node:path";
|
|
4227
5459
|
async function doctorCommand(directory) {
|
|
4228
|
-
const resolved =
|
|
5460
|
+
const resolved = resolve12(directory);
|
|
4229
5461
|
const paths = deploymentPaths(resolved);
|
|
4230
5462
|
const failures = [];
|
|
4231
5463
|
let state;
|
|
@@ -4248,10 +5480,10 @@ async function doctorCommand(directory) {
|
|
|
4248
5480
|
failures.push(error instanceof Error ? error.message : String(error));
|
|
4249
5481
|
console.log("✗ Managed state or .env is missing/invalid");
|
|
4250
5482
|
}
|
|
4251
|
-
for (const file of [paths.env, paths.state,
|
|
5483
|
+
for (const file of [paths.env, paths.state, join22(resolved, ".or3-initial-credentials")]) {
|
|
4252
5484
|
if (!existsSync3(file))
|
|
4253
5485
|
continue;
|
|
4254
|
-
const mode = (await
|
|
5486
|
+
const mode = (await stat5(file)).mode & 511;
|
|
4255
5487
|
if (mode !== 384) {
|
|
4256
5488
|
failures.push(`${file} must be mode 0600 (currently ${mode.toString(8)}).`);
|
|
4257
5489
|
console.log(`✗ Permissions: ${file} is ${mode.toString(8)}`);
|
|
@@ -4319,8 +5551,8 @@ async function doctorCommand(directory) {
|
|
|
4319
5551
|
throw new Error("Dashboard operator deployment identity does not match managed state.");
|
|
4320
5552
|
}
|
|
4321
5553
|
assertDashboardOperatorMounts(container.Mounts ?? [], resolved);
|
|
4322
|
-
const ipcDirectory = await
|
|
4323
|
-
const operatorSocket = await
|
|
5554
|
+
const ipcDirectory = await stat5(paths.operatorIpc);
|
|
5555
|
+
const operatorSocket = await stat5(join22(paths.operatorIpc, "operator.sock"));
|
|
4324
5556
|
const operatorUid = Number(env.OR3_OPERATOR_UID);
|
|
4325
5557
|
const operatorGid = Number(env.OR3_OPERATOR_GID);
|
|
4326
5558
|
if ((ipcDirectory.mode & 511) !== 456 || ipcDirectory.uid !== operatorUid || ipcDirectory.gid !== operatorGid || !operatorSocket.isSocket() || (operatorSocket.mode & 511) !== 432 || operatorSocket.uid !== operatorUid || operatorSocket.gid !== operatorGid) {
|
|
@@ -4379,15 +5611,15 @@ async function doctorCommand(directory) {
|
|
|
4379
5611
|
|
|
4380
5612
|
// src/commands/init.ts
|
|
4381
5613
|
import { chmod as chmod8, mkdir as mkdir8 } from "node:fs/promises";
|
|
4382
|
-
import { join as
|
|
5614
|
+
import { join as join23, relative, resolve as resolve13 } from "node:path";
|
|
4383
5615
|
async function initCommand(positionals, flags) {
|
|
4384
5616
|
const hasLocal = boolFlag(flags, "local");
|
|
4385
5617
|
const hasPublic = boolFlag(flags, "public");
|
|
4386
5618
|
if (hasLocal === hasPublic)
|
|
4387
5619
|
throw new Error("Choose exactly one of --local or --public.");
|
|
4388
5620
|
const mode = hasPublic ? "public" : "local";
|
|
4389
|
-
const directory =
|
|
4390
|
-
if (await fileExists(
|
|
5621
|
+
const directory = resolve13(process.cwd(), positionals[0] ?? "or3-cloud");
|
|
5622
|
+
if (await fileExists(join23(directory, ".or3-cloud", "state.json"))) {
|
|
4391
5623
|
throw new Error(`${directory} is already a managed deployment. Use update, doctor, or adopt.`);
|
|
4392
5624
|
}
|
|
4393
5625
|
await readDirectoryEmpty(directory);
|
|
@@ -4413,7 +5645,7 @@ async function initCommand(positionals, flags) {
|
|
|
4413
5645
|
await ensureDocker();
|
|
4414
5646
|
const names = composeProjectNames(directory);
|
|
4415
5647
|
await assertDockerProjectAbsent(names.project);
|
|
4416
|
-
for (const volume of [names.volume, ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
|
|
5648
|
+
for (const volume of [names.volume, ...managedVaultVolumeNames(names.volume), ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
|
|
4417
5649
|
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
4418
5650
|
if (existing.ok)
|
|
4419
5651
|
throw new Error(`Docker volume ${volume} already exists. Choose a new directory or inspect it before initializing.`);
|
|
@@ -4438,7 +5670,7 @@ ${existing.stderr}`)) {
|
|
|
4438
5670
|
await copyAssets(directory, mode);
|
|
4439
5671
|
const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
|
|
4440
5672
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
|
|
4441
|
-
await writeSecure(
|
|
5673
|
+
await writeSecure(join23(directory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
4442
5674
|
bootstrapEmail: email,
|
|
4443
5675
|
bootstrapPassword: password,
|
|
4444
5676
|
adminUsername: email,
|
|
@@ -4463,7 +5695,7 @@ ${existing.stderr}`)) {
|
|
|
4463
5695
|
const folder = relative(process.cwd(), directory) || ".";
|
|
4464
5696
|
console.log(`
|
|
4465
5697
|
OR3 Cloud ${version} is running at ${url}`);
|
|
4466
|
-
console.log(`Sign in as ${email} with the password in ${
|
|
5698
|
+
console.log(`Sign in as ${email} with the password in ${join23(folder, ".or3-initial-credentials")}; the same login opens ${url}/admin.`);
|
|
4467
5699
|
console.log("Save it in a password manager, then delete that file.");
|
|
4468
5700
|
console.log(`
|
|
4469
5701
|
Run later commands from the deployment folder: cd ${quote(folder)} && npx @or3/cloud doctor`);
|
|
@@ -4484,6 +5716,10 @@ async function startCommand(directory) {
|
|
|
4484
5716
|
const loaded = await loadManaged(directory);
|
|
4485
5717
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
4486
5718
|
assertPhaseAllowsCommand("start", loaded.state);
|
|
5719
|
+
assertLegacyManagedProfile(loaded.env);
|
|
5720
|
+
await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env);
|
|
5721
|
+
if (managedVaultEnabled(loaded.directory, loaded.env))
|
|
5722
|
+
await assertVaultVolumesExist(loaded.directory, loaded.state);
|
|
4487
5723
|
await startProject(directory, loaded.state.mode, loaded.env);
|
|
4488
5724
|
const url = loaded.state.mode === "public" ? `https://${loaded.state.domain}` : `http://127.0.0.1:${loaded.state.port}`;
|
|
4489
5725
|
console.log(`OR3 started and is deeply healthy at ${url}.`);
|
|
@@ -4501,6 +5737,8 @@ async function restartCommand(directory, lifecycle) {
|
|
|
4501
5737
|
const loaded = await loadManaged(directory);
|
|
4502
5738
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
4503
5739
|
assertPhaseAllowsCommand("restart", loaded.state);
|
|
5740
|
+
assertLegacyManagedProfile(loaded.env);
|
|
5741
|
+
await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env, lifecycle?.deadline);
|
|
4504
5742
|
if (lifecycle) {
|
|
4505
5743
|
await assertSafeComposeBinding(directory, loaded.state.mode, loaded.env, lifecycle.deadline);
|
|
4506
5744
|
await assertRunningAppImage(directory, loaded.state.mode, loaded.state.image, lifecycle.deadline);
|
|
@@ -4515,11 +5753,8 @@ async function restartCommand(directory, lifecycle) {
|
|
|
4515
5753
|
console.log("OR3 restarted and is deeply healthy.");
|
|
4516
5754
|
}
|
|
4517
5755
|
|
|
4518
|
-
// src/commands/recover.ts
|
|
4519
|
-
import { join as join24 } from "node:path";
|
|
4520
|
-
|
|
4521
5756
|
// src/backup/restore.ts
|
|
4522
|
-
import { join as
|
|
5757
|
+
import { join as join24 } from "node:path";
|
|
4523
5758
|
|
|
4524
5759
|
// src/backup/generation-restore.ts
|
|
4525
5760
|
function rebindRestoredGenerations(fs, path, crypto, db, root, options = {}) {
|
|
@@ -4715,14 +5950,14 @@ if (root !== '/data' && !root.startsWith('/data/')) throw new Error('Generation
|
|
|
4715
5950
|
if (!result.ok)
|
|
4716
5951
|
throw new Error(`Restored generation verification failed. ${redact(result.stderr, secretValues(env))}`);
|
|
4717
5952
|
}
|
|
4718
|
-
function assertFilesystemPayloadHash(key, readChunk,
|
|
5953
|
+
function assertFilesystemPayloadHash(key, readChunk, createHash5) {
|
|
4719
5954
|
const match = /^(sha256|md5)_([a-fA-F0-9]+)$/.exec(key);
|
|
4720
5955
|
const algorithm = match?.[1];
|
|
4721
5956
|
const expected = match?.[2];
|
|
4722
5957
|
if (algorithm !== "sha256" && algorithm !== "md5" || expected?.length !== (algorithm === "sha256" ? 64 : 32)) {
|
|
4723
5958
|
throw new Error("Invalid canonical filesystem payload key");
|
|
4724
5959
|
}
|
|
4725
|
-
const hash =
|
|
5960
|
+
const hash = createHash5(algorithm);
|
|
4726
5961
|
for (let chunk = readChunk();chunk !== undefined; chunk = readChunk())
|
|
4727
5962
|
hash.update(chunk);
|
|
4728
5963
|
if (hash.digest("hex") !== expected.toLowerCase())
|
|
@@ -4732,8 +5967,10 @@ var VERIFY_RESTORED_DATA_SCRIPT = `
|
|
|
4732
5967
|
const fs = require('node:fs');
|
|
4733
5968
|
const Database = require('/app/.output/server/node_modules/better-sqlite3');
|
|
4734
5969
|
const validFilesystemPayload = ${assertFilesystemPayloadHash.toString()};
|
|
4735
|
-
const
|
|
4736
|
-
if (
|
|
5970
|
+
const auth = process.env.AUTH_PROVIDER || 'basic-auth', storage = process.env.NUXT_PUBLIC_STORAGE_PROVIDER || 'fs';
|
|
5971
|
+
if (!(auth === 'basic-auth' && storage === 'fs' || auth === 'clerk' && storage === 's3')) throw new Error('Unsupported restored provider profile');
|
|
5972
|
+
const paths = auth === 'basic-auth' ? [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH] : [process.env.OR3_SQLITE_DB_PATH];
|
|
5973
|
+
if (paths.some(path => !path)) throw new Error('Managed database paths must be configured');
|
|
4737
5974
|
for (const path of paths) {
|
|
4738
5975
|
const databasePath = fs.realpathSync(path);
|
|
4739
5976
|
if (!fs.statSync(databasePath).isFile()) throw new Error('Restored database payload must be a regular file');
|
|
@@ -4754,7 +5991,7 @@ const path = require('node:path');
|
|
|
4754
5991
|
const crypto = require('node:crypto');
|
|
4755
5992
|
const storageRoot = path.resolve(process.env.OR3_STORAGE_FS_ROOT || '/data/storage');
|
|
4756
5993
|
const workspacesRoot = path.join(storageRoot, 'workspaces');
|
|
4757
|
-
if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
|
|
5994
|
+
if (storage === 'fs' && (storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
|
|
4758
5995
|
const chunk = Buffer.alloc(65536);
|
|
4759
5996
|
for (const workspace of fs.readdirSync(workspacesRoot, { withFileTypes: true }).filter(entry => /^[a-zA-Z0-9_-]+$/.test(entry.name))) {
|
|
4760
5997
|
const directory = path.join(workspacesRoot, workspace.name);
|
|
@@ -4776,7 +6013,7 @@ if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSy
|
|
|
4776
6013
|
}
|
|
4777
6014
|
`;
|
|
4778
6015
|
async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPath) {
|
|
4779
|
-
const archiveBytes = await gzipUncompressedBytes(
|
|
6016
|
+
const archiveBytes = await gzipUncompressedBytes(join24(backupPath, "data.tgz"));
|
|
4780
6017
|
const requiredBytes = Math.max(manifest2.dataBytes ?? 0, archiveBytes);
|
|
4781
6018
|
const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
|
|
4782
6019
|
const reclaimableBytes = await dataVolumeSize(directory, state.mode, env, "allocated");
|
|
@@ -4785,15 +6022,32 @@ async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPa
|
|
|
4785
6022
|
async function restoreBackupData(directory, state, env, backupPath, options = {}) {
|
|
4786
6023
|
const manifest2 = await readManifest(backupPath, directory);
|
|
4787
6024
|
assertRestorableManagedAssets(manifest2);
|
|
4788
|
-
const backupEnv = parseEnv(await readText(
|
|
6025
|
+
const backupEnv = parseEnv(await readText(join24(backupPath, "config.env")));
|
|
4789
6026
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
4790
6027
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
4791
6028
|
}
|
|
4792
6029
|
assertBackupMatchesDeployment(manifest2, backupEnv, state, env);
|
|
6030
|
+
if (manifest2.schemaVersion === 1)
|
|
6031
|
+
assertSupportedBackupProfile(backupEnv);
|
|
6032
|
+
else if (!options.beforeStart && options.remoteReferencePolicy !== "warn" || options.remoteReferencePolicy === "warn" && (!options.previousSafetySnapshot || options.beforeStart) || options.previousSafetySnapshot && manifest2.remote.kind !== "s3-reference-v1") {
|
|
6033
|
+
throw new Error("S3 restore requires a journaled startup or a reference-only safety snapshot.");
|
|
6034
|
+
}
|
|
4793
6035
|
if (manifest2.imageDigest)
|
|
4794
6036
|
await requireImageDigest(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
|
|
6037
|
+
await assertExistingVaultCustody(directory, state, manifest2, backupPath, backupEnv);
|
|
4795
6038
|
await validateVolumeArchive(directory, state.mode, env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
|
|
4796
|
-
if (
|
|
6039
|
+
if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-copy-v1") {
|
|
6040
|
+
const result = await checkRemoteCopyTarget(directory, manifest2, env, backupPath, manifest2.remote);
|
|
6041
|
+
if (result.missing || result.mismatched)
|
|
6042
|
+
throw new Error("S3 restore target has missing or mismatched objects.");
|
|
6043
|
+
} else if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-reference-v1") {
|
|
6044
|
+
const result = await checkRemoteTarget(directory, manifest2, env, backupPath, manifest2.remote);
|
|
6045
|
+
if (result.missing || result.mismatched) {
|
|
6046
|
+
if (options.remoteReferencePolicy !== "warn")
|
|
6047
|
+
throw new Error("S3 reference target has missing or mismatched objects.");
|
|
6048
|
+
console.warn(`Restoring the pre-mutation snapshot with ${result.missing} missing and ${result.mismatched} mismatched S3 objects; attachment completeness is not certified.`);
|
|
6049
|
+
}
|
|
6050
|
+
} else if (!options.previousSafetySnapshot) {
|
|
4797
6051
|
await validateCanonicalBackupReferences(directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
|
|
4798
6052
|
} else {
|
|
4799
6053
|
console.warn("Restoring the recorded byte-preservation safety snapshot; canonical attachment completeness is not certified. Verify previously uploaded files.");
|
|
@@ -4802,10 +6056,10 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
|
|
|
4802
6056
|
await assertRestoreFreeSpace(directory, state, env, manifest2, backupPath);
|
|
4803
6057
|
const restoresOperatorOverlay = [3, MANAGED_ASSET_INVENTORY_VERSION].includes(manifest2.managedAssetInventoryVersion ?? 1) && Boolean(manifest2.managedAssetSha256?.["compose.operator.yaml"]);
|
|
4804
6058
|
const restoredEnv = restoresOperatorOverlay ? {
|
|
4805
|
-
...backupEnv,
|
|
6059
|
+
...restoreStorageConfiguration(backupEnv, env),
|
|
4806
6060
|
OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest)
|
|
4807
6061
|
} : withoutDashboardOperator({
|
|
4808
|
-
...backupEnv,
|
|
6062
|
+
...restoreStorageConfiguration(backupEnv, env),
|
|
4809
6063
|
OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest)
|
|
4810
6064
|
});
|
|
4811
6065
|
if (options.recreateDataVolume)
|
|
@@ -4835,6 +6089,10 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
|
|
|
4835
6089
|
"--env",
|
|
4836
6090
|
`OR3_SQLITE_DB_PATH=${restoredEnv.OR3_SQLITE_DB_PATH ?? ""}`,
|
|
4837
6091
|
"--env",
|
|
6092
|
+
`AUTH_PROVIDER=${restoredEnv.AUTH_PROVIDER ?? restoredEnv.OR3_AUTH_PROVIDER ?? "basic-auth"}`,
|
|
6093
|
+
"--env",
|
|
6094
|
+
`NUXT_PUBLIC_STORAGE_PROVIDER=${restoredEnv.NUXT_PUBLIC_STORAGE_PROVIDER ?? "fs"}`,
|
|
6095
|
+
"--env",
|
|
4838
6096
|
`OR3_STORAGE_FS_ROOT=${restoredEnv.OR3_STORAGE_FS_ROOT ?? "/data/storage"}`,
|
|
4839
6097
|
"--entrypoint",
|
|
4840
6098
|
"sh",
|
|
@@ -4844,9 +6102,19 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
|
|
|
4844
6102
|
if (!snapshotCheck.ok) {
|
|
4845
6103
|
throw new Error(`Restored snapshot integrity verification failed. ${redact(snapshotCheck.stderr, secretValues(restoredEnv))}`);
|
|
4846
6104
|
}
|
|
4847
|
-
|
|
6105
|
+
if (manifest2.schemaVersion === 1)
|
|
6106
|
+
await restoreGenerationIdentities(directory, state, restoredEnv, backupPath, { previousSafetySnapshot: options.previousSafetySnapshot });
|
|
4848
6107
|
await options.beforeStart?.();
|
|
4849
6108
|
await startProject(directory, state.mode, restoredEnv);
|
|
6109
|
+
if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-copy-v1") {
|
|
6110
|
+
const result = await checkRemoteCopyTarget(directory, manifest2, restoredEnv, backupPath, manifest2.remote);
|
|
6111
|
+
if (result.missing || result.mismatched)
|
|
6112
|
+
throw new Error("S3 restore target has missing or mismatched objects after startup.");
|
|
6113
|
+
} else if (manifest2.schemaVersion === 2 && manifest2.remote.kind === "s3-reference-v1" && options.remoteReferencePolicy !== "warn") {
|
|
6114
|
+
const result = await checkRemoteTarget(directory, manifest2, restoredEnv, backupPath, manifest2.remote);
|
|
6115
|
+
if (result.missing || result.mismatched)
|
|
6116
|
+
throw new Error("S3 reference target has missing or mismatched objects after startup.");
|
|
6117
|
+
}
|
|
4850
6118
|
return manifest2;
|
|
4851
6119
|
}
|
|
4852
6120
|
async function restorePreMutationSnapshot(directory, state, env) {
|
|
@@ -4856,30 +6124,30 @@ async function restorePreMutationSnapshot(directory, state, env) {
|
|
|
4856
6124
|
const previous = await recordedBackupPath(directory, pending);
|
|
4857
6125
|
await updatePending(directory, state, { phase: "restoring-previous" });
|
|
4858
6126
|
if (pending.recreateDataVolume) {
|
|
4859
|
-
await restoreBackupData(directory, state, env, previous.path, { recreateDataVolume: true, previousSafetySnapshot: true });
|
|
6127
|
+
await restoreBackupData(directory, state, env, previous.path, { recreateDataVolume: true, previousSafetySnapshot: true, remoteReferencePolicy: "warn" });
|
|
4860
6128
|
return previous;
|
|
4861
6129
|
}
|
|
4862
6130
|
if (pending.previousRootOwnership) {
|
|
4863
6131
|
await pullAndRequireImage(state.image, state.imageDigest, "Previous deployment");
|
|
4864
6132
|
await setManagedVolumeRootOwnership(directory, state, env, state.image, pending.previousRootOwnership);
|
|
4865
6133
|
}
|
|
4866
|
-
await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true });
|
|
6134
|
+
await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true, remoteReferencePolicy: "warn" });
|
|
4867
6135
|
return previous;
|
|
4868
6136
|
}
|
|
4869
6137
|
|
|
4870
6138
|
// src/deployment/observation.ts
|
|
4871
|
-
import { createHash as
|
|
4872
|
-
import { resolve as
|
|
6139
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
6140
|
+
import { resolve as resolve14 } from "node:path";
|
|
4873
6141
|
|
|
4874
6142
|
// src/deployment/lease.ts
|
|
4875
6143
|
import { randomBytes as randomBytes8 } from "node:crypto";
|
|
4876
|
-
import { mkdir as mkdir10, rename as rename3, rm as
|
|
4877
|
-
import { dirname as dirname7, join as
|
|
6144
|
+
import { mkdir as mkdir10, rename as rename3, rm as rm10 } from "node:fs/promises";
|
|
6145
|
+
import { dirname as dirname7, join as join26 } from "node:path";
|
|
4878
6146
|
|
|
4879
6147
|
// ../../shared/server/restarts/controller.ts
|
|
4880
|
-
import { randomUUID as
|
|
4881
|
-
import { mkdir as mkdir9, open as
|
|
4882
|
-
import { dirname as dirname6, join as
|
|
6148
|
+
import { randomUUID as randomUUID5, createHash as createHash5 } from "node:crypto";
|
|
6149
|
+
import { mkdir as mkdir9, open as open4, readFile as readFile5, rename as rename2, rm as rm9 } from "node:fs/promises";
|
|
6150
|
+
import { dirname as dirname6, join as join25 } from "node:path";
|
|
4883
6151
|
|
|
4884
6152
|
// ../../node_modules/zod/v4/classic/external.js
|
|
4885
6153
|
var exports_external = {};
|
|
@@ -9928,7 +11196,7 @@ var recursive = /* @__PURE__ */ new WeakMap;
|
|
|
9928
11196
|
var NONE = 0;
|
|
9929
11197
|
var ASSUMED = 1;
|
|
9930
11198
|
var PROVEN = 2;
|
|
9931
|
-
function isRecursive(inst, stack,
|
|
11199
|
+
function isRecursive(inst, stack, resolve14) {
|
|
9932
11200
|
const cached2 = recursive.get(inst);
|
|
9933
11201
|
if (cached2 !== undefined)
|
|
9934
11202
|
return cached2 ? PROVEN : NONE;
|
|
@@ -9938,7 +11206,7 @@ function isRecursive(inst, stack, resolve13) {
|
|
|
9938
11206
|
let result = NONE;
|
|
9939
11207
|
const check = (child) => {
|
|
9940
11208
|
if (result !== PROVEN && child?._zod) {
|
|
9941
|
-
const answer = isRecursive(child, stack,
|
|
11209
|
+
const answer = isRecursive(child, stack, resolve14);
|
|
9942
11210
|
if (answer > result)
|
|
9943
11211
|
result = answer;
|
|
9944
11212
|
}
|
|
@@ -9949,7 +11217,7 @@ function isRecursive(inst, stack, resolve13) {
|
|
|
9949
11217
|
const desc = Object.getOwnPropertyDescriptor(sh, key);
|
|
9950
11218
|
if (spread && !desc.enumerable)
|
|
9951
11219
|
continue;
|
|
9952
|
-
const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack,
|
|
11220
|
+
const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve14) : NONE;
|
|
9953
11221
|
if (child > answer)
|
|
9954
11222
|
answer = child;
|
|
9955
11223
|
}
|
|
@@ -10012,7 +11280,7 @@ function isRecursive(inst, stack, resolve13) {
|
|
|
10012
11280
|
check(def.output);
|
|
10013
11281
|
break;
|
|
10014
11282
|
case "lazy": {
|
|
10015
|
-
const inner = def._cachedInner ?? (
|
|
11283
|
+
const inner = def._cachedInner ?? (resolve14 ? inst._zod.innerType : undefined);
|
|
10016
11284
|
merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
|
|
10017
11285
|
break;
|
|
10018
11286
|
}
|
|
@@ -10073,7 +11341,7 @@ function bucketFor(state, inst) {
|
|
|
10073
11341
|
return bucket;
|
|
10074
11342
|
}
|
|
10075
11343
|
var handoff;
|
|
10076
|
-
var
|
|
11344
|
+
var open3 = [];
|
|
10077
11345
|
var memo = {
|
|
10078
11346
|
alloc(_inst, payload, empty) {
|
|
10079
11347
|
const bucket = handoff;
|
|
@@ -10082,7 +11350,7 @@ var memo = {
|
|
|
10082
11350
|
handoff = undefined;
|
|
10083
11351
|
const entry = { value: empty, issues: null };
|
|
10084
11352
|
bucket.set(payload.value, entry);
|
|
10085
|
-
|
|
11353
|
+
open3.push(entry);
|
|
10086
11354
|
return empty;
|
|
10087
11355
|
},
|
|
10088
11356
|
guard(inst) {
|
|
@@ -10153,10 +11421,10 @@ var memo = {
|
|
|
10153
11421
|
return payload;
|
|
10154
11422
|
}
|
|
10155
11423
|
handoff = bucket;
|
|
10156
|
-
const depth =
|
|
11424
|
+
const depth = open3.length;
|
|
10157
11425
|
const result = base(payload, ctx);
|
|
10158
11426
|
handoff = undefined;
|
|
10159
|
-
const entry =
|
|
11427
|
+
const entry = open3.length > depth ? open3.pop() : undefined;
|
|
10160
11428
|
if (result instanceof Promise) {
|
|
10161
11429
|
return result.then((r) => {
|
|
10162
11430
|
if (entry)
|
|
@@ -24328,7 +25596,7 @@ var recordSchema = exports_external.object({
|
|
|
24328
25596
|
expected: exports_external.record(exports_external.string(), exports_external.union([exports_external.string().max(128), exports_external.boolean()])),
|
|
24329
25597
|
executionIntent: exports_external.boolean(),
|
|
24330
25598
|
runtimeIdentity: exports_external.string().max(512).optional()
|
|
24331
|
-
}).strict().refine((record2) => record2.requestId === record2.request.requestId && record2.fingerprint ===
|
|
25599
|
+
}).strict().refine((record2) => record2.requestId === record2.request.requestId && record2.fingerprint === createHash5("sha256").update(JSON.stringify(record2.request)).digest("hex"), "Receipt identity mismatch");
|
|
24332
25600
|
|
|
24333
25601
|
class RestartError extends Error {
|
|
24334
25602
|
code;
|
|
@@ -24342,15 +25610,15 @@ class RestartError extends Error {
|
|
|
24342
25610
|
var resolved = new Set(["succeeded", "cancelled", "failed"]);
|
|
24343
25611
|
var replayWindowMs = 24 * 60 * 60 * 1000;
|
|
24344
25612
|
async function readRestartJournal(path) {
|
|
24345
|
-
const text = await
|
|
25613
|
+
const text = await readFile5(path, "utf8");
|
|
24346
25614
|
if (Buffer.byteLength(text) > 512 * 1024)
|
|
24347
25615
|
throw new RestartError("journal_invalid");
|
|
24348
25616
|
return exports_external.object({ schemaVersion: exports_external.literal(1), identity: exports_external.string().min(1), operations: exports_external.array(recordSchema).max(256) }).strict().parse(JSON.parse(text));
|
|
24349
25617
|
}
|
|
24350
25618
|
async function writePrivateJson(path, value) {
|
|
24351
|
-
const temporary = `${path}.${
|
|
25619
|
+
const temporary = `${path}.${randomUUID5()}.tmp`;
|
|
24352
25620
|
try {
|
|
24353
|
-
const file2 = await
|
|
25621
|
+
const file2 = await open4(temporary, "wx", 384);
|
|
24354
25622
|
try {
|
|
24355
25623
|
await file2.writeFile(JSON.stringify(value));
|
|
24356
25624
|
await file2.sync();
|
|
@@ -24358,14 +25626,14 @@ async function writePrivateJson(path, value) {
|
|
|
24358
25626
|
await file2.close();
|
|
24359
25627
|
}
|
|
24360
25628
|
await rename2(temporary, path);
|
|
24361
|
-
const directory = await
|
|
25629
|
+
const directory = await open4(dirname6(path), "r");
|
|
24362
25630
|
try {
|
|
24363
25631
|
await directory.sync();
|
|
24364
25632
|
} finally {
|
|
24365
25633
|
await directory.close();
|
|
24366
25634
|
}
|
|
24367
25635
|
} finally {
|
|
24368
|
-
await
|
|
25636
|
+
await rm9(temporary, { force: true });
|
|
24369
25637
|
}
|
|
24370
25638
|
}
|
|
24371
25639
|
function publicOperation(o) {
|
|
@@ -24392,7 +25660,7 @@ class RestartController {
|
|
|
24392
25660
|
constructor(directory, driver) {
|
|
24393
25661
|
this.directory = directory;
|
|
24394
25662
|
this.driver = driver;
|
|
24395
|
-
this.path =
|
|
25663
|
+
this.path = join25(directory, "restarts.json");
|
|
24396
25664
|
}
|
|
24397
25665
|
lock(action) {
|
|
24398
25666
|
const next = this.serial.then(action, action);
|
|
@@ -24482,7 +25750,7 @@ class RestartController {
|
|
|
24482
25750
|
if (!parsed.success)
|
|
24483
25751
|
throw new RestartError("invalid_request", 400);
|
|
24484
25752
|
const request = parsed.data;
|
|
24485
|
-
const fingerprint =
|
|
25753
|
+
const fingerprint = createHash5("sha256").update(JSON.stringify(request)).digest("hex");
|
|
24486
25754
|
return this.lock(async () => {
|
|
24487
25755
|
const old = this.operations.find((o2) => o2.requestId === request.requestId);
|
|
24488
25756
|
if (old) {
|
|
@@ -24608,7 +25876,7 @@ class RestartController {
|
|
|
24608
25876
|
});
|
|
24609
25877
|
return;
|
|
24610
25878
|
}
|
|
24611
|
-
await new Promise((
|
|
25879
|
+
await new Promise((resolve14) => setTimeout(resolve14, 250));
|
|
24612
25880
|
}
|
|
24613
25881
|
if (this.disposed)
|
|
24614
25882
|
return;
|
|
@@ -24631,7 +25899,7 @@ class RestartController {
|
|
|
24631
25899
|
throw new RestartError("configuration_unsupported", 501);
|
|
24632
25900
|
if ((await this.driver.configuration()).revision !== expectedRevision)
|
|
24633
25901
|
throw new RestartError("stale_revision");
|
|
24634
|
-
const release = await this.driver.acquire(
|
|
25902
|
+
const release = await this.driver.acquire(randomUUID5());
|
|
24635
25903
|
try {
|
|
24636
25904
|
await this.driver.save(updates, expectedRevision);
|
|
24637
25905
|
} finally {
|
|
@@ -24690,7 +25958,7 @@ class RestartController {
|
|
|
24690
25958
|
var DASHBOARD_LEASE_STALE_MS = 30000;
|
|
24691
25959
|
async function readLeaseOwner(leasePath) {
|
|
24692
25960
|
try {
|
|
24693
|
-
const owner = JSON.parse(await readText(
|
|
25961
|
+
const owner = JSON.parse(await readText(join26(leasePath, "owner.json")));
|
|
24694
25962
|
if (owner.schemaVersion !== 1 || typeof owner.nonce !== "string" || typeof owner.command !== "string" || owner.origin !== "cli" && owner.origin !== "dashboard" || !Number.isSafeInteger(owner.pid) || typeof owner.acquiredAt !== "string" || typeof owner.heartbeatAt !== "string")
|
|
24695
25963
|
return;
|
|
24696
25964
|
return owner;
|
|
@@ -24738,7 +26006,7 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
|
|
|
24738
26006
|
const stalePath = `${leasePath}.stale-${randomBytes8(8).toString("hex")}`;
|
|
24739
26007
|
try {
|
|
24740
26008
|
await rename3(leasePath, stalePath);
|
|
24741
|
-
await
|
|
26009
|
+
await rm10(stalePath, { recursive: true, force: true });
|
|
24742
26010
|
await mkdir10(leasePath, { recursive: false, mode: 448 });
|
|
24743
26011
|
} catch (reclaimError) {
|
|
24744
26012
|
throw new Error(`Another OR3 Cloud operation owns ${directory}. Refusing to race its lifecycle lock. ${reclaimError instanceof Error ? reclaimError.message : String(reclaimError)}`);
|
|
@@ -24749,17 +26017,17 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
|
|
|
24749
26017
|
}
|
|
24750
26018
|
}
|
|
24751
26019
|
try {
|
|
24752
|
-
await writeSecure(
|
|
26020
|
+
await writeSecure(join26(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
|
|
24753
26021
|
`);
|
|
24754
26022
|
} catch (error62) {
|
|
24755
|
-
await
|
|
26023
|
+
await rm10(leasePath, { recursive: true, force: true }).catch(() => {
|
|
24756
26024
|
return;
|
|
24757
26025
|
});
|
|
24758
26026
|
throw error62;
|
|
24759
26027
|
}
|
|
24760
26028
|
const heartbeat = setInterval(() => {
|
|
24761
26029
|
owner.heartbeatAt = now();
|
|
24762
|
-
writeSecure(
|
|
26030
|
+
writeSecure(join26(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
|
|
24763
26031
|
`).catch(() => {
|
|
24764
26032
|
return;
|
|
24765
26033
|
});
|
|
@@ -24769,7 +26037,7 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
|
|
|
24769
26037
|
clearInterval(heartbeat);
|
|
24770
26038
|
const current = await readLeaseOwner(leasePath);
|
|
24771
26039
|
if (current?.nonce === nonce)
|
|
24772
|
-
await
|
|
26040
|
+
await rm10(leasePath, { recursive: true, force: true });
|
|
24773
26041
|
};
|
|
24774
26042
|
}
|
|
24775
26043
|
async function withDeploymentLease(directory, command, action) {
|
|
@@ -24784,7 +26052,7 @@ async function withDeploymentLease(directory, command, action) {
|
|
|
24784
26052
|
}
|
|
24785
26053
|
async function assertRestartJournalSettled(directory) {
|
|
24786
26054
|
try {
|
|
24787
|
-
const journal = await readRestartJournal(
|
|
26055
|
+
const journal = await readRestartJournal(join26(directory, ".or3-cloud", "restarts", "restarts.json"));
|
|
24788
26056
|
if (journal.operations.some((operation) => !["succeeded", "cancelled", "failed"].includes(operation.phase))) {
|
|
24789
26057
|
throw new Error("A dashboard restart is unresolved. Run the compatible CLI recover --dry-run to inspect and recover --finish to reconcile it before another mutation.");
|
|
24790
26058
|
}
|
|
@@ -24798,8 +26066,19 @@ async function assertRestartJournalSettled(directory) {
|
|
|
24798
26066
|
}
|
|
24799
26067
|
|
|
24800
26068
|
// src/deployment/observation.ts
|
|
26069
|
+
function latestFullSnapshot(backups, observedAt) {
|
|
26070
|
+
const cutoff = Date.parse(observedAt);
|
|
26071
|
+
const candidates = (backups?.entries ?? []).filter((entry) => entry.kind === "verified").map((entry) => entry.backup).filter((backup2) => backup2.recoveryArtifactsComplete === true && backup2.snapshotKind === "full" && backup2.schemaVersion === 1 && Date.parse(backup2.createdAt) <= cutoff).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || a.backupId.localeCompare(b.backupId));
|
|
26072
|
+
const backup = candidates[0];
|
|
26073
|
+
return backup ? {
|
|
26074
|
+
backupId: backup.backupId,
|
|
26075
|
+
createdAt: backup.createdAt,
|
|
26076
|
+
schemaVersion: backup.schemaVersion,
|
|
26077
|
+
remoteKind: backup.remoteKind ?? "none"
|
|
26078
|
+
} : null;
|
|
26079
|
+
}
|
|
24801
26080
|
async function observeDeployment(directory, options = {}) {
|
|
24802
|
-
const resolved2 =
|
|
26081
|
+
const resolved2 = resolve14(directory);
|
|
24803
26082
|
const observedAt = now();
|
|
24804
26083
|
let state = null;
|
|
24805
26084
|
let stateError = null;
|
|
@@ -24926,6 +26205,7 @@ function publicStateProjection(state) {
|
|
|
24926
26205
|
dashboardJobId: pending.dashboardJobId ?? null,
|
|
24927
26206
|
targetVersion: pending.targetVersion ?? null,
|
|
24928
26207
|
targetImageDigest: pending.targetImageDigest ?? null,
|
|
26208
|
+
remoteReconcile: pending.remoteReconcile ?? null,
|
|
24929
26209
|
backupProgress: pending.backupProgress ? {
|
|
24930
26210
|
stage: pending.backupProgress.stage,
|
|
24931
26211
|
service: pending.backupProgress.service,
|
|
@@ -24955,7 +26235,7 @@ function observationFindings(observation) {
|
|
|
24955
26235
|
return findings;
|
|
24956
26236
|
}
|
|
24957
26237
|
function stateFingerprint(state) {
|
|
24958
|
-
return
|
|
26238
|
+
return createHash6("sha256").update(JSON.stringify({
|
|
24959
26239
|
schema: state.schemaVersion,
|
|
24960
26240
|
appVersion: state.appVersion,
|
|
24961
26241
|
image: state.image,
|
|
@@ -24987,6 +26267,9 @@ function decideRecoveryAction(state) {
|
|
|
24987
26267
|
}
|
|
24988
26268
|
return { action: "none", detail: "No incomplete operation is recorded." };
|
|
24989
26269
|
}
|
|
26270
|
+
if (pending.operation === "adopt") {
|
|
26271
|
+
return { action: "require-explicit-restore", detail: "The adoption target may contain newer writes. Replaying the source backup can discard them.", snapshotId: pending.backupId };
|
|
26272
|
+
}
|
|
24990
26273
|
if (pending.operation === "update" && pending.phase === "target-ready" && pending.evidence) {
|
|
24991
26274
|
return { action: "finish", detail: "A completed replacement carries durable target-ready evidence and can be committed without restoring data." };
|
|
24992
26275
|
}
|
|
@@ -25028,6 +26311,7 @@ async function reconcileOperatorHandoff(directory, state, report = console.log)
|
|
|
25028
26311
|
return detail;
|
|
25029
26312
|
}
|
|
25030
26313
|
async function collectTargetReadyChecks(directory, mode, env) {
|
|
26314
|
+
const profile = managedProviderProfile(env);
|
|
25031
26315
|
const checks3 = [];
|
|
25032
26316
|
let containerId;
|
|
25033
26317
|
const container = await run("docker", composeArgs(directory, mode, ["ps", "-q", "or3"]), directory);
|
|
@@ -25040,16 +26324,16 @@ async function collectTargetReadyChecks(directory, mode, env) {
|
|
|
25040
26324
|
if (databaseCheck.ok) {
|
|
25041
26325
|
try {
|
|
25042
26326
|
const databases = JSON.parse(databaseCheck.stdout.trim());
|
|
25043
|
-
databasesOk = databases.length === 2 && databases.every((entry) => entry.quickCheck === "ok" && entry.tables >= 1);
|
|
26327
|
+
databasesOk = databases.length === (profile === "basic-sqlite-fs" ? 2 : 1) && databases.every((entry) => entry.quickCheck === "ok" && entry.tables >= 1);
|
|
25044
26328
|
} catch {
|
|
25045
26329
|
databasesOk = false;
|
|
25046
26330
|
}
|
|
25047
26331
|
}
|
|
25048
|
-
checks3.push(databasesOk ? { code: "database-integrity", status: "passed", detail: "auth.sqlite and sync.sqlite quick_check passed with managed ownership
|
|
26332
|
+
checks3.push(databasesOk ? { code: "database-integrity", status: "passed", detail: `${profile === "basic-sqlite-fs" ? "auth.sqlite and sync.sqlite" : "sync.sqlite"} quick_check passed with managed ownership.` } : { code: "database-integrity", status: "failed", detail: `SQLite integrity or ownership verification failed. ${redact(databaseCheck.stderr.trim(), secretValues(env))}` });
|
|
25049
26333
|
if (mode === "public") {
|
|
25050
26334
|
const baseUrl = new URL(`https://${env.OR3_PUBLIC_DOMAIN}`);
|
|
25051
26335
|
try {
|
|
25052
|
-
validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"));
|
|
26336
|
+
validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"), profile);
|
|
25053
26337
|
checks3.push({ code: "public-health", status: "passed", detail: `${baseUrl.origin} deep health reports the managed profile.` });
|
|
25054
26338
|
} catch (error62) {
|
|
25055
26339
|
checks3.push({ code: "public-health", status: "failed", detail: redact(error62 instanceof Error ? error62.message : String(error62)) });
|
|
@@ -25247,6 +26531,8 @@ async function runRecovery(directory, context) {
|
|
|
25247
26531
|
const loaded = await loadManaged(directory);
|
|
25248
26532
|
const pending = loaded.state.incompleteOperation;
|
|
25249
26533
|
const decision = decideRecoveryAction(loaded.state);
|
|
26534
|
+
if (pending || decision.action === "reconcile-handoff")
|
|
26535
|
+
assertLegacyManagedProfile(loaded.env);
|
|
25250
26536
|
if (decision.action === "reconcile-handoff") {
|
|
25251
26537
|
if (explicitRestore)
|
|
25252
26538
|
throw new Error("This deployment completed its application update; only the dashboard operator handoff needs reconciliation. Run `npx @or3/cloud recover --finish` without --restore.");
|
|
@@ -25273,7 +26559,18 @@ async function runRecovery(directory, context) {
|
|
|
25273
26559
|
const detail = await finishTargetReadyUpdate(loaded.directory, loaded.state, loaded.env, report);
|
|
25274
26560
|
return { kind: "recovered", operation: "update", detail };
|
|
25275
26561
|
}
|
|
26562
|
+
const resumesExistingVault = pending.operation === "credentials-reset" || (pending.operation === "backup" ? pending.initialAppRunning !== false : ["update", "restore", "rollback"].includes(pending.operation) && (pending.phase === "prepared" || pending.phase === "snapshot-created"));
|
|
26563
|
+
if (resumesExistingVault && managedVaultEnabled(loaded.directory, loaded.env))
|
|
26564
|
+
await assertVaultVolumesExist(loaded.directory, loaded.state);
|
|
26565
|
+
if (resumesExistingVault || pending.operation === "init" || pending.operation === "adopt") {
|
|
26566
|
+
await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env);
|
|
26567
|
+
}
|
|
26568
|
+
if (pending.operation === "credentials-reset" && pending.credentialReset?.nextEnv)
|
|
26569
|
+
assertLegacyManagedProfile(pending.credentialReset.nextEnv);
|
|
25276
26570
|
const requiresExplicitRestore = decision.action === "require-explicit-restore";
|
|
26571
|
+
if (pending.operation === "adopt" && requiresExplicitRestore && !explicitRestore) {
|
|
26572
|
+
throw new Error("The adoption target may contain newer writes. Nothing was changed. Review recover --dry-run; recover --restore --yes explicitly discards target writes after the source backup.");
|
|
26573
|
+
}
|
|
25277
26574
|
try {
|
|
25278
26575
|
if (pending.operation === "backup") {
|
|
25279
26576
|
const failures = [];
|
|
@@ -25394,6 +26691,7 @@ async function runRecovery(directory, context) {
|
|
|
25394
26691
|
if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
|
|
25395
26692
|
throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
|
|
25396
26693
|
}
|
|
26694
|
+
await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
|
|
25397
26695
|
await stopProject(loaded.directory, loaded.state.mode);
|
|
25398
26696
|
await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
|
|
25399
26697
|
}
|
|
@@ -25418,84 +26716,29 @@ async function runRecovery(directory, context) {
|
|
|
25418
26716
|
} catch (error62) {
|
|
25419
26717
|
const recoverySecrets = pending.operation === "credentials-reset" ? secretValues(pending.credentialReset?.nextEnv ?? loaded.env) : secretValues(loaded.env);
|
|
25420
26718
|
loaded.state.lastError = redact(error62 instanceof Error ? error62.message : String(error62), recoverySecrets);
|
|
25421
|
-
if (pending.operation === "adopt" && pending.sourceDirectory) {
|
|
25422
|
-
try {
|
|
25423
|
-
await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
|
|
25424
|
-
return;
|
|
25425
|
-
});
|
|
25426
|
-
const sourceEnv = parseEnv(await readText(join24(pending.sourceDirectory, ".env")));
|
|
25427
|
-
const sourceFiles = ["-f", join24(pending.sourceDirectory, "compose.yaml")];
|
|
25428
|
-
const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
|
|
25429
|
-
if (sourceModeValue === "public")
|
|
25430
|
-
sourceFiles.push("-f", join24(pending.sourceDirectory, "compose.public.yaml"));
|
|
25431
|
-
if (pending.sourceInitiallyRunning !== false) {
|
|
25432
|
-
await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
|
|
25433
|
-
}
|
|
25434
|
-
} catch (recovery) {
|
|
25435
|
-
loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
25436
|
-
}
|
|
25437
|
-
}
|
|
25438
26719
|
await writeState(loaded.directory, loaded.state);
|
|
25439
26720
|
throw new Error(`Recovery could not safely complete ${pending.operation}. Keep the operation record and run doctor. ${loaded.state.lastError}`);
|
|
25440
26721
|
}
|
|
25441
26722
|
}
|
|
25442
26723
|
|
|
25443
26724
|
// src/commands/remove.ts
|
|
25444
|
-
import { rm as rm10 } from "node:fs/promises";
|
|
25445
|
-
import { join as join25 } from "node:path";
|
|
25446
|
-
function purgeVolumesFromState(state) {
|
|
25447
|
-
const volumes = [state.volumeName];
|
|
25448
|
-
if (state.mode === "public") {
|
|
25449
|
-
if (state.caddyDataVolume)
|
|
25450
|
-
volumes.push(state.caddyDataVolume);
|
|
25451
|
-
if (state.caddyConfigVolume)
|
|
25452
|
-
volumes.push(state.caddyConfigVolume);
|
|
25453
|
-
}
|
|
25454
|
-
return volumes;
|
|
25455
|
-
}
|
|
25456
26725
|
async function removeCommand(directory, flags) {
|
|
25457
26726
|
if (boolFlag(flags, "purge-data") && !boolFlag(flags, "yes")) {
|
|
25458
|
-
throw new Error("--purge-data
|
|
26727
|
+
throw new Error("--purge-data is disabled because reconstruction from an export is not yet supported. Preserve the deployment and backups.");
|
|
25459
26728
|
}
|
|
25460
26729
|
await ensureDocker();
|
|
25461
26730
|
const loaded = await loadManaged(directory);
|
|
25462
26731
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
25463
26732
|
assertNoPending(loaded.state);
|
|
25464
|
-
|
|
25465
|
-
|
|
25466
|
-
await compose(directory, state.mode, ["down"]);
|
|
25467
|
-
console.log(`Removed runtime containers and network. Data volume, backups, configuration, and managed state are retained at ${directory}. Re-run \`npx @or3/cloud start\` to restore service.`);
|
|
25468
|
-
return;
|
|
26733
|
+
if (boolFlag(flags, "purge-data")) {
|
|
26734
|
+
throw new Error("Purge is disabled because reconstruction from an export is not yet supported. Preserve the deployment and backups.");
|
|
25469
26735
|
}
|
|
25470
|
-
|
|
25471
|
-
|
|
25472
|
-
const managedFiles = [
|
|
25473
|
-
".env",
|
|
25474
|
-
".or3-cloud",
|
|
25475
|
-
...managedAssetNames(state.mode),
|
|
25476
|
-
".or3-initial-credentials"
|
|
25477
|
-
];
|
|
25478
|
-
console.log("Removing exactly these targets:");
|
|
25479
|
-
for (const volume of volumes)
|
|
25480
|
-
console.log(` docker volume ${volume}`);
|
|
25481
|
-
for (const file2 of managedFiles)
|
|
25482
|
-
console.log(` ${join25(directory, file2)}`);
|
|
25483
|
-
await compose(directory, state.mode, ["down"]);
|
|
25484
|
-
await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
|
|
25485
|
-
for (const volume of volumes) {
|
|
25486
|
-
const result = await run("docker", ["volume", "rm", volume], directory);
|
|
25487
|
-
if (!result.ok)
|
|
25488
|
-
throw new Error(`${result.command}
|
|
25489
|
-
${result.stderr}`);
|
|
25490
|
-
}
|
|
25491
|
-
for (const file2 of managedFiles) {
|
|
25492
|
-
await rm10(join25(directory, file2), { recursive: true, force: true });
|
|
25493
|
-
}
|
|
25494
|
-
console.log(`Purged: data volumes, backups, .env, managed state, and compose files were deleted. The verified export at ${exportReceipt.destination} is the remaining copy.`);
|
|
26736
|
+
await compose(directory, loaded.state.mode, ["down"]);
|
|
26737
|
+
console.log(`Removed runtime containers and network. Data volume, backups, configuration, and managed state are retained at ${directory}. Re-run \`npx @or3/cloud start\` to restore service.`);
|
|
25495
26738
|
}
|
|
25496
26739
|
|
|
25497
26740
|
// src/commands/restore.ts
|
|
25498
|
-
import { join as
|
|
26741
|
+
import { join as join27 } from "node:path";
|
|
25499
26742
|
async function restoreCommand(directory, flags, positionals) {
|
|
25500
26743
|
if (!boolFlag(flags, "yes"))
|
|
25501
26744
|
throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
|
|
@@ -25508,18 +26751,26 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
25508
26751
|
const backupPath = await resolveBackup(loaded.directory, backupValue);
|
|
25509
26752
|
const manifest2 = await readManifest(backupPath, loaded.directory);
|
|
25510
26753
|
assertRestorableManagedAssets(manifest2);
|
|
25511
|
-
const backupEnv = parseEnv(await readText(
|
|
26754
|
+
const backupEnv = parseEnv(await readText(join27(backupPath, "config.env")));
|
|
25512
26755
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
25513
26756
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
25514
26757
|
}
|
|
25515
26758
|
assertBackupMatchesDeployment(manifest2, backupEnv, loaded.state, loaded.env);
|
|
26759
|
+
assertSupportedBackupProfile(loaded.env);
|
|
26760
|
+
await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
|
|
25516
26761
|
const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
|
|
25517
26762
|
if (manifest2.imageDigest)
|
|
25518
26763
|
await pullAndRequireImage(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
|
|
25519
26764
|
await assertSupportedHostArchitecture(manifest2.image);
|
|
26765
|
+
await assertExistingVaultCustody(loaded.directory, loaded.state, manifest2, backupPath, backupEnv);
|
|
25520
26766
|
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
|
|
25521
26767
|
await validateVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
|
|
25522
|
-
await
|
|
26768
|
+
const remotePreflight = manifest2.schemaVersion === 2 ? await preflightRemoteRestore(loaded.directory, manifest2, loaded.env, backupPath) : undefined;
|
|
26769
|
+
if (manifest2.schemaVersion === 1)
|
|
26770
|
+
await validateCanonicalBackupReferences(loaded.directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
|
|
26771
|
+
const remoteReconcile = remotePreflight && "uploaded" in remotePreflight ? remotePreflight : undefined;
|
|
26772
|
+
if (remoteReconcile)
|
|
26773
|
+
console.log(`S3 reconciliation added ${remoteReconcile.uploaded} objects and ${remoteReconcile.markersCreated} markers; added objects remain if restore fails.`);
|
|
25523
26774
|
const pending = {
|
|
25524
26775
|
id: id("restore"),
|
|
25525
26776
|
operation: "restore",
|
|
@@ -25533,6 +26784,7 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
25533
26784
|
targetImage: manifest2.image,
|
|
25534
26785
|
targetImageDigest: manifest2.imageDigest,
|
|
25535
26786
|
recreateDataVolume,
|
|
26787
|
+
remoteReconcile,
|
|
25536
26788
|
phase: "prepared"
|
|
25537
26789
|
};
|
|
25538
26790
|
await markPending(loaded.directory, loaded.state, pending);
|
|
@@ -25584,6 +26836,8 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
25584
26836
|
throw new Error(recoveredMessage);
|
|
25585
26837
|
}
|
|
25586
26838
|
try {
|
|
26839
|
+
if (managedVaultEnabled(loaded.directory, loaded.env))
|
|
26840
|
+
await assertVaultVolumesExist(loaded.directory, loaded.state);
|
|
25587
26841
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
25588
26842
|
loaded.state.lastError = `Restore of ${manifest2.backupId} stopped before data replacement: ${original}`;
|
|
25589
26843
|
await clearPending(loaded.directory, loaded.state);
|
|
@@ -25598,7 +26852,7 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
25598
26852
|
}
|
|
25599
26853
|
|
|
25600
26854
|
// src/commands/rollback.ts
|
|
25601
|
-
import { join as
|
|
26855
|
+
import { join as join28 } from "node:path";
|
|
25602
26856
|
async function rollbackCommand(directory, flags) {
|
|
25603
26857
|
if (!boolFlag(flags, "yes"))
|
|
25604
26858
|
throw new Error("Rollback restores the previous image and data snapshot. Re-run with --yes after confirming post-update data loss.");
|
|
@@ -25614,16 +26868,23 @@ async function rollbackCommand(directory, flags) {
|
|
|
25614
26868
|
if (manifest2.appVersion !== point.appVersion || manifest2.image !== point.image || manifest2.imageDigest !== point.imageDigest) {
|
|
25615
26869
|
throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
|
|
25616
26870
|
}
|
|
25617
|
-
const backupEnv = parseEnv(await readText(
|
|
26871
|
+
const backupEnv = parseEnv(await readText(join28(backupPath, "config.env")));
|
|
25618
26872
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
25619
26873
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
25620
26874
|
}
|
|
25621
26875
|
assertBackupMatchesDeployment(manifest2, backupEnv, loaded.state, loaded.env);
|
|
26876
|
+
assertSupportedBackupProfile(loaded.env);
|
|
26877
|
+
await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
|
|
25622
26878
|
const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
|
|
25623
26879
|
await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
|
|
25624
26880
|
await assertSupportedHostArchitecture(point.image);
|
|
26881
|
+
await assertExistingVaultCustody(loaded.directory, loaded.state, manifest2, backupPath, backupEnv);
|
|
25625
26882
|
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
|
|
25626
26883
|
await validateVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
|
|
26884
|
+
const remotePreflight = manifest2.schemaVersion === 2 ? await preflightRemoteRestore(loaded.directory, manifest2, loaded.env, backupPath) : undefined;
|
|
26885
|
+
const remoteReconcile = remotePreflight && "uploaded" in remotePreflight ? remotePreflight : undefined;
|
|
26886
|
+
if (remoteReconcile)
|
|
26887
|
+
console.log(`S3 reconciliation added ${remoteReconcile.uploaded} objects and ${remoteReconcile.markersCreated} markers; added objects remain if rollback fails.`);
|
|
25627
26888
|
const pending = {
|
|
25628
26889
|
id: id("rollback"),
|
|
25629
26890
|
operation: "rollback",
|
|
@@ -25637,6 +26898,7 @@ async function rollbackCommand(directory, flags) {
|
|
|
25637
26898
|
targetImage: point.image,
|
|
25638
26899
|
targetImageDigest: point.imageDigest,
|
|
25639
26900
|
recreateDataVolume,
|
|
26901
|
+
remoteReconcile,
|
|
25640
26902
|
phase: "prepared"
|
|
25641
26903
|
};
|
|
25642
26904
|
await markPending(loaded.directory, loaded.state, pending);
|
|
@@ -25688,6 +26950,8 @@ async function rollbackCommand(directory, flags) {
|
|
|
25688
26950
|
throw new Error(recoveredMessage);
|
|
25689
26951
|
}
|
|
25690
26952
|
try {
|
|
26953
|
+
if (managedVaultEnabled(loaded.directory, loaded.env))
|
|
26954
|
+
await assertVaultVolumesExist(loaded.directory, loaded.state);
|
|
25691
26955
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
25692
26956
|
loaded.state.lastError = `Rollback to ${point.appVersion} stopped before data replacement: ${original}`;
|
|
25693
26957
|
await clearPending(loaded.directory, loaded.state);
|
|
@@ -25725,6 +26989,7 @@ async function statusCommand(directory, flags = {}) {
|
|
|
25725
26989
|
identityMatches: observation.identityMatches,
|
|
25726
26990
|
lastReceipt: state?.lastReceipt ?? null,
|
|
25727
26991
|
backups: observation.backups,
|
|
26992
|
+
recoveryPoint: latestFullSnapshot(observation.backups, observation.observedAt),
|
|
25728
26993
|
findings
|
|
25729
26994
|
}, null, 2));
|
|
25730
26995
|
return;
|
|
@@ -25824,8 +27089,8 @@ ${result.stderr}`);
|
|
|
25824
27089
|
}
|
|
25825
27090
|
|
|
25826
27091
|
// src/commands/update.ts
|
|
25827
|
-
import { readFile as
|
|
25828
|
-
import { join as
|
|
27092
|
+
import { readFile as readFile6, statfs as statfs2 } from "node:fs/promises";
|
|
27093
|
+
import { join as join29, resolve as resolve15 } from "node:path";
|
|
25829
27094
|
async function assessUpdate(directory, targetVersion, options = {}) {
|
|
25830
27095
|
const checkDocker = options.checkDocker ?? false;
|
|
25831
27096
|
const observation = await observeDeployment(directory, { checkDocker, checkImage: false });
|
|
@@ -25890,7 +27155,7 @@ async function assessUpdate(directory, targetVersion, options = {}) {
|
|
|
25890
27155
|
for (const name of managedAssetNames(state?.mode ?? "local")) {
|
|
25891
27156
|
let desired;
|
|
25892
27157
|
try {
|
|
25893
|
-
desired = await
|
|
27158
|
+
desired = await readFile6(join29(ASSET_ROOT, name));
|
|
25894
27159
|
} catch (error62) {
|
|
25895
27160
|
findings.push({
|
|
25896
27161
|
code: "managed-asset-unreadable",
|
|
@@ -25901,12 +27166,12 @@ async function assessUpdate(directory, targetVersion, options = {}) {
|
|
|
25901
27166
|
continue;
|
|
25902
27167
|
}
|
|
25903
27168
|
try {
|
|
25904
|
-
const installedPath =
|
|
27169
|
+
const installedPath = join29(directory, name);
|
|
25905
27170
|
if (!await fileExists(installedPath)) {
|
|
25906
27171
|
changed.push(`${name} (add)`);
|
|
25907
27172
|
continue;
|
|
25908
27173
|
}
|
|
25909
|
-
const installed = await
|
|
27174
|
+
const installed = await readFile6(installedPath);
|
|
25910
27175
|
if (!installed.equals(desired))
|
|
25911
27176
|
changed.push(`${name} (update)`);
|
|
25912
27177
|
} catch (error62) {
|
|
@@ -26013,6 +27278,8 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
26013
27278
|
progress(`OR3 ${targetVersion} is already installed. Nothing to do.`);
|
|
26014
27279
|
return;
|
|
26015
27280
|
}
|
|
27281
|
+
assertSupportedBackupProfile(env);
|
|
27282
|
+
await assertBackupSourceProfile(loaded.directory, state, env);
|
|
26016
27283
|
const preflight = await assessUpdate(loaded.directory, targetVersion, { underLease: true, checkDocker: true });
|
|
26017
27284
|
const preflightBlockers = preflight.findings.filter((finding) => finding.severity === "blocker");
|
|
26018
27285
|
if (preflightBlockers.length > 0) {
|
|
@@ -26114,7 +27381,8 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
26114
27381
|
}
|
|
26115
27382
|
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, {
|
|
26116
27383
|
recreateDataVolume,
|
|
26117
|
-
previousSafetySnapshot: true
|
|
27384
|
+
previousSafetySnapshot: true,
|
|
27385
|
+
remoteReferencePolicy: "warn"
|
|
26118
27386
|
});
|
|
26119
27387
|
} catch (restoreError) {
|
|
26120
27388
|
throw new Error(`Update to ${targetVersion} failed, and automatic backup restoration also failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Original update error: ${error62 instanceof Error ? error62.message : String(error62)}`);
|
|
@@ -26137,7 +27405,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
26137
27405
|
const targetReadyEvidence = {
|
|
26138
27406
|
checkedAt: now(),
|
|
26139
27407
|
deploymentId: targetDeploymentId,
|
|
26140
|
-
deploymentRoot:
|
|
27408
|
+
deploymentRoot: resolve15(loaded.directory),
|
|
26141
27409
|
containerId: targetChecks.containerId,
|
|
26142
27410
|
imageDigest: digest,
|
|
26143
27411
|
configurationSha256: await sha256File(deploymentPaths(loaded.directory).env),
|
|
@@ -26211,7 +27479,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
26211
27479
|
state.image = targetImage;
|
|
26212
27480
|
state.imageDigest = digest;
|
|
26213
27481
|
state.deploymentId = targetDeploymentId;
|
|
26214
|
-
state.deploymentRoot =
|
|
27482
|
+
state.deploymentRoot = resolve15(loaded.directory);
|
|
26215
27483
|
state.lastSuccessfulOperation = "update";
|
|
26216
27484
|
state.lastError = undefined;
|
|
26217
27485
|
let commit;
|
|
@@ -26257,7 +27525,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
26257
27525
|
}
|
|
26258
27526
|
|
|
26259
27527
|
// src/commands/verify.ts
|
|
26260
|
-
import { join as
|
|
27528
|
+
import { join as join30, resolve as resolve16 } from "node:path";
|
|
26261
27529
|
async function verificationCredentials(directory, flags, env) {
|
|
26262
27530
|
const passwordFile = stringFlag(flags, "verification-password-file");
|
|
26263
27531
|
if (flags["verification-email"] !== undefined && !stringFlag(flags, "verification-email")) {
|
|
@@ -26269,7 +27537,7 @@ async function verificationCredentials(directory, flags, env) {
|
|
|
26269
27537
|
const configuredEmail = stringFlag(flags, "verification-email")?.trim() || env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
26270
27538
|
let initialCredentials = {};
|
|
26271
27539
|
if (!configuredEmail || !passwordFile && !env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD) {
|
|
26272
|
-
const initialCredentialsPath =
|
|
27540
|
+
const initialCredentialsPath = join30(directory, ".or3-initial-credentials");
|
|
26273
27541
|
if (await fileExists(initialCredentialsPath)) {
|
|
26274
27542
|
initialCredentials = parseEnv(await readOwnerOnlyText(initialCredentialsPath, "The initial credentials file"));
|
|
26275
27543
|
}
|
|
@@ -26278,7 +27546,7 @@ async function verificationCredentials(directory, flags, env) {
|
|
|
26278
27546
|
if (!email3)
|
|
26279
27547
|
throw new Error("A current owner email is required for verification.");
|
|
26280
27548
|
validateEmail(email3);
|
|
26281
|
-
const password = passwordFile ? (await readOwnerOnlyText(
|
|
27549
|
+
const password = passwordFile ? (await readOwnerOnlyText(resolve16(passwordFile), "The verification password file")).trim() : env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD || initialCredentials.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
26282
27550
|
if (!password) {
|
|
26283
27551
|
throw new Error("A current owner password is required. Pass --verification-password-file after changing the bootstrap password.");
|
|
26284
27552
|
}
|
|
@@ -26389,11 +27657,134 @@ async function verifyReadOnlyCommand(directory, flags) {
|
|
|
26389
27657
|
}
|
|
26390
27658
|
}
|
|
26391
27659
|
|
|
27660
|
+
// src/commands/vault-key.ts
|
|
27661
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
27662
|
+
import { createHash as createHash7, randomUUID as randomUUID6 } from "node:crypto";
|
|
27663
|
+
import { constants as constants2 } from "node:fs";
|
|
27664
|
+
import { open as open5, realpath as realpath3, stat as stat6 } from "node:fs/promises";
|
|
27665
|
+
import { basename as basename7, dirname as dirname8, isAbsolute as isAbsolute6, join as join31, sep as sep2 } from "node:path";
|
|
27666
|
+
async function vaultKeyCommand(directory, positionals, flags) {
|
|
27667
|
+
if (positionals.length !== 1 || positionals[0] !== "export")
|
|
27668
|
+
throw new Error("Use vault-key export --out <absolute-path>.");
|
|
27669
|
+
const destination = requireStringFlag(flags, "out");
|
|
27670
|
+
if (!isAbsolute6(destination))
|
|
27671
|
+
throw new Error("Vault key destination must be an absolute path.");
|
|
27672
|
+
const leaf = destination.split(sep2).at(-1);
|
|
27673
|
+
if (!leaf || leaf === "." || leaf === "..")
|
|
27674
|
+
throw new Error("Vault key destination must name a new file.");
|
|
27675
|
+
const parent = await realpath3(dirname8(destination));
|
|
27676
|
+
const parentInfo = await stat6(parent);
|
|
27677
|
+
if (!parentInfo.isDirectory() || (parentInfo.mode & 511) !== 448 || typeof process.getuid === "function" && parentInfo.uid !== process.getuid()) {
|
|
27678
|
+
throw new Error("Vault key destination parent must be an owner-only directory (0700).");
|
|
27679
|
+
}
|
|
27680
|
+
const target = join31(parent, basename7(destination));
|
|
27681
|
+
const deployment = await realpath3(directory);
|
|
27682
|
+
if (target === deployment || target.startsWith(`${deployment}${sep2}`))
|
|
27683
|
+
throw new Error("Vault key destination must be outside the managed deployment directory.");
|
|
27684
|
+
const file2 = await open5(target, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
|
|
27685
|
+
let written = false;
|
|
27686
|
+
const parts = [];
|
|
27687
|
+
try {
|
|
27688
|
+
await ensureDocker();
|
|
27689
|
+
const loaded = await loadManaged(directory);
|
|
27690
|
+
assertNoPending(loaded.state);
|
|
27691
|
+
await requireImageDigest(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
27692
|
+
const volume = `${loaded.state.volumeName}-vault-key`;
|
|
27693
|
+
if (!(await run("docker", ["volume", "inspect", volume], directory)).ok)
|
|
27694
|
+
throw new Error("Vault key custody is missing or cannot be inspected.");
|
|
27695
|
+
const script = `const fs=require('node:fs');const p='/vault-key/master.key';const s=fs.lstatSync(p);if(!s.isFile()||s.isSymbolicLink()||s.uid!==1000||(s.mode&0o777)!==0o600||s.nlink!==1||s.size!==32)process.exit(1);process.stdout.write(fs.readFileSync(p));`;
|
|
27696
|
+
const helperName = `or3-vault-key-export-${randomUUID6()}`;
|
|
27697
|
+
const child = spawn2("docker", [
|
|
27698
|
+
"run",
|
|
27699
|
+
"--rm",
|
|
27700
|
+
"--name",
|
|
27701
|
+
helperName,
|
|
27702
|
+
"--log-driver",
|
|
27703
|
+
"none",
|
|
27704
|
+
"--pull",
|
|
27705
|
+
"never",
|
|
27706
|
+
"--network",
|
|
27707
|
+
"none",
|
|
27708
|
+
"--read-only",
|
|
27709
|
+
"--user",
|
|
27710
|
+
"1000:65532",
|
|
27711
|
+
"--security-opt",
|
|
27712
|
+
"no-new-privileges:true",
|
|
27713
|
+
"--cap-drop",
|
|
27714
|
+
"ALL",
|
|
27715
|
+
"--mount",
|
|
27716
|
+
`type=volume,src=${volume},dst=/vault-key,readonly`,
|
|
27717
|
+
"--entrypoint",
|
|
27718
|
+
"/nodejs/bin/node",
|
|
27719
|
+
imageAtDigest(loaded.state.image, loaded.state.imageDigest),
|
|
27720
|
+
"-e",
|
|
27721
|
+
script
|
|
27722
|
+
], { cwd: directory, stdio: ["ignore", "pipe", "ignore"] });
|
|
27723
|
+
const timeout = setTimeout(() => child.kill("SIGKILL"), 120000);
|
|
27724
|
+
const exited = new Promise((resolve17, reject) => {
|
|
27725
|
+
child.once("error", reject);
|
|
27726
|
+
child.once("close", resolve17);
|
|
27727
|
+
});
|
|
27728
|
+
let bytes = 0;
|
|
27729
|
+
let helperFailure;
|
|
27730
|
+
try {
|
|
27731
|
+
for await (const chunk of child.stdout) {
|
|
27732
|
+
const buffer = Buffer.from(chunk);
|
|
27733
|
+
parts.push(buffer);
|
|
27734
|
+
bytes += buffer.length;
|
|
27735
|
+
if (bytes > 32) {
|
|
27736
|
+
child.kill("SIGKILL");
|
|
27737
|
+
throw new Error("Vault key export returned an invalid key.");
|
|
27738
|
+
}
|
|
27739
|
+
}
|
|
27740
|
+
if (await exited !== 0 || bytes !== 32)
|
|
27741
|
+
throw new Error("Vault key export could not read valid custody.");
|
|
27742
|
+
} catch (error62) {
|
|
27743
|
+
helperFailure = error62;
|
|
27744
|
+
} finally {
|
|
27745
|
+
clearTimeout(timeout);
|
|
27746
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
27747
|
+
child.kill("SIGKILL");
|
|
27748
|
+
await exited.catch(() => null);
|
|
27749
|
+
const cleanup = await run("docker", ["rm", "--force", helperName], directory);
|
|
27750
|
+
if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
|
|
27751
|
+
throw new AggregateError(helperFailure ? [helperFailure] : [], "Vault key helper cleanup failed; inspect the named helper before retrying.");
|
|
27752
|
+
}
|
|
27753
|
+
}
|
|
27754
|
+
if (helperFailure)
|
|
27755
|
+
throw helperFailure;
|
|
27756
|
+
const key = Buffer.concat(parts, bytes);
|
|
27757
|
+
try {
|
|
27758
|
+
await file2.writeFile(key);
|
|
27759
|
+
await file2.chmod(384);
|
|
27760
|
+
await file2.sync();
|
|
27761
|
+
const parentHandle = await open5(parent, "r");
|
|
27762
|
+
try {
|
|
27763
|
+
await parentHandle.sync();
|
|
27764
|
+
} finally {
|
|
27765
|
+
await parentHandle.close();
|
|
27766
|
+
}
|
|
27767
|
+
written = true;
|
|
27768
|
+
console.log(`Exported vault master key to ${target} (SHA-256 ${createHash7("sha256").update(key).digest("hex")}). Protect it separately from the backup.`);
|
|
27769
|
+
} finally {
|
|
27770
|
+
key.fill(0);
|
|
27771
|
+
}
|
|
27772
|
+
} finally {
|
|
27773
|
+
for (const part of parts)
|
|
27774
|
+
part.fill(0);
|
|
27775
|
+
await file2.close();
|
|
27776
|
+
if (!written) {
|
|
27777
|
+
console.error(`Vault key export did not complete; inspect the owner-only destination ${target} before retrying.`);
|
|
27778
|
+
}
|
|
27779
|
+
}
|
|
27780
|
+
}
|
|
27781
|
+
|
|
26392
27782
|
// src/cli/dispatch.ts
|
|
26393
27783
|
var MUTATING_COMMANDS = new Set([
|
|
26394
27784
|
"init",
|
|
26395
27785
|
"update",
|
|
26396
27786
|
"backup",
|
|
27787
|
+
"vault-key",
|
|
26397
27788
|
"verify",
|
|
26398
27789
|
"restore",
|
|
26399
27790
|
"rollback",
|
|
@@ -26407,10 +27798,10 @@ var MUTATING_COMMANDS = new Set([
|
|
|
26407
27798
|
]);
|
|
26408
27799
|
function mutationDirectory(command, positionals, flags) {
|
|
26409
27800
|
if (command === "init")
|
|
26410
|
-
return
|
|
27801
|
+
return resolve17(process.cwd(), positionals[0] ?? "or3-cloud");
|
|
26411
27802
|
if (command === "adopt") {
|
|
26412
27803
|
const sourceDirectory = requireStringFlag(flags, "from");
|
|
26413
|
-
return
|
|
27804
|
+
return resolve17(process.cwd(), positionals[0] ?? `${basename8(sourceDirectory)}-managed`);
|
|
26414
27805
|
}
|
|
26415
27806
|
return process.cwd();
|
|
26416
27807
|
}
|
|
@@ -26428,6 +27819,8 @@ async function dispatchCommand(command, parsed) {
|
|
|
26428
27819
|
}
|
|
26429
27820
|
if (command === "backup")
|
|
26430
27821
|
return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
|
|
27822
|
+
if (command === "vault-key")
|
|
27823
|
+
return await vaultKeyCommand(process.cwd(), parsed.positionals, parsed.flags);
|
|
26431
27824
|
if (command === "restore")
|
|
26432
27825
|
return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
26433
27826
|
if (command === "rollback")
|
|
@@ -26469,6 +27862,7 @@ Usage:
|
|
|
26469
27862
|
npx @or3/cloud init [directory] --public --domain <hostname>
|
|
26470
27863
|
npx @or3/cloud update [--to <exact-version>] [--dry-run] [--json]
|
|
26471
27864
|
npx @or3/cloud backup [list [--json]|prune [--keep <n>]|export <backup-id> <destination-dir>]
|
|
27865
|
+
npx @or3/cloud vault-key export --out <absolute-path>
|
|
26472
27866
|
npx @or3/cloud restore <backup-id-or-path> --yes
|
|
26473
27867
|
npx @or3/cloud rollback --yes
|
|
26474
27868
|
npx @or3/cloud credentials reset --yes [--owner-password-file <path> --admin-password-file <path>]
|
|
@@ -26491,6 +27885,7 @@ Options:
|
|
|
26491
27885
|
--verification-password-file <path> Read the current owner password for this verification
|
|
26492
27886
|
--port <port> Local OR3 port (default: 3000)
|
|
26493
27887
|
--keep <n> Backups to retain when pruning (default: 5)
|
|
27888
|
+
--out <absolute-path> New owner-only vault-key export destination
|
|
26494
27889
|
--force Bypass suspect-entry deferral; protected recovery backups remain protected
|
|
26495
27890
|
--tail <n> Log lines to show (default: 200)
|
|
26496
27891
|
--public Require verification through the public HTTPS origin
|
|
@@ -26499,7 +27894,7 @@ Options:
|
|
|
26499
27894
|
--finish Commit a proven completed replacement without restoring data
|
|
26500
27895
|
--restore Explicitly restore the recorded snapshot (requires --yes)
|
|
26501
27896
|
--json Emit one machine-readable result object on stdout
|
|
26502
|
-
--purge-data
|
|
27897
|
+
--purge-data Currently disabled pending export reconstruction support
|
|
26503
27898
|
--yes Confirm a destructive restore, rollback, credentials reset, or purge
|
|
26504
27899
|
--help Show this help
|
|
26505
27900
|
--version Show the Cloud package version
|
|
@@ -26510,11 +27905,11 @@ The installer never changes firewall, DNS, Cloudflare, or Tailscale settings.`);
|
|
|
26510
27905
|
|
|
26511
27906
|
// src/commands/recover-restart.ts
|
|
26512
27907
|
import { request } from "node:http";
|
|
26513
|
-
import { join as
|
|
27908
|
+
import { join as join32 } from "node:path";
|
|
26514
27909
|
async function recoverRestart(directory, flags) {
|
|
26515
27910
|
let journal;
|
|
26516
27911
|
try {
|
|
26517
|
-
journal = await readRestartJournal(
|
|
27912
|
+
journal = await readRestartJournal(join32(directory, ".or3-cloud", "restarts", "restarts.json"));
|
|
26518
27913
|
} catch (error62) {
|
|
26519
27914
|
if (error62.code === "ENOENT")
|
|
26520
27915
|
return false;
|
|
@@ -26537,9 +27932,9 @@ async function recoverRestart(directory, flags) {
|
|
|
26537
27932
|
if (!boolFlag(flags, "finish") || boolFlag(flags, "restore"))
|
|
26538
27933
|
throw new Error("The restart is unresolved. Inspect recover --dry-run, then use recover --finish to reconcile it. Data restoration is not a restart recovery action.");
|
|
26539
27934
|
const value = JSON.stringify({ requestId: operation.requestId });
|
|
26540
|
-
await new Promise((
|
|
27935
|
+
await new Promise((resolve18, reject) => {
|
|
26541
27936
|
const req = request({
|
|
26542
|
-
socketPath:
|
|
27937
|
+
socketPath: join32(deploymentPaths(directory).operatorIpc, "operator.sock"),
|
|
26543
27938
|
path: "/lifecycle/v1/reconcile",
|
|
26544
27939
|
method: "POST",
|
|
26545
27940
|
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(value) }
|
|
@@ -26554,7 +27949,7 @@ async function recoverRestart(directory, flags) {
|
|
|
26554
27949
|
response.on("end", () => {
|
|
26555
27950
|
clearTimeout(timer);
|
|
26556
27951
|
if (response.statusCode === 200)
|
|
26557
|
-
|
|
27952
|
+
resolve18();
|
|
26558
27953
|
else
|
|
26559
27954
|
reject(new Error("Restart recovery is unverified. Keep the receipt and inspect app/operator diagnostics; no additional restart was executed."));
|
|
26560
27955
|
});
|
|
@@ -26605,7 +28000,7 @@ async function assertDeploymentFolder(command, directory = process.cwd()) {
|
|
|
26605
28000
|
return;
|
|
26606
28001
|
throw new Error("No OR3 Cloud deployment here. Run this from the folder that init created (usually ./or3-cloud).");
|
|
26607
28002
|
}
|
|
26608
|
-
var invokedAsCli = process.argv[1] ? ["or3", "cli.mjs"].includes(
|
|
26609
|
-
if (invokedAsCli || process.argv[1] &&
|
|
28003
|
+
var invokedAsCli = process.argv[1] ? ["or3", "cli.mjs"].includes(basename9(process.argv[1])) : false;
|
|
28004
|
+
if (invokedAsCli || process.argv[1] && resolve18(process.argv[1]) === resolve18(fileURLToPath2(import.meta.url))) {
|
|
26610
28005
|
await main();
|
|
26611
28006
|
}
|