@or3/cloud 0.1.106 → 0.1.112
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 +27 -2
- package/assets/compose.yaml +2 -2
- package/assets/dashboard-lifecycle.mjs +182 -5
- package/assets/dashboard-operator.mjs +17 -0
- package/dist/cli.mjs +1997 -403
- 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.112";
|
|
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",
|
|
@@ -402,6 +560,8 @@ var ALLOWED_ENV_KEYS = new Set([
|
|
|
402
560
|
"OR3_CLOUD_STORAGE_ENABLED",
|
|
403
561
|
"NUXT_PUBLIC_STORAGE_PROVIDER",
|
|
404
562
|
"OR3_STORAGE_FS_ROOT",
|
|
563
|
+
"OR3_STORAGE_FS_CLEANUP_NAMESPACE_ID",
|
|
564
|
+
"OR3_STORAGE_RETENTION_WORKSPACES",
|
|
405
565
|
"OR3_STORAGE_FS_TOKEN_SECRET",
|
|
406
566
|
"OR3_STORAGE_FS_URL_TTL_SECONDS",
|
|
407
567
|
"OR3_ADMIN_USERNAME",
|
|
@@ -548,7 +708,9 @@ function buildEnv(input) {
|
|
|
548
708
|
return values;
|
|
549
709
|
}
|
|
550
710
|
function secretValues(env) {
|
|
551
|
-
|
|
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);
|
|
552
714
|
}
|
|
553
715
|
|
|
554
716
|
// src/deployment/state-store.ts
|
|
@@ -842,6 +1004,12 @@ import { dirname as dirname2, join as join5 } from "node:path";
|
|
|
842
1004
|
var execFile = promisify(execFileCallback);
|
|
843
1005
|
var COMMAND_TIMEOUT_MS = 180000;
|
|
844
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
|
+
}
|
|
845
1013
|
var PROCESS_ENV_PASSTHROUGH = [
|
|
846
1014
|
"PATH",
|
|
847
1015
|
"HOME",
|
|
@@ -938,7 +1106,8 @@ function terminateChildProcess(child) {
|
|
|
938
1106
|
}
|
|
939
1107
|
child.kill("SIGKILL");
|
|
940
1108
|
}
|
|
941
|
-
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);
|
|
942
1111
|
if (await fileExists(destination)) {
|
|
943
1112
|
throw new Error(`Refusing to overwrite existing archive ${destination}.`);
|
|
944
1113
|
}
|
|
@@ -946,7 +1115,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
946
1115
|
const temporary = `${destination}.${randomBytes3(8).toString("hex")}.partial`;
|
|
947
1116
|
const child = spawn(command, args, {
|
|
948
1117
|
cwd,
|
|
949
|
-
env: commandEnvironment(command, args, cwd),
|
|
1118
|
+
env: commandEnvironment(command, args, cwd, environment),
|
|
950
1119
|
detached: process.platform !== "win32",
|
|
951
1120
|
stdio: ["ignore", "pipe", "pipe"]
|
|
952
1121
|
});
|
|
@@ -956,7 +1125,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
956
1125
|
const timeout = setTimeout(() => {
|
|
957
1126
|
timedOut = true;
|
|
958
1127
|
terminateChildProcess(child);
|
|
959
|
-
},
|
|
1128
|
+
}, timeoutMs);
|
|
960
1129
|
child.stderr.setEncoding("utf8");
|
|
961
1130
|
child.stderr.on("data", (chunk) => {
|
|
962
1131
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
@@ -972,7 +1141,7 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
972
1141
|
transfer
|
|
973
1142
|
]);
|
|
974
1143
|
if (hasTimedOut())
|
|
975
|
-
throw new Error(`${command} exceeded the ${
|
|
1144
|
+
throw new Error(`${command} exceeded the ${timeoutMs / 1000}-second archive deadline.`);
|
|
976
1145
|
if (exitCode !== 0) {
|
|
977
1146
|
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
978
1147
|
}
|
|
@@ -996,10 +1165,11 @@ Temporary archive cleanup failed: ${redact(cleanupError instanceof Error ? clean
|
|
|
996
1165
|
clearTimeout(timeout);
|
|
997
1166
|
}
|
|
998
1167
|
}
|
|
999
|
-
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);
|
|
1000
1170
|
const child = spawn(command, args, {
|
|
1001
1171
|
cwd,
|
|
1002
|
-
env: commandEnvironment(command, args, cwd),
|
|
1172
|
+
env: commandEnvironment(command, args, cwd, environment),
|
|
1003
1173
|
detached: process.platform !== "win32",
|
|
1004
1174
|
stdio: ["pipe", "ignore", "pipe"]
|
|
1005
1175
|
});
|
|
@@ -1009,7 +1179,7 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
|
1009
1179
|
const timeout = setTimeout(() => {
|
|
1010
1180
|
timedOut = true;
|
|
1011
1181
|
terminateChildProcess(child);
|
|
1012
|
-
},
|
|
1182
|
+
}, timeoutMs);
|
|
1013
1183
|
child.stderr.setEncoding("utf8");
|
|
1014
1184
|
child.stderr.on("data", (chunk) => {
|
|
1015
1185
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
@@ -1024,13 +1194,13 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
|
1024
1194
|
pipeline(createReadStream2(source), child.stdin)
|
|
1025
1195
|
]);
|
|
1026
1196
|
if (hasTimedOut())
|
|
1027
|
-
throw new Error(`${command} exceeded the ${
|
|
1197
|
+
throw new Error(`${command} exceeded the ${timeoutMs / 1000}-second archive deadline.`);
|
|
1028
1198
|
if (exitCode !== 0) {
|
|
1029
1199
|
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
1030
1200
|
}
|
|
1031
1201
|
} catch (error) {
|
|
1032
1202
|
terminateChildProcess(child);
|
|
1033
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
1203
|
+
const detail = redact(error instanceof Error ? error.message : String(error), secrets);
|
|
1034
1204
|
throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
|
|
1035
1205
|
${redact(stderr, secrets)}` : ""}`);
|
|
1036
1206
|
} finally {
|
|
@@ -1119,6 +1289,31 @@ function parseComposeConfig(text) {
|
|
|
1119
1289
|
throw new Error(`Docker Compose did not return a readable JSON configuration: ${error instanceof Error ? error.message : String(error)}`);
|
|
1120
1290
|
}
|
|
1121
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
|
+
}
|
|
1122
1317
|
function composeEnvironmentValue(service, key) {
|
|
1123
1318
|
const environment = service.environment;
|
|
1124
1319
|
if (Array.isArray(environment)) {
|
|
@@ -1169,6 +1364,41 @@ async function resolvedComposeConfig(directory, mode, deadline = performance.now
|
|
|
1169
1364
|
${result.stderr}`);
|
|
1170
1365
|
return result.stdout;
|
|
1171
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
|
+
}
|
|
1172
1402
|
async function assertSafeComposeBinding(directory, mode, env, deadline) {
|
|
1173
1403
|
const config = await resolvedComposeConfig(directory, mode, deadline);
|
|
1174
1404
|
const parsed = parseComposeConfig(config);
|
|
@@ -1568,8 +1798,14 @@ async function restoreVolumeArchive(directory, mode, env, backupPath) {
|
|
|
1568
1798
|
], join7(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1569
1799
|
}
|
|
1570
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";
|
|
1571
1807
|
const storageRoot = posix.relative("/data", archivedStorageRoot ?? env.OR3_STORAGE_FS_ROOT ?? "/data/storage");
|
|
1572
|
-
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/`;
|
|
1573
1809
|
await streamFileToCommand("docker", [
|
|
1574
1810
|
"run",
|
|
1575
1811
|
"--rm",
|
|
@@ -1585,7 +1821,7 @@ async function validateVolumeArchive(directory, _mode, env, backupPath, archived
|
|
|
1585
1821
|
"sh",
|
|
1586
1822
|
env.OR3_IMAGE,
|
|
1587
1823
|
"-c",
|
|
1588
|
-
`{ 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" '
|
|
1589
1825
|
{
|
|
1590
1826
|
entry = $0
|
|
1591
1827
|
sub(/^\\.\\//, "", entry)
|
|
@@ -1610,8 +1846,8 @@ async function validateVolumeArchive(directory, _mode, env, backupPath, archived
|
|
|
1610
1846
|
}
|
|
1611
1847
|
END {
|
|
1612
1848
|
if (!seen_status || tar_status != "0") exit 1
|
|
1613
|
-
if (!
|
|
1614
|
-
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"
|
|
1615
1851
|
exit 1
|
|
1616
1852
|
}
|
|
1617
1853
|
for (entry in metadata) {
|
|
@@ -1624,14 +1860,15 @@ END {
|
|
|
1624
1860
|
}
|
|
1625
1861
|
}'`,
|
|
1626
1862
|
"or3-archive-validation",
|
|
1627
|
-
storagePrefix
|
|
1863
|
+
storagePrefix,
|
|
1864
|
+
requireAuth ? "1" : "0"
|
|
1628
1865
|
], join7(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1629
1866
|
}
|
|
1630
1867
|
|
|
1631
1868
|
// src/backup/manifests.ts
|
|
1632
|
-
import { createHmac, randomBytes as randomBytes5, timingSafeEqual } from "node:crypto";
|
|
1633
|
-
import { lstat, readdir as readdir2 } from "node:fs/promises";
|
|
1634
|
-
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";
|
|
1635
1872
|
|
|
1636
1873
|
// src/deployment/assets.ts
|
|
1637
1874
|
import { randomBytes as randomBytes4 } from "node:crypto";
|
|
@@ -1791,6 +2028,572 @@ function assertRestorableManagedAssets(manifest2) {
|
|
|
1791
2028
|
}
|
|
1792
2029
|
}
|
|
1793
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
|
+
|
|
1794
2597
|
// src/backup/manifests.ts
|
|
1795
2598
|
var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
|
|
1796
2599
|
async function backupAuthenticationKey(directory, create = false) {
|
|
@@ -1818,14 +2621,14 @@ function backupAuthenticationTag(key, manifestContents) {
|
|
|
1818
2621
|
}
|
|
1819
2622
|
async function writeBackupAuthentication(directory, backupPath, manifestContents) {
|
|
1820
2623
|
const key = await backupAuthenticationKey(directory, true);
|
|
1821
|
-
await writeSecure(
|
|
2624
|
+
await writeSecure(join12(backupPath, "manifest.auth"), `${backupAuthenticationTag(key, manifestContents)}
|
|
1822
2625
|
`);
|
|
1823
2626
|
}
|
|
1824
2627
|
async function assertBackupAuthentication(directory, backupPath, manifestContents) {
|
|
1825
2628
|
const key = await backupAuthenticationKey(directory);
|
|
1826
2629
|
let provided;
|
|
1827
2630
|
try {
|
|
1828
|
-
const value = (await readText(
|
|
2631
|
+
const value = (await readText(join12(backupPath, "manifest.auth"))).trim();
|
|
1829
2632
|
if (!/^[0-9a-f]{64}$/i.test(value))
|
|
1830
2633
|
throw new Error("invalid authentication tag");
|
|
1831
2634
|
provided = Buffer.from(value, "hex");
|
|
@@ -1854,10 +2657,10 @@ function classifyBackupError(error) {
|
|
|
1854
2657
|
return { code: "backup-entry-invalid", message };
|
|
1855
2658
|
}
|
|
1856
2659
|
async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
1857
|
-
const path =
|
|
2660
|
+
const path = join12(backupsRoot, entry);
|
|
1858
2661
|
let info;
|
|
1859
2662
|
try {
|
|
1860
|
-
info = await
|
|
2663
|
+
info = await lstat3(path);
|
|
1861
2664
|
} catch (error) {
|
|
1862
2665
|
return { kind: "unreadable", entryName: entry, ...classifyBackupError(error) };
|
|
1863
2666
|
}
|
|
@@ -1886,7 +2689,7 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
|
1886
2689
|
};
|
|
1887
2690
|
}
|
|
1888
2691
|
try {
|
|
1889
|
-
await
|
|
2692
|
+
await lstat3(join12(path, "manifest.auth"));
|
|
1890
2693
|
} catch (error) {
|
|
1891
2694
|
if (error.code === "ENOENT") {
|
|
1892
2695
|
return {
|
|
@@ -1899,8 +2702,8 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
|
1899
2702
|
return { kind: "unreadable", entryName: entry, ...classifyBackupError(error) };
|
|
1900
2703
|
}
|
|
1901
2704
|
try {
|
|
1902
|
-
const raw = JSON.parse(await readText(
|
|
1903
|
-
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) {
|
|
1904
2707
|
return {
|
|
1905
2708
|
kind: "unsupported",
|
|
1906
2709
|
entryName: entry,
|
|
@@ -1930,9 +2733,13 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
|
1930
2733
|
};
|
|
1931
2734
|
}
|
|
1932
2735
|
let bytes = 0;
|
|
1933
|
-
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : []]) {
|
|
1934
|
-
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;
|
|
1935
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);
|
|
1936
2743
|
return {
|
|
1937
2744
|
kind: "verified",
|
|
1938
2745
|
backup: {
|
|
@@ -1941,7 +2748,11 @@ async function inspectBackupEntry(backupsRoot, entry, directory) {
|
|
|
1941
2748
|
appVersion: manifest2.appVersion,
|
|
1942
2749
|
path,
|
|
1943
2750
|
bytes,
|
|
1944
|
-
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 } : {}
|
|
1945
2756
|
}
|
|
1946
2757
|
};
|
|
1947
2758
|
} catch (error) {
|
|
@@ -1987,15 +2798,15 @@ async function inventoryBackups(directory) {
|
|
|
1987
2798
|
});
|
|
1988
2799
|
return { entries, storeErrors: [] };
|
|
1989
2800
|
}
|
|
1990
|
-
async function enumerateBackups(directory) {
|
|
1991
|
-
const inventory = await inventoryBackups(directory);
|
|
1992
|
-
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);
|
|
1993
|
-
}
|
|
1994
2801
|
function assertBackupMatchesDeployment(manifest2, backupEnv, state, env) {
|
|
1995
2802
|
if (manifest2.mode !== state.mode || manifest2.appVersion === undefined || manifest2.image === undefined) {
|
|
1996
2803
|
throw new Error(`Backup ${manifest2.backupId} does not match the managed deployment mode.`);
|
|
1997
2804
|
}
|
|
1998
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);
|
|
1999
2810
|
const expected = {
|
|
2000
2811
|
OR3_COMPOSE_PROJECT: state.composeProject,
|
|
2001
2812
|
OR3_VOLUME_NAME: state.volumeName,
|
|
@@ -2038,32 +2849,53 @@ function assertBackupMatchesDeployment(manifest2, backupEnv, state, env) {
|
|
|
2038
2849
|
}
|
|
2039
2850
|
async function readManifest(backupPath, authenticatedForDirectory) {
|
|
2040
2851
|
await lifecycleFaults.beforeArchiveRead?.();
|
|
2041
|
-
const manifestContents = await readText(
|
|
2852
|
+
const manifestContents = await readText(join12(backupPath, "manifest.json"));
|
|
2042
2853
|
if (authenticatedForDirectory)
|
|
2043
2854
|
await assertBackupAuthentication(authenticatedForDirectory, backupPath, manifestContents);
|
|
2044
2855
|
const manifest2 = JSON.parse(manifestContents);
|
|
2045
|
-
|
|
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)) {
|
|
2046
2858
|
throw new Error(`Invalid backup manifest at ${backupPath}.`);
|
|
2047
2859
|
}
|
|
2048
|
-
const actual = await sha256File(
|
|
2860
|
+
const actual = await sha256File(join12(backupPath, "data.tgz"));
|
|
2049
2861
|
if (actual !== manifest2.dataSha256)
|
|
2050
2862
|
throw new Error(`Backup checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${actual}.`);
|
|
2051
2863
|
if (manifest2.configSha256) {
|
|
2052
|
-
const configActual = await sha256File(
|
|
2864
|
+
const configActual = await sha256File(join12(backupPath, "config.env"));
|
|
2053
2865
|
if (configActual !== manifest2.configSha256)
|
|
2054
2866
|
throw new Error(`Backup configuration checksum mismatch for ${manifest2.backupId}.`);
|
|
2055
2867
|
}
|
|
2056
|
-
|
|
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)
|
|
2057
2889
|
throw new Error(`Vault backup checksum mismatch for ${manifest2.backupId}.`);
|
|
2058
2890
|
await verifiedManagedAssetContents(backupPath, manifest2);
|
|
2059
2891
|
return manifest2;
|
|
2060
2892
|
}
|
|
2061
2893
|
async function resolveBackup(directory, value) {
|
|
2062
|
-
if (!
|
|
2894
|
+
if (!isAbsolute3(value) && !BACKUP_ID_PATTERN.test(value)) {
|
|
2063
2895
|
throw new Error(`Backup ID "${value}" is invalid. Use an OR3-generated backup ID or an absolute external backup path.`);
|
|
2064
2896
|
}
|
|
2065
|
-
const candidate =
|
|
2066
|
-
if (!await fileExists(
|
|
2897
|
+
const candidate = isAbsolute3(value) ? value : backupDirectory(directory, value);
|
|
2898
|
+
if (!await fileExists(join12(candidate, "manifest.json")))
|
|
2067
2899
|
throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
|
|
2068
2900
|
return resolve4(candidate);
|
|
2069
2901
|
}
|
|
@@ -2072,7 +2904,7 @@ async function recordedBackupPath(directory, pending) {
|
|
|
2072
2904
|
const path = previous ? pending.previousBackupPath : pending.backupPath;
|
|
2073
2905
|
const backupId = previous ? pending.previousBackupId : pending.backupId;
|
|
2074
2906
|
const resolved = path ? resolve4(path) : backupId ? await resolveBackup(directory, backupId) : undefined;
|
|
2075
|
-
if (!resolved || !await fileExists(
|
|
2907
|
+
if (!resolved || !await fileExists(join12(resolved, "manifest.json"))) {
|
|
2076
2908
|
throw new Error(`The incomplete ${pending.operation} has no readable ${previous ? "pre-mutation" : "target"} backup source.`);
|
|
2077
2909
|
}
|
|
2078
2910
|
const manifest2 = await readManifest(resolved, directory);
|
|
@@ -2214,16 +3046,21 @@ async function maskedQuestion(question) {
|
|
|
2214
3046
|
|
|
2215
3047
|
// src/deployment/adoption-source.ts
|
|
2216
3048
|
import { existsSync as existsSync2 } from "node:fs";
|
|
2217
|
-
import { basename as basename2, join as
|
|
3049
|
+
import { basename as basename2, join as join13 } from "node:path";
|
|
2218
3050
|
|
|
2219
3051
|
// src/runtime/health.ts
|
|
2220
3052
|
var DEEP_HEALTH_TIMEOUT_MS = 180000;
|
|
2221
|
-
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))";
|
|
2222
3054
|
var VERIFY_DATABASES_SCRIPT = `
|
|
2223
3055
|
const fs = require('node:fs');
|
|
2224
3056
|
const Database = require('/app/.output/server/node_modules/better-sqlite3');
|
|
2225
3057
|
const results = [];
|
|
2226
|
-
|
|
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) {
|
|
2227
3064
|
const info = fs.statSync(path);
|
|
2228
3065
|
if (info.uid !== 65532 || info.gid !== 65532) throw new Error(path + ' must be owned by 65532:65532');
|
|
2229
3066
|
const db = new Database(path, { readonly: true, fileMustExist: true });
|
|
@@ -2296,15 +3133,112 @@ async function sourceServiceRunning(directory, composeFiles) {
|
|
|
2296
3133
|
throw new Error(`Could not determine whether the V1 OR3 service is running. ${result.stderr.trim()}`);
|
|
2297
3134
|
return Boolean(result.stdout.trim());
|
|
2298
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
|
+
}
|
|
2299
3233
|
async function readSourceVolume(sourceDirectory) {
|
|
2300
3234
|
const composeResult = await run("docker", [
|
|
2301
3235
|
"compose",
|
|
2302
3236
|
"--project-directory",
|
|
2303
3237
|
sourceDirectory,
|
|
2304
3238
|
"--env-file",
|
|
2305
|
-
|
|
3239
|
+
join13(sourceDirectory, ".env"),
|
|
2306
3240
|
"-f",
|
|
2307
|
-
|
|
3241
|
+
join13(sourceDirectory, "compose.yaml"),
|
|
2308
3242
|
"ps",
|
|
2309
3243
|
"-aq",
|
|
2310
3244
|
"or3"
|
|
@@ -2325,7 +3259,7 @@ async function readSourceVolume(sourceDirectory) {
|
|
|
2325
3259
|
}
|
|
2326
3260
|
function sourceMode(sourceDirectory, sourceEnv) {
|
|
2327
3261
|
const publicDeployment = sourceEnv.OR3_PUBLIC_DOMAIN && sourceEnv.OR3_PUBLIC_DOMAIN !== "localhost" && sourceEnv.OR3_FORCE_HTTPS !== "false";
|
|
2328
|
-
return publicDeployment && existsSync2(
|
|
3262
|
+
return publicDeployment && existsSync2(join13(sourceDirectory, "compose.public.yaml")) ? "public" : "local";
|
|
2329
3263
|
}
|
|
2330
3264
|
function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
|
|
2331
3265
|
return [
|
|
@@ -2333,7 +3267,7 @@ function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
|
|
|
2333
3267
|
"--project-directory",
|
|
2334
3268
|
sourceDirectory,
|
|
2335
3269
|
"--env-file",
|
|
2336
|
-
|
|
3270
|
+
join13(sourceDirectory, ".env"),
|
|
2337
3271
|
...sourceComposeFiles
|
|
2338
3272
|
];
|
|
2339
3273
|
}
|
|
@@ -2346,8 +3280,8 @@ async function restartSource(sourceDirectory, sourceComposeFiles, secrets) {
|
|
|
2346
3280
|
}
|
|
2347
3281
|
function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
|
|
2348
3282
|
const values = [sourceEnv.AUTH_PROVIDER, sourceEnv.OR3_AUTH_PROVIDER].filter(Boolean);
|
|
2349
|
-
if (
|
|
2350
|
-
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.`);
|
|
2351
3285
|
if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
|
|
2352
3286
|
throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
|
|
2353
3287
|
}
|
|
@@ -2372,21 +3306,94 @@ function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
|
|
|
2372
3306
|
throw new Error("V1 project is missing OR3 version metadata.");
|
|
2373
3307
|
}
|
|
2374
3308
|
}
|
|
2375
|
-
function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
|
|
3309
|
+
function assertSupportedSourceCompose(configText, sourceVolume, expectedPort, sourceEnv, allowedImages) {
|
|
2376
3310
|
const config = parseComposeConfig(configText);
|
|
2377
3311
|
const service = config.services?.or3;
|
|
2378
3312
|
if (!service)
|
|
2379
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
|
+
}
|
|
2380
3383
|
for (const [key, expected] of [
|
|
2381
3384
|
["OR3_BASIC_AUTH_DB_PATH", "/data/auth.sqlite"],
|
|
2382
3385
|
["OR3_SQLITE_DB_PATH", "/data/sync.sqlite"],
|
|
2383
|
-
["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"]
|
|
2384
3389
|
]) {
|
|
2385
3390
|
if (composeEnvironmentValue(service, key) !== expected) {
|
|
2386
3391
|
throw new Error(`V1 Compose configuration does not resolve ${key} to ${expected}. Adoption refuses custom data layouts.`);
|
|
2387
3392
|
}
|
|
2388
3393
|
}
|
|
2389
3394
|
const mounts = service.volumes ?? [];
|
|
3395
|
+
if (mounts.length !== 1)
|
|
3396
|
+
throw new Error("V1 Compose has additional mounts that adoption cannot safely migrate.");
|
|
2390
3397
|
const dataMount = mounts.find((mount) => mount.target === "/data");
|
|
2391
3398
|
if (!dataMount || dataMount.type !== "volume") {
|
|
2392
3399
|
throw new Error("V1 Compose configuration must mount one named volume at /data.");
|
|
@@ -2406,8 +3413,8 @@ function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
|
|
|
2406
3413
|
|
|
2407
3414
|
// src/runtime/dashboard-operator.ts
|
|
2408
3415
|
import { randomBytes as randomBytes6 } from "node:crypto";
|
|
2409
|
-
import { chmod as chmod4, mkdir as mkdir4, rm as
|
|
2410
|
-
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";
|
|
2411
3418
|
|
|
2412
3419
|
// src/runtime/images.ts
|
|
2413
3420
|
async function pullImage(image, expectedDigest2) {
|
|
@@ -2549,13 +3556,13 @@ async function dashboardOperatorEnv(directory, version) {
|
|
|
2549
3556
|
if (configured && !configured.startsWith("unix://"))
|
|
2550
3557
|
return;
|
|
2551
3558
|
const socket = configured ? configured.slice("unix://".length) : "/var/run/docker.sock";
|
|
2552
|
-
if (!
|
|
3559
|
+
if (!isAbsolute4(socket))
|
|
2553
3560
|
return;
|
|
2554
3561
|
let socketStat;
|
|
2555
3562
|
let uid;
|
|
2556
3563
|
let gid;
|
|
2557
3564
|
try {
|
|
2558
|
-
socketStat = await
|
|
3565
|
+
socketStat = await stat3(socket);
|
|
2559
3566
|
if (!socketStat.isSocket())
|
|
2560
3567
|
return;
|
|
2561
3568
|
uid = process.getuid();
|
|
@@ -2633,7 +3640,7 @@ async function prepareVerifiedDashboardOperator(directory, version) {
|
|
|
2633
3640
|
if (await verifyDashboardOperatorBridge(directory, candidate))
|
|
2634
3641
|
return candidate;
|
|
2635
3642
|
} catch {}
|
|
2636
|
-
await
|
|
3643
|
+
await rm5(deploymentPaths(directory).operatorIpc, { recursive: true, force: true }).catch(() => {
|
|
2637
3644
|
return;
|
|
2638
3645
|
});
|
|
2639
3646
|
console.warn("Dashboard updates are unavailable on this Docker setup; the deployment will remain host-CLI managed.");
|
|
@@ -2641,7 +3648,7 @@ async function prepareVerifiedDashboardOperator(directory, version) {
|
|
|
2641
3648
|
}
|
|
2642
3649
|
function dashboardOperatorHandoffArgs(directory, env, jobId) {
|
|
2643
3650
|
const project = env.OR3_COMPOSE_PROJECT;
|
|
2644
|
-
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 ?? "")) {
|
|
2645
3652
|
throw new Error("Refusing to schedule an invalid dashboard operator handoff.");
|
|
2646
3653
|
}
|
|
2647
3654
|
return [
|
|
@@ -2672,7 +3679,7 @@ function dashboardOperatorHandoffArgs(directory, env, jobId) {
|
|
|
2672
3679
|
"--entrypoint",
|
|
2673
3680
|
"/usr/local/bin/node",
|
|
2674
3681
|
env.OR3_OPERATOR_IMAGE,
|
|
2675
|
-
|
|
3682
|
+
join14(directory, "dashboard-operator.mjs"),
|
|
2676
3683
|
"--complete-handoff",
|
|
2677
3684
|
jobId,
|
|
2678
3685
|
project
|
|
@@ -2758,10 +3765,10 @@ async function checkPublicPrerequisites(domain) {
|
|
|
2758
3765
|
|
|
2759
3766
|
// src/runtime/project.ts
|
|
2760
3767
|
import { readFileSync as readFileSync5 } from "node:fs";
|
|
2761
|
-
import { join as
|
|
3768
|
+
import { join as join15 } from "node:path";
|
|
2762
3769
|
function managedVaultEnabled(directory, env) {
|
|
2763
|
-
const selected = env ?? parseEnv(readFileSync5(
|
|
2764
|
-
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(`
|
|
2765
3772
|
or3-vault:
|
|
2766
3773
|
`);
|
|
2767
3774
|
}
|
|
@@ -2783,11 +3790,11 @@ async function startProject(directory, mode, env) {
|
|
|
2783
3790
|
}
|
|
2784
3791
|
|
|
2785
3792
|
// src/runtime/verification.ts
|
|
2786
|
-
import { createHash as
|
|
2787
|
-
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") {
|
|
2788
3795
|
const health = value;
|
|
2789
|
-
if (!health || health.status !== "ok" || health.providers?.auth?.provider !== "basic-auth" || health.providers.sync?.provider !== "sqlite" || health.providers.storage?.provider !== "fs") {
|
|
2790
|
-
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.`);
|
|
2791
3798
|
}
|
|
2792
3799
|
return health;
|
|
2793
3800
|
}
|
|
@@ -2882,7 +3889,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
|
|
|
2882
3889
|
Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489", "hex"),
|
|
2883
3890
|
randomBytes7(8)
|
|
2884
3891
|
]);
|
|
2885
|
-
const hash = `sha256:${
|
|
3892
|
+
const hash = `sha256:${createHash4("sha256").update(probeBytes).digest("hex")}`;
|
|
2886
3893
|
let storageId;
|
|
2887
3894
|
let metadataAttempted = false;
|
|
2888
3895
|
let physicalCleanupDeferred = false;
|
|
@@ -2924,7 +3931,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
|
|
|
2924
3931
|
}
|
|
2925
3932
|
});
|
|
2926
3933
|
const metadataCreatedAt = Date.now();
|
|
2927
|
-
const metadataOpId =
|
|
3934
|
+
const metadataOpId = randomUUID4();
|
|
2928
3935
|
const pushed = await verificationJson(baseUrl, "/api/sync/push", {
|
|
2929
3936
|
cookie,
|
|
2930
3937
|
body: {
|
|
@@ -2974,7 +3981,7 @@ async function verifyPublicApplication(baseUrl, credentials) {
|
|
|
2974
3981
|
if (storageId) {
|
|
2975
3982
|
if (metadataAttempted) {
|
|
2976
3983
|
const metadataDeleteAt = Date.now();
|
|
2977
|
-
const metadataDeleteOpId =
|
|
3984
|
+
const metadataDeleteOpId = randomUUID4();
|
|
2978
3985
|
const deleted = await verificationJson(baseUrl, "/api/sync/push", {
|
|
2979
3986
|
cookie,
|
|
2980
3987
|
body: {
|
|
@@ -3074,31 +4081,31 @@ async function adoptCommand(positionals, flags) {
|
|
|
3074
4081
|
if (targetDirectory === sourceDirectory)
|
|
3075
4082
|
throw new Error("Choose a separate target directory for adoption.");
|
|
3076
4083
|
await readDirectoryEmpty(targetDirectory);
|
|
3077
|
-
if (!await fileExists(
|
|
4084
|
+
if (!await fileExists(join16(sourceDirectory, "or3-release.json")))
|
|
3078
4085
|
throw new Error(`V1 release metadata is missing at ${sourceDirectory}/or3-release.json.`);
|
|
3079
|
-
const release = JSON.parse(await readText(
|
|
4086
|
+
const release = JSON.parse(await readText(join16(sourceDirectory, "or3-release.json")));
|
|
3080
4087
|
if (!release.or3Version || !isVersion(release.or3Version))
|
|
3081
4088
|
throw new Error("V1 release metadata does not contain a usable OR3 version.");
|
|
3082
4089
|
if (release.or3Version !== PACKAGE_VERSION) {
|
|
3083
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.`);
|
|
3084
4091
|
}
|
|
3085
|
-
const sourceEnv = parseEnv(await readText(
|
|
3086
|
-
const providers = await readText(
|
|
4092
|
+
const sourceEnv = parseEnv(await readText(join16(sourceDirectory, ".env")));
|
|
4093
|
+
const providers = await readText(join16(sourceDirectory, "or3.providers.generated.ts"));
|
|
3087
4094
|
assertSupportedSource(sourceDirectory, sourceEnv, providers);
|
|
3088
4095
|
const mode = sourceMode(sourceDirectory, sourceEnv);
|
|
3089
4096
|
const domain = mode === "public" ? sourceEnv.OR3_PUBLIC_DOMAIN : undefined;
|
|
3090
4097
|
if (domain)
|
|
3091
4098
|
await checkPublicPrerequisites(domain);
|
|
3092
|
-
const sourceComposeFiles = ["-f",
|
|
4099
|
+
const sourceComposeFiles = ["-f", join16(sourceDirectory, "compose.yaml")];
|
|
3093
4100
|
if (mode === "public")
|
|
3094
|
-
sourceComposeFiles.push("-f",
|
|
4101
|
+
sourceComposeFiles.push("-f", join16(sourceDirectory, "compose.public.yaml"));
|
|
3095
4102
|
await ensureDocker();
|
|
3096
4103
|
const sourceConfig = await run("docker", [
|
|
3097
4104
|
"compose",
|
|
3098
4105
|
"--project-directory",
|
|
3099
4106
|
sourceDirectory,
|
|
3100
4107
|
"--env-file",
|
|
3101
|
-
|
|
4108
|
+
join16(sourceDirectory, ".env"),
|
|
3102
4109
|
...sourceComposeFiles,
|
|
3103
4110
|
"config",
|
|
3104
4111
|
"--format",
|
|
@@ -3127,11 +4134,12 @@ async function adoptCommand(positionals, flags) {
|
|
|
3127
4134
|
throw new Error("V1 project has an invalid OR3 port.");
|
|
3128
4135
|
if (mode === "public" && [80, 443].includes(sourcePort))
|
|
3129
4136
|
throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
|
|
3130
|
-
assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort);
|
|
4137
|
+
assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort, sourceEnv, [sourceImage, image]);
|
|
3131
4138
|
const sourceInitiallyRunning = await sourceServiceRunning(sourceDirectory, sourceComposeFiles);
|
|
4139
|
+
await assertSourceContainer(sourceDirectory, sourceComposeFiles, image, sourceConfig.stdout, sourceVolume, sourcePort, sourceEnv);
|
|
3132
4140
|
const targetNames = composeProjectNames(targetDirectory);
|
|
3133
4141
|
await assertDockerProjectAbsent(targetNames.project);
|
|
3134
|
-
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] : []]) {
|
|
3135
4143
|
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
3136
4144
|
if (existing.ok)
|
|
3137
4145
|
throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
|
|
@@ -3185,7 +4193,7 @@ ${existing.stderr}`)) {
|
|
|
3185
4193
|
OR3_GUEST_ACCESS_ENABLED: "false"
|
|
3186
4194
|
});
|
|
3187
4195
|
await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
|
|
3188
|
-
await writeSecure(
|
|
4196
|
+
await writeSecure(join16(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
3189
4197
|
bootstrapEmail: email,
|
|
3190
4198
|
bootstrapPassword: password,
|
|
3191
4199
|
adminUsername: targetEnv.OR3_ADMIN_USERNAME,
|
|
@@ -3206,6 +4214,7 @@ ${existing.stderr}`)) {
|
|
|
3206
4214
|
phase: "prepared"
|
|
3207
4215
|
});
|
|
3208
4216
|
let sourceStopAttempted = false;
|
|
4217
|
+
let targetMayHaveStarted = false;
|
|
3209
4218
|
try {
|
|
3210
4219
|
sourceStopAttempted = true;
|
|
3211
4220
|
const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
|
|
@@ -3221,7 +4230,7 @@ ${existing.stderr}`)) {
|
|
|
3221
4230
|
}
|
|
3222
4231
|
}
|
|
3223
4232
|
await mkdir5(sourceBackupDir, { recursive: true, mode: 448 });
|
|
3224
|
-
await copySecure(
|
|
4233
|
+
await copySecure(join16(sourceDirectory, ".env"), join16(sourceBackupDir, "config.env"));
|
|
3225
4234
|
await archiveExternalVolume(sourceImage, sourceVolume, sourceBackupDir);
|
|
3226
4235
|
const sourceManifest = {
|
|
3227
4236
|
schemaVersion: 1,
|
|
@@ -3230,8 +4239,8 @@ ${existing.stderr}`)) {
|
|
|
3230
4239
|
appVersion: release.or3Version,
|
|
3231
4240
|
image,
|
|
3232
4241
|
imageDigest: digest,
|
|
3233
|
-
dataSha256: await sha256File(
|
|
3234
|
-
configSha256: await sha256File(
|
|
4242
|
+
dataSha256: await sha256File(join16(sourceBackupDir, "data.tgz")),
|
|
4243
|
+
configSha256: await sha256File(join16(sourceBackupDir, "config.env")),
|
|
3235
4244
|
mode,
|
|
3236
4245
|
domain,
|
|
3237
4246
|
composeProject: sourceEnv.OR3_COMPOSE_PROJECT ?? sanitizeName(basename3(sourceDirectory)),
|
|
@@ -3242,11 +4251,13 @@ ${existing.stderr}`)) {
|
|
|
3242
4251
|
};
|
|
3243
4252
|
const sourceManifestContents = `${JSON.stringify(sourceManifest, null, 2)}
|
|
3244
4253
|
`;
|
|
3245
|
-
await writeSecure(
|
|
4254
|
+
await writeSecure(join16(sourceBackupDir, "manifest.json"), sourceManifestContents);
|
|
3246
4255
|
await writeBackupAuthentication(targetDirectory, sourceBackupDir, sourceManifestContents);
|
|
3247
4256
|
await readManifest(sourceBackupDir, targetDirectory);
|
|
3248
4257
|
await updatePending(targetDirectory, state, { phase: "snapshot-created" });
|
|
4258
|
+
await updatePending(targetDirectory, state, { phase: "target-mutating" });
|
|
3249
4259
|
await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
|
|
4260
|
+
targetMayHaveStarted = true;
|
|
3250
4261
|
await startProject(targetDirectory, mode, targetEnv);
|
|
3251
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);
|
|
3252
4263
|
const runtimeEnv = withoutProvisioningCredentials(targetEnv);
|
|
@@ -3258,11 +4269,12 @@ ${existing.stderr}`)) {
|
|
|
3258
4269
|
console.log(`Source backup: ${sourceBackupDir}`);
|
|
3259
4270
|
console.log(`The original deployment is preserved and stopped. Verify sign-in, chat, and file access before removing anything.`);
|
|
3260
4271
|
} catch (error) {
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
4272
|
+
if (!targetMayHaveStarted)
|
|
4273
|
+
await compose(targetDirectory, mode, ["down"]).catch(() => {
|
|
4274
|
+
return;
|
|
4275
|
+
});
|
|
3264
4276
|
let sourceRecoveryError = "";
|
|
3265
|
-
if (sourceStopAttempted && sourceInitiallyRunning) {
|
|
4277
|
+
if (!targetMayHaveStarted && sourceStopAttempted && sourceInitiallyRunning) {
|
|
3266
4278
|
try {
|
|
3267
4279
|
await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
|
|
3268
4280
|
} catch (recovery) {
|
|
@@ -3271,88 +4283,297 @@ ${existing.stderr}`)) {
|
|
|
3271
4283
|
}
|
|
3272
4284
|
state.lastError = redact(`${error instanceof Error ? error.message : String(error)}${sourceRecoveryError}`, secretValues(targetEnv));
|
|
3273
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. ";
|
|
3274
4287
|
throw new Error(`${state.lastError}
|
|
3275
|
-
${
|
|
4288
|
+
${recoveryDetail}Managed files remain at ${targetDirectory}.${sourceBackupDir ? ` Source backup: ${sourceBackupDir}.` : ""}`);
|
|
3276
4289
|
}
|
|
3277
4290
|
}
|
|
3278
4291
|
|
|
3279
4292
|
// src/backup/create.ts
|
|
3280
|
-
import { chmod as chmod6, lstat as
|
|
3281
|
-
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";
|
|
3282
4295
|
|
|
3283
4296
|
// src/backup/references.ts
|
|
3284
|
-
import {
|
|
3285
|
-
|
|
3286
|
-
var
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
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))) {
|
|
3290
4341
|
throw new Error("Unsupported offline backup inspection provider profile; SQLite/FS is required.");
|
|
3291
4342
|
}
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
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;
|
|
3300
4403
|
try {
|
|
3301
|
-
|
|
3302
|
-
|
|
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
|
-
const
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
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"]));
|
|
3340
4569
|
if (result.contract !== 1 || result.complete !== true || !Number.isSafeInteger(result.liveAttachments) || result.liveAttachments < 0)
|
|
3341
4570
|
throw new Error("Offline canonical backup reference inspection did not complete.");
|
|
3342
|
-
}
|
|
3343
|
-
primary = error;
|
|
3344
|
-
}
|
|
3345
|
-
const cleanup = await run("docker", ["rm", "--force", helper], directory);
|
|
3346
|
-
if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
|
|
3347
|
-
throw new AggregateError(primary ? [primary] : [], `Offline backup inspection helper cleanup failed. ${redact(cleanup.stderr, secretValues(env))}`);
|
|
3348
|
-
}
|
|
3349
|
-
if (primary)
|
|
3350
|
-
throw primary;
|
|
4571
|
+
});
|
|
3351
4572
|
}
|
|
3352
4573
|
|
|
3353
4574
|
// src/backup/retention.ts
|
|
3354
|
-
import { rm as
|
|
3355
|
-
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";
|
|
3356
4577
|
var BACKUP_RETENTION_KEEP = 5;
|
|
3357
4578
|
function assertRemovableArtifactName(name) {
|
|
3358
4579
|
if (!BACKUP_ID_PATTERN.test(name)) {
|
|
@@ -3367,18 +4588,18 @@ async function removeNamedBackupArtifact(directory, backupId) {
|
|
|
3367
4588
|
if (dirname4(target) !== deploymentPaths(directory).backups) {
|
|
3368
4589
|
throw new Error(`Refusing to remove a path outside the backups directory: ${target}.`);
|
|
3369
4590
|
}
|
|
3370
|
-
await
|
|
3371
|
-
await
|
|
4591
|
+
await rm6(target, { recursive: true, force: true });
|
|
4592
|
+
await rm6(join18(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
|
|
3372
4593
|
}
|
|
3373
4594
|
async function removeEnumeratedBackupArtifact(directory, backup) {
|
|
3374
4595
|
await lifecycleFaults.beforeArtifactDelete?.();
|
|
3375
|
-
const backupsRoot =
|
|
3376
|
-
const target =
|
|
4596
|
+
const backupsRoot = resolve9(deploymentPaths(directory).backups);
|
|
4597
|
+
const target = resolve9(backup.path);
|
|
3377
4598
|
if (dirname4(target) !== backupsRoot || basename4(target) !== backup.backupId || !BACKUP_ID_PATTERN.test(backup.backupId)) {
|
|
3378
4599
|
throw new Error(`Refusing to prune an untrusted backup path ${backup.path}.`);
|
|
3379
4600
|
}
|
|
3380
|
-
await
|
|
3381
|
-
await
|
|
4601
|
+
await rm6(target, { recursive: true, force: true });
|
|
4602
|
+
await rm6(join18(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
|
|
3382
4603
|
}
|
|
3383
4604
|
function planRetention(entries, keep, protectedIds, options = {}) {
|
|
3384
4605
|
if (!Number.isInteger(keep) || keep < 1)
|
|
@@ -3402,9 +4623,13 @@ function planRetention(entries, keep, protectedIds, options = {}) {
|
|
|
3402
4623
|
if (automatic)
|
|
3403
4624
|
canPrune = false;
|
|
3404
4625
|
}
|
|
3405
|
-
const
|
|
3406
|
-
const
|
|
3407
|
-
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);
|
|
3408
4633
|
const removeSet = new Set(remove);
|
|
3409
4634
|
return {
|
|
3410
4635
|
keep: verified.filter((backup) => !removeSet.has(backup.backupId)).map((backup) => backup.backupId),
|
|
@@ -3453,7 +4678,7 @@ async function pruneBackups(directory, state, keep, force, options = {}) {
|
|
|
3453
4678
|
throw new Error(`Retention selected backup ${backupId}, but its verified path disappeared before deletion.`);
|
|
3454
4679
|
const revalidated = await inspectBackupEntry(deploymentPaths(directory).backups, backupId, directory);
|
|
3455
4680
|
const current = revalidated.kind === "verified" ? revalidated.backup : undefined;
|
|
3456
|
-
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) {
|
|
3457
4682
|
throw new Error(`Backup ${backupId} changed while pruning was in progress. Aborting retention before deletion.`);
|
|
3458
4683
|
}
|
|
3459
4684
|
await removeEnumeratedBackupArtifact(directory, current);
|
|
@@ -3468,7 +4693,7 @@ var asError = (error) => error instanceof Error ? error : new Error(String(error
|
|
|
3468
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.';
|
|
3469
4694
|
async function artifactStatus(path) {
|
|
3470
4695
|
try {
|
|
3471
|
-
await
|
|
4696
|
+
await lstat4(path);
|
|
3472
4697
|
return { absent: false, detail: `Still present: ${path}.` };
|
|
3473
4698
|
} catch (error) {
|
|
3474
4699
|
if (error.code === "ENOENT")
|
|
@@ -3485,7 +4710,7 @@ async function cleanupPartialBackup(directory, backupId) {
|
|
|
3485
4710
|
}
|
|
3486
4711
|
const statuses = await Promise.all([
|
|
3487
4712
|
artifactStatus(backupDirectory(directory, backupId)),
|
|
3488
|
-
artifactStatus(
|
|
4713
|
+
artifactStatus(join19(deploymentPaths(directory).exports, `${backupId}.json`))
|
|
3489
4714
|
]);
|
|
3490
4715
|
const removed = statuses.every((status) => status.absent);
|
|
3491
4716
|
const detail = statuses.map((status) => status.detail).join(" ");
|
|
@@ -3494,7 +4719,17 @@ async function cleanupPartialBackup(directory, backupId) {
|
|
|
3494
4719
|
return { error, removed, detail };
|
|
3495
4720
|
}
|
|
3496
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);
|
|
3497
4730
|
const restartAfter = options.restartAfter ?? true;
|
|
4731
|
+
if (managedVaultEnabled(directory, env))
|
|
4732
|
+
await assertVaultVolumesExist(directory, state);
|
|
3498
4733
|
await requireImageDigest(state.image, state.imageDigest, "Current deployment");
|
|
3499
4734
|
const backupId = options.backupId ?? id("backup");
|
|
3500
4735
|
if (!BACKUP_ID_PATTERN.test(backupId))
|
|
@@ -3534,6 +4769,7 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3534
4769
|
await report("capturing", `Backup capture: OR3 is stopped; writing ${backupDir}.`);
|
|
3535
4770
|
await volumeArchive(directory, state.mode, env, backupDir);
|
|
3536
4771
|
if (managedVaultEnabled(directory, env)) {
|
|
4772
|
+
await assertVaultVolumesExist(directory, state);
|
|
3537
4773
|
await streamCommandToFile("docker", composeArgs(directory, state.mode, [
|
|
3538
4774
|
"run",
|
|
3539
4775
|
"--rm",
|
|
@@ -3544,9 +4780,9 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3544
4780
|
"or3-vault",
|
|
3545
4781
|
"/app/vault/entrypoint.mjs",
|
|
3546
4782
|
"backup"
|
|
3547
|
-
]),
|
|
4783
|
+
]), join19(backupDir, "vault.backup"), directory, secretValues(env));
|
|
3548
4784
|
}
|
|
3549
|
-
await copySecure(deploymentPaths(directory).env,
|
|
4785
|
+
await copySecure(deploymentPaths(directory).env, join19(backupDir, "config.env"));
|
|
3550
4786
|
managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
|
|
3551
4787
|
artifact = "captured";
|
|
3552
4788
|
} catch (error) {
|
|
@@ -3560,6 +4796,8 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3560
4796
|
progressFailures.push(asError(error));
|
|
3561
4797
|
}
|
|
3562
4798
|
try {
|
|
4799
|
+
if (managedVaultEnabled(directory, env))
|
|
4800
|
+
await assertVaultVolumesExist(directory, state);
|
|
3563
4801
|
await startProject(directory, state.mode, env);
|
|
3564
4802
|
service = "healthy";
|
|
3565
4803
|
downtimeMs = Date.now() - stopStartedAt;
|
|
@@ -3569,20 +4807,30 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3569
4807
|
}
|
|
3570
4808
|
}
|
|
3571
4809
|
}
|
|
4810
|
+
if (!primaryFailure && restartFailure && remoteStorage)
|
|
4811
|
+
primaryFailure = new Error("S3 capture requires successful service recovery before remote transfer.");
|
|
3572
4812
|
if (!primaryFailure) {
|
|
3573
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
|
+
}
|
|
3574
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);
|
|
3575
4822
|
const manifest2 = {
|
|
3576
|
-
schemaVersion: 1,
|
|
4823
|
+
...remote ? { schemaVersion: 2, storage: "s3", remote } : { schemaVersion: 1 },
|
|
3577
4824
|
backupId,
|
|
4825
|
+
snapshotKind: options.safetySnapshot ? "safety" : "full",
|
|
3578
4826
|
createdAt: capturedAt,
|
|
3579
4827
|
appVersion: state.appVersion,
|
|
3580
4828
|
image: state.image,
|
|
3581
4829
|
imageDigest: state.imageDigest,
|
|
3582
|
-
dataSha256: await sha256File(
|
|
3583
|
-
...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")) } : {},
|
|
3584
4832
|
dataBytes: volumeSize,
|
|
3585
|
-
configSha256: await sha256File(
|
|
4833
|
+
configSha256: await sha256File(join19(backupDir, "config.env")),
|
|
3586
4834
|
managedAssetSha256,
|
|
3587
4835
|
managedAssetInventoryVersion: managedAssetSha256?.["dashboard-lifecycle.mjs"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256?.["compose.operator.yaml"] ? 3 : managedAssetSha256?.["dashboard-operator.mjs"] ? 2 : undefined,
|
|
3588
4836
|
mode: state.mode,
|
|
@@ -3596,10 +4844,8 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3596
4844
|
};
|
|
3597
4845
|
const manifestContents = `${JSON.stringify(manifest2, null, 2)}
|
|
3598
4846
|
`;
|
|
3599
|
-
await writeSecure(
|
|
4847
|
+
await writeSecure(join19(backupDir, "manifest.json"), manifestContents);
|
|
3600
4848
|
await writeBackupAuthentication(directory, backupDir, manifestContents);
|
|
3601
|
-
if (!options.safetySnapshot)
|
|
3602
|
-
await validateCanonicalBackupReferences(directory, { ...env, OR3_IMAGE: imageAtDigest(state.image, state.imageDigest) }, backupDir);
|
|
3603
4849
|
verifiedManifest = await readManifest(backupDir, directory);
|
|
3604
4850
|
artifact = "verified";
|
|
3605
4851
|
} catch (error) {
|
|
@@ -3648,12 +4894,12 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3648
4894
|
async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath) {
|
|
3649
4895
|
if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
|
|
3650
4896
|
return;
|
|
3651
|
-
const expectedPath =
|
|
3652
|
-
if (
|
|
4897
|
+
const expectedPath = resolve10(backupDirectory(directory, backupId));
|
|
4898
|
+
if (resolve10(backupPath) !== expectedPath)
|
|
3653
4899
|
return;
|
|
3654
4900
|
const statuses = await Promise.all([
|
|
3655
4901
|
artifactStatus(expectedPath),
|
|
3656
|
-
artifactStatus(
|
|
4902
|
+
artifactStatus(join19(deploymentPaths(directory).exports, `${backupId}.json`))
|
|
3657
4903
|
]);
|
|
3658
4904
|
const detail = statuses.map((status) => status.detail).join(" ");
|
|
3659
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".`;
|
|
@@ -3661,8 +4907,8 @@ async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath)
|
|
|
3661
4907
|
async function cleanupJournaledPartialBackup(directory, backupId, backupPath) {
|
|
3662
4908
|
if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
|
|
3663
4909
|
return;
|
|
3664
|
-
const expectedPath =
|
|
3665
|
-
if (
|
|
4910
|
+
const expectedPath = resolve10(backupDirectory(directory, backupId));
|
|
4911
|
+
if (resolve10(backupPath) !== expectedPath)
|
|
3666
4912
|
return;
|
|
3667
4913
|
try {
|
|
3668
4914
|
await readManifest(expectedPath, directory);
|
|
@@ -3679,51 +4925,53 @@ async function createPreMutationSnapshot(directory, state, env, backupId) {
|
|
|
3679
4925
|
}
|
|
3680
4926
|
|
|
3681
4927
|
// src/backup/export.ts
|
|
3682
|
-
import { chmod as chmod7, mkdir as mkdir7, realpath, rm as
|
|
3683
|
-
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";
|
|
3684
4930
|
var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
|
|
3685
4931
|
async function exportBackup(directory, backupId, destination) {
|
|
3686
4932
|
const backupPath = await resolveBackup(directory, backupId);
|
|
3687
4933
|
const manifest2 = await readManifest(backupPath, directory);
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
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);
|
|
3691
4939
|
if (source === dest || dest.startsWith(`${source}${sep}`)) {
|
|
3692
4940
|
throw new Error("Choose a destination directory different from the backup itself.");
|
|
3693
4941
|
}
|
|
3694
4942
|
if (await fileExists(dest))
|
|
3695
4943
|
throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
|
|
3696
4944
|
await mkdir7(dirname5(dest), { recursive: true, mode: 448 });
|
|
3697
|
-
const canonicalDestination =
|
|
4945
|
+
const canonicalDestination = join20(await realpath2(dirname5(dest)), basename5(dest));
|
|
3698
4946
|
if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`)) {
|
|
3699
4947
|
throw new Error("Backup exports must live outside the managed deployment directory so `remove --purge-data` can never delete the remaining copy.");
|
|
3700
4948
|
}
|
|
3701
4949
|
await mkdir7(dest, { mode: 448 });
|
|
3702
4950
|
try {
|
|
3703
|
-
if (await
|
|
4951
|
+
if (await realpath2(dest) !== canonicalDestination) {
|
|
3704
4952
|
throw new Error("Backup export destination changed while it was being created. Refusing to write an export through an unexpected path.");
|
|
3705
4953
|
}
|
|
3706
4954
|
await chmod7(dest, 448);
|
|
3707
|
-
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth", ...manifest2.vaultSha256 ? ["vault.backup"] : []]) {
|
|
3708
|
-
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));
|
|
3709
4957
|
}
|
|
3710
4958
|
if (manifest2.managedAssetSha256) {
|
|
3711
4959
|
for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
|
|
3712
|
-
await copySecure(
|
|
4960
|
+
await copySecure(join20(backupPath, "managed-assets", name), join20(dest, "managed-assets", name));
|
|
3713
4961
|
}
|
|
3714
4962
|
}
|
|
3715
|
-
const dataSha = await sha256File(
|
|
4963
|
+
const dataSha = await sha256File(join20(dest, "data.tgz"));
|
|
3716
4964
|
if (dataSha !== manifest2.dataSha256) {
|
|
3717
4965
|
throw new Error(`Exported data.tgz checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${dataSha}.`);
|
|
3718
4966
|
}
|
|
3719
4967
|
if (manifest2.configSha256) {
|
|
3720
|
-
const configSha = await sha256File(
|
|
4968
|
+
const configSha = await sha256File(join20(dest, "config.env"));
|
|
3721
4969
|
if (configSha !== manifest2.configSha256) {
|
|
3722
4970
|
throw new Error(`Exported config.env checksum mismatch for ${manifest2.backupId}.`);
|
|
3723
4971
|
}
|
|
3724
4972
|
}
|
|
3725
4973
|
await readManifest(dest, directory);
|
|
3726
|
-
const destinationDevice = (await
|
|
4974
|
+
const destinationDevice = (await stat4(dest)).dev;
|
|
3727
4975
|
await mkdir7(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
|
|
3728
4976
|
const receipt = {
|
|
3729
4977
|
schemaVersion: 1,
|
|
@@ -3734,65 +4982,28 @@ async function exportBackup(directory, backupId, destination) {
|
|
|
3734
4982
|
dataSha256: manifest2.dataSha256,
|
|
3735
4983
|
configSha256: manifest2.configSha256
|
|
3736
4984
|
};
|
|
3737
|
-
await writeSecure(
|
|
4985
|
+
await writeSecure(join20(deploymentPaths(directory).exports, `${manifest2.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
|
|
3738
4986
|
`);
|
|
3739
|
-
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;
|
|
3740
4990
|
if (manifest2.managedAssetSha256) {
|
|
3741
4991
|
for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
|
|
3742
|
-
bytes += (await
|
|
4992
|
+
bytes += (await stat4(join20(dest, "managed-assets", name))).size;
|
|
3743
4993
|
}
|
|
3744
4994
|
}
|
|
3745
4995
|
console.log(`Exported backup ${manifest2.backupId} (${bytes} bytes) to ${dest}`);
|
|
3746
4996
|
console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
|
|
3747
|
-
if ((await
|
|
3748
|
-
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.");
|
|
3749
4999
|
} else {
|
|
3750
|
-
console.log("Verified export recorded.
|
|
5000
|
+
console.log("Verified export recorded. Data purge remains disabled until reconstruction from an export is supported.");
|
|
3751
5001
|
}
|
|
3752
5002
|
} catch (error) {
|
|
3753
|
-
await
|
|
5003
|
+
await rm7(dest, { recursive: true, force: true });
|
|
3754
5004
|
throw error;
|
|
3755
5005
|
}
|
|
3756
5006
|
}
|
|
3757
|
-
function assertPurgeBackupFreshness(backups, nowMs) {
|
|
3758
|
-
const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
|
|
3759
|
-
if (!backups.some((backup) => new Date(backup.createdAt).getTime() >= cutoff)) {
|
|
3760
|
-
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.');
|
|
3761
|
-
}
|
|
3762
|
-
}
|
|
3763
|
-
async function readBackupExportReceipt(directory, backupId) {
|
|
3764
|
-
try {
|
|
3765
|
-
const receipt = JSON.parse(await readText(join17(deploymentPaths(directory).exports, `${backupId}.json`)));
|
|
3766
|
-
if (receipt.schemaVersion !== 1 || receipt.backupId !== backupId || typeof receipt.destination !== "string" || !isAbsolute3(receipt.destination) || typeof receipt.destinationDevice !== "number" || !receipt.dataSha256)
|
|
3767
|
-
return;
|
|
3768
|
-
return receipt;
|
|
3769
|
-
} catch {
|
|
3770
|
-
return;
|
|
3771
|
-
}
|
|
3772
|
-
}
|
|
3773
|
-
async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
|
|
3774
|
-
assertPurgeBackupFreshness(backups, nowMs);
|
|
3775
|
-
const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
|
|
3776
|
-
for (const backup of backups) {
|
|
3777
|
-
if (new Date(backup.createdAt).getTime() < cutoff)
|
|
3778
|
-
continue;
|
|
3779
|
-
const receipt = await readBackupExportReceipt(directory, backup.backupId);
|
|
3780
|
-
if (!receipt || !await fileExists(receipt.destination))
|
|
3781
|
-
continue;
|
|
3782
|
-
const deploymentRoot = await realpath(directory);
|
|
3783
|
-
try {
|
|
3784
|
-
const canonicalDestination = await realpath(receipt.destination);
|
|
3785
|
-
if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`))
|
|
3786
|
-
continue;
|
|
3787
|
-
const exported = await readManifest(receipt.destination, directory);
|
|
3788
|
-
const sourceDevice = (await stat3(backup.path)).dev;
|
|
3789
|
-
const destinationDevice = (await stat3(receipt.destination)).dev;
|
|
3790
|
-
if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
|
|
3791
|
-
return receipt;
|
|
3792
|
-
} catch {}
|
|
3793
|
-
}
|
|
3794
|
-
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.");
|
|
3795
|
-
}
|
|
3796
5007
|
|
|
3797
5008
|
// src/commands/backup.ts
|
|
3798
5009
|
function parseKeep(flags) {
|
|
@@ -3806,6 +5017,10 @@ async function backupCreateCommand(directory) {
|
|
|
3806
5017
|
await ensureDocker();
|
|
3807
5018
|
const loaded = await loadManaged(directory);
|
|
3808
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);
|
|
3809
5024
|
const backupId = id("backup");
|
|
3810
5025
|
const initialAppRunning = await projectServiceRunning(loaded.directory, loaded.state.mode);
|
|
3811
5026
|
const pending = {
|
|
@@ -3880,6 +5095,10 @@ async function backupListCommand(directory, flags = {}) {
|
|
|
3880
5095
|
appVersion: backup.appVersion,
|
|
3881
5096
|
bytes: backup.bytes,
|
|
3882
5097
|
dataSha256: backup.dataSha256,
|
|
5098
|
+
manifestSchemaVersion: backup.schemaVersion,
|
|
5099
|
+
snapshotKind: backup.snapshotKind ?? null,
|
|
5100
|
+
recoveryArtifactsComplete: backup.recoveryArtifactsComplete === true,
|
|
5101
|
+
...backup.remoteKind ? { remoteKind: backup.remoteKind } : {},
|
|
3883
5102
|
trust: "verified"
|
|
3884
5103
|
})),
|
|
3885
5104
|
findings: findings.map((entry) => ({ entryName: entry.entryName, kind: entry.kind, code: entry.code, message: entry.message })),
|
|
@@ -3895,10 +5114,16 @@ async function backupListCommand(directory, flags = {}) {
|
|
|
3895
5114
|
console.log(`No authenticated backups are available for ${loaded.directory}.`);
|
|
3896
5115
|
} else {
|
|
3897
5116
|
console.log(`OR3 Cloud backups for ${loaded.directory} (${verified.length} verified):`);
|
|
3898
|
-
console.log("backupId createdAt version bytes trust checksum");
|
|
5117
|
+
console.log("backupId createdAt version bytes kind snapshot coverage trust checksum");
|
|
3899
5118
|
for (const backup of verified) {
|
|
3900
|
-
|
|
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)}`);
|
|
3901
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.");
|
|
3902
5127
|
}
|
|
3903
5128
|
if (findings.length > 0) {
|
|
3904
5129
|
console.log(`
|
|
@@ -3965,8 +5190,8 @@ async function backupCommand(directory, positionals, flags) {
|
|
|
3965
5190
|
}
|
|
3966
5191
|
|
|
3967
5192
|
// src/credentials/reset.ts
|
|
3968
|
-
import { rm as
|
|
3969
|
-
import { join as
|
|
5193
|
+
import { rm as rm8 } from "node:fs/promises";
|
|
5194
|
+
import { join as join21 } from "node:path";
|
|
3970
5195
|
|
|
3971
5196
|
// src/credentials/reset-script.ts
|
|
3972
5197
|
function buildCredentialsResetScript(input2) {
|
|
@@ -4150,7 +5375,10 @@ async function verifyCredentialsInsideContainer(directory, mode, values) {
|
|
|
4150
5375
|
throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
|
|
4151
5376
|
}
|
|
4152
5377
|
async function applyCredentialReset(directory, state, nextEnv) {
|
|
5378
|
+
assertLegacyManagedProfile(nextEnv);
|
|
4153
5379
|
const values = credentialResetValues(nextEnv);
|
|
5380
|
+
if (managedVaultEnabled(directory, nextEnv))
|
|
5381
|
+
await assertVaultVolumesExist(directory, state);
|
|
4154
5382
|
const running = await run("docker", [...composeArgs(directory, state.mode, ["ps", "-q", "or3"])], directory);
|
|
4155
5383
|
if (!running.ok || !running.stdout.trim()) {
|
|
4156
5384
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
|
|
@@ -4159,9 +5387,11 @@ async function applyCredentialReset(directory, state, nextEnv) {
|
|
|
4159
5387
|
await runCredentialsResetScript(directory, state.mode, values);
|
|
4160
5388
|
const runtimeEnv = withoutProvisioningCredentials(nextEnv);
|
|
4161
5389
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
|
|
4162
|
-
const initialCredentials =
|
|
4163
|
-
await
|
|
5390
|
+
const initialCredentials = join21(directory, ".or3-initial-credentials");
|
|
5391
|
+
await rm8(initialCredentials, { force: true });
|
|
4164
5392
|
await stopProject(directory, state.mode);
|
|
5393
|
+
if (managedVaultEnabled(directory, runtimeEnv))
|
|
5394
|
+
await assertVaultVolumesExist(directory, state);
|
|
4165
5395
|
await startProject(directory, state.mode, runtimeEnv);
|
|
4166
5396
|
await verifyCredentialsInsideContainer(directory, state.mode, values);
|
|
4167
5397
|
}
|
|
@@ -4174,6 +5404,10 @@ async function credentialsResetCommand(directory, flags) {
|
|
|
4174
5404
|
const loaded = await loadManaged(directory);
|
|
4175
5405
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
4176
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);
|
|
4177
5411
|
const ownerEmail = loaded.env.OR3_MANAGED_OWNER_EMAIL || loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
4178
5412
|
const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
|
|
4179
5413
|
if (!ownerEmail)
|
|
@@ -4220,10 +5454,10 @@ async function credentialsCommand(directory, positionals, flags) {
|
|
|
4220
5454
|
|
|
4221
5455
|
// src/commands/doctor.ts
|
|
4222
5456
|
import { existsSync as existsSync3 } from "node:fs";
|
|
4223
|
-
import { stat as
|
|
4224
|
-
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";
|
|
4225
5459
|
async function doctorCommand(directory) {
|
|
4226
|
-
const resolved =
|
|
5460
|
+
const resolved = resolve12(directory);
|
|
4227
5461
|
const paths = deploymentPaths(resolved);
|
|
4228
5462
|
const failures = [];
|
|
4229
5463
|
let state;
|
|
@@ -4246,10 +5480,10 @@ async function doctorCommand(directory) {
|
|
|
4246
5480
|
failures.push(error instanceof Error ? error.message : String(error));
|
|
4247
5481
|
console.log("✗ Managed state or .env is missing/invalid");
|
|
4248
5482
|
}
|
|
4249
|
-
for (const file of [paths.env, paths.state,
|
|
5483
|
+
for (const file of [paths.env, paths.state, join22(resolved, ".or3-initial-credentials")]) {
|
|
4250
5484
|
if (!existsSync3(file))
|
|
4251
5485
|
continue;
|
|
4252
|
-
const mode = (await
|
|
5486
|
+
const mode = (await stat5(file)).mode & 511;
|
|
4253
5487
|
if (mode !== 384) {
|
|
4254
5488
|
failures.push(`${file} must be mode 0600 (currently ${mode.toString(8)}).`);
|
|
4255
5489
|
console.log(`✗ Permissions: ${file} is ${mode.toString(8)}`);
|
|
@@ -4317,8 +5551,8 @@ async function doctorCommand(directory) {
|
|
|
4317
5551
|
throw new Error("Dashboard operator deployment identity does not match managed state.");
|
|
4318
5552
|
}
|
|
4319
5553
|
assertDashboardOperatorMounts(container.Mounts ?? [], resolved);
|
|
4320
|
-
const ipcDirectory = await
|
|
4321
|
-
const operatorSocket = await
|
|
5554
|
+
const ipcDirectory = await stat5(paths.operatorIpc);
|
|
5555
|
+
const operatorSocket = await stat5(join22(paths.operatorIpc, "operator.sock"));
|
|
4322
5556
|
const operatorUid = Number(env.OR3_OPERATOR_UID);
|
|
4323
5557
|
const operatorGid = Number(env.OR3_OPERATOR_GID);
|
|
4324
5558
|
if ((ipcDirectory.mode & 511) !== 456 || ipcDirectory.uid !== operatorUid || ipcDirectory.gid !== operatorGid || !operatorSocket.isSocket() || (operatorSocket.mode & 511) !== 432 || operatorSocket.uid !== operatorUid || operatorSocket.gid !== operatorGid) {
|
|
@@ -4377,15 +5611,15 @@ async function doctorCommand(directory) {
|
|
|
4377
5611
|
|
|
4378
5612
|
// src/commands/init.ts
|
|
4379
5613
|
import { chmod as chmod8, mkdir as mkdir8 } from "node:fs/promises";
|
|
4380
|
-
import { join as
|
|
5614
|
+
import { join as join23, relative, resolve as resolve13 } from "node:path";
|
|
4381
5615
|
async function initCommand(positionals, flags) {
|
|
4382
5616
|
const hasLocal = boolFlag(flags, "local");
|
|
4383
5617
|
const hasPublic = boolFlag(flags, "public");
|
|
4384
5618
|
if (hasLocal === hasPublic)
|
|
4385
5619
|
throw new Error("Choose exactly one of --local or --public.");
|
|
4386
5620
|
const mode = hasPublic ? "public" : "local";
|
|
4387
|
-
const directory =
|
|
4388
|
-
if (await fileExists(
|
|
5621
|
+
const directory = resolve13(process.cwd(), positionals[0] ?? "or3-cloud");
|
|
5622
|
+
if (await fileExists(join23(directory, ".or3-cloud", "state.json"))) {
|
|
4389
5623
|
throw new Error(`${directory} is already a managed deployment. Use update, doctor, or adopt.`);
|
|
4390
5624
|
}
|
|
4391
5625
|
await readDirectoryEmpty(directory);
|
|
@@ -4411,7 +5645,7 @@ async function initCommand(positionals, flags) {
|
|
|
4411
5645
|
await ensureDocker();
|
|
4412
5646
|
const names = composeProjectNames(directory);
|
|
4413
5647
|
await assertDockerProjectAbsent(names.project);
|
|
4414
|
-
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] : []]) {
|
|
4415
5649
|
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
4416
5650
|
if (existing.ok)
|
|
4417
5651
|
throw new Error(`Docker volume ${volume} already exists. Choose a new directory or inspect it before initializing.`);
|
|
@@ -4436,7 +5670,7 @@ ${existing.stderr}`)) {
|
|
|
4436
5670
|
await copyAssets(directory, mode);
|
|
4437
5671
|
const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
|
|
4438
5672
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
|
|
4439
|
-
await writeSecure(
|
|
5673
|
+
await writeSecure(join23(directory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
4440
5674
|
bootstrapEmail: email,
|
|
4441
5675
|
bootstrapPassword: password,
|
|
4442
5676
|
adminUsername: email,
|
|
@@ -4461,7 +5695,7 @@ ${existing.stderr}`)) {
|
|
|
4461
5695
|
const folder = relative(process.cwd(), directory) || ".";
|
|
4462
5696
|
console.log(`
|
|
4463
5697
|
OR3 Cloud ${version} is running at ${url}`);
|
|
4464
|
-
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.`);
|
|
4465
5699
|
console.log("Save it in a password manager, then delete that file.");
|
|
4466
5700
|
console.log(`
|
|
4467
5701
|
Run later commands from the deployment folder: cd ${quote(folder)} && npx @or3/cloud doctor`);
|
|
@@ -4482,6 +5716,10 @@ async function startCommand(directory) {
|
|
|
4482
5716
|
const loaded = await loadManaged(directory);
|
|
4483
5717
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
4484
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);
|
|
4485
5723
|
await startProject(directory, loaded.state.mode, loaded.env);
|
|
4486
5724
|
const url = loaded.state.mode === "public" ? `https://${loaded.state.domain}` : `http://127.0.0.1:${loaded.state.port}`;
|
|
4487
5725
|
console.log(`OR3 started and is deeply healthy at ${url}.`);
|
|
@@ -4499,6 +5737,8 @@ async function restartCommand(directory, lifecycle) {
|
|
|
4499
5737
|
const loaded = await loadManaged(directory);
|
|
4500
5738
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
4501
5739
|
assertPhaseAllowsCommand("restart", loaded.state);
|
|
5740
|
+
assertLegacyManagedProfile(loaded.env);
|
|
5741
|
+
await assertLegacyComposeProfile(loaded.directory, loaded.state.mode, loaded.env, lifecycle?.deadline);
|
|
4502
5742
|
if (lifecycle) {
|
|
4503
5743
|
await assertSafeComposeBinding(directory, loaded.state.mode, loaded.env, lifecycle.deadline);
|
|
4504
5744
|
await assertRunningAppImage(directory, loaded.state.mode, loaded.state.image, lifecycle.deadline);
|
|
@@ -4513,19 +5753,211 @@ async function restartCommand(directory, lifecycle) {
|
|
|
4513
5753
|
console.log("OR3 restarted and is deeply healthy.");
|
|
4514
5754
|
}
|
|
4515
5755
|
|
|
4516
|
-
// src/
|
|
5756
|
+
// src/backup/restore.ts
|
|
4517
5757
|
import { join as join24 } from "node:path";
|
|
4518
5758
|
|
|
5759
|
+
// src/backup/generation-restore.ts
|
|
5760
|
+
function rebindRestoredGenerations(fs, path, crypto, db, root, options = {}) {
|
|
5761
|
+
const preserveMissing = options.previousSafetySnapshot === true;
|
|
5762
|
+
const fail = () => {
|
|
5763
|
+
throw new Error("Restored generation identity or payload is invalid");
|
|
5764
|
+
};
|
|
5765
|
+
const info = (name) => {
|
|
5766
|
+
try {
|
|
5767
|
+
return fs.lstatSync(name, { bigint: true });
|
|
5768
|
+
} catch (error) {
|
|
5769
|
+
if (error.code === "ENOENT")
|
|
5770
|
+
return null;
|
|
5771
|
+
throw error;
|
|
5772
|
+
}
|
|
5773
|
+
};
|
|
5774
|
+
const dir = (name) => {
|
|
5775
|
+
const s = info(name);
|
|
5776
|
+
if (!s?.isDirectory())
|
|
5777
|
+
fail();
|
|
5778
|
+
return s;
|
|
5779
|
+
};
|
|
5780
|
+
const json = (name) => {
|
|
5781
|
+
const s = info(name);
|
|
5782
|
+
if (!s?.isFile() || s.nlink !== 1n || s.size > 4096n)
|
|
5783
|
+
fail();
|
|
5784
|
+
return JSON.parse(fs.readFileSync(name, "utf8"));
|
|
5785
|
+
};
|
|
5786
|
+
const base = path.join(root, "generations-v1");
|
|
5787
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
|
|
5788
|
+
const required = new Set;
|
|
5789
|
+
if (tables.has("storage_object_generations")) {
|
|
5790
|
+
for (const row of db.prepare("SELECT generation_id FROM storage_object_generations WHERE storage_provider_id = 'fs'").all())
|
|
5791
|
+
required.add(String(row.generation_id));
|
|
5792
|
+
}
|
|
5793
|
+
if (tables.has("storage_generation_uploads")) {
|
|
5794
|
+
for (const row of db.prepare("SELECT generation_id FROM storage_generation_uploads WHERE storage_provider_id = 'fs' AND ready_receipt_id IS NOT NULL").all())
|
|
5795
|
+
required.add(String(row.generation_id));
|
|
5796
|
+
}
|
|
5797
|
+
if (!info(base)) {
|
|
5798
|
+
if (required.size && !preserveMissing)
|
|
5799
|
+
fail();
|
|
5800
|
+
return;
|
|
5801
|
+
}
|
|
5802
|
+
dir(root);
|
|
5803
|
+
dir(base);
|
|
5804
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
5805
|
+
const namespaceFile = path.join(base, "namespace.json");
|
|
5806
|
+
if (!info(namespaceFile) && preserveMissing)
|
|
5807
|
+
return;
|
|
5808
|
+
const ns = json(namespaceFile);
|
|
5809
|
+
if (ns.version !== 1 || !uuid.test(ns.namespaceId))
|
|
5810
|
+
fail();
|
|
5811
|
+
if (!tables.has("storage_object_generations"))
|
|
5812
|
+
fail();
|
|
5813
|
+
const workspaceRoot = path.join(base, "workspaces");
|
|
5814
|
+
if (!info(workspaceRoot) && preserveMissing)
|
|
5815
|
+
return;
|
|
5816
|
+
dir(workspaceRoot);
|
|
5817
|
+
const changes = [];
|
|
5818
|
+
for (const workspace of fs.readdirSync(workspaceRoot)) {
|
|
5819
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(workspace))
|
|
5820
|
+
fail();
|
|
5821
|
+
const work = path.join(workspaceRoot, workspace);
|
|
5822
|
+
dir(work);
|
|
5823
|
+
for (const id2 of fs.readdirSync(work)) {
|
|
5824
|
+
if (!uuid.test(id2))
|
|
5825
|
+
fail();
|
|
5826
|
+
const slot = path.join(work, id2);
|
|
5827
|
+
dir(slot);
|
|
5828
|
+
const file = path.join(slot, "ready.json");
|
|
5829
|
+
if (!info(file)) {
|
|
5830
|
+
if (required.has(id2) && !preserveMissing)
|
|
5831
|
+
fail();
|
|
5832
|
+
continue;
|
|
5833
|
+
}
|
|
5834
|
+
const ready = json(file);
|
|
5835
|
+
if (ready.version !== 1 || ready.namespaceId !== ns.namespaceId || ready.workspaceId !== workspace || ready.generationId !== id2 || !/^sha256:[a-f0-9]{64}$/.test(ready.hash) || !Number.isSafeInteger(ready.sizeBytes) || ready.sizeBytes < 0 || !uuid.test(ready.readyReceiptId) || ready.storageId !== `fs-generation-v1:${ns.namespaceId}:${workspace}:${id2}:${ready.hash.slice(7)}`)
|
|
5836
|
+
fail();
|
|
5837
|
+
const generation = db.prepare("SELECT * FROM storage_object_generations WHERE generation_id = ?").get(id2);
|
|
5838
|
+
const upload = tables.has("storage_generation_uploads") ? db.prepare("SELECT * FROM storage_generation_uploads WHERE generation_id = ?").get(id2) : null;
|
|
5839
|
+
const record = generation ?? upload;
|
|
5840
|
+
if (!record || record.workspace_id !== workspace || record.storage_provider_id !== "fs" || record.storage_id !== ready.storageId || record.hash.replace(/^sha256:/i, "").toLowerCase() !== ready.hash.slice(7) || record.size_bytes !== ready.sizeBytes)
|
|
5841
|
+
fail();
|
|
5842
|
+
if (upload && (upload.namespace_id !== ns.namespaceId || upload.ready_receipt_id && upload.ready_receipt_id !== ready.readyReceiptId))
|
|
5843
|
+
fail();
|
|
5844
|
+
required.delete(id2);
|
|
5845
|
+
const payload = path.join(slot, "payload");
|
|
5846
|
+
const payloadInfo = info(payload);
|
|
5847
|
+
const needsBlob = generation?.state === "verified";
|
|
5848
|
+
if (!payloadInfo) {
|
|
5849
|
+
if (needsBlob && !preserveMissing)
|
|
5850
|
+
fail();
|
|
5851
|
+
continue;
|
|
5852
|
+
}
|
|
5853
|
+
dir(payload);
|
|
5854
|
+
if (generation?.state === "deleted" || !generation && upload?.state === "abandoned")
|
|
5855
|
+
fail();
|
|
5856
|
+
const entries = fs.readdirSync(payload);
|
|
5857
|
+
const links = new Map;
|
|
5858
|
+
for (const entry of entries) {
|
|
5859
|
+
if (entry !== "blob" && !/^\.upload-[0-9a-f-]{36}$/.test(entry))
|
|
5860
|
+
fail();
|
|
5861
|
+
const s = info(path.join(payload, entry));
|
|
5862
|
+
if (!s?.isFile())
|
|
5863
|
+
fail();
|
|
5864
|
+
const key = `${s.dev}:${s.ino}`;
|
|
5865
|
+
const previous = links.get(key);
|
|
5866
|
+
links.set(key, { count: (previous?.count ?? 0n) + 1n, expected: s.nlink });
|
|
5867
|
+
}
|
|
5868
|
+
if ([...links.values()].some((value) => value.count !== value.expected))
|
|
5869
|
+
fail();
|
|
5870
|
+
const blob = path.join(payload, "blob");
|
|
5871
|
+
if (info(blob)) {
|
|
5872
|
+
const fd = fs.openSync(blob, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
5873
|
+
try {
|
|
5874
|
+
const hash = crypto.createHash("sha256"), buffer = Buffer.alloc(65536);
|
|
5875
|
+
let size = 0, count = 0;
|
|
5876
|
+
while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
|
|
5877
|
+
hash.update(buffer.subarray(0, count));
|
|
5878
|
+
size += count;
|
|
5879
|
+
}
|
|
5880
|
+
if (size !== ready.sizeBytes || hash.digest("hex") !== ready.hash.slice(7))
|
|
5881
|
+
fail();
|
|
5882
|
+
} finally {
|
|
5883
|
+
fs.closeSync(fd);
|
|
5884
|
+
}
|
|
5885
|
+
} else if (needsBlob) {
|
|
5886
|
+
if (!preserveMissing)
|
|
5887
|
+
fail();
|
|
5888
|
+
continue;
|
|
5889
|
+
}
|
|
5890
|
+
changes.push({ file, ready: { ...ready, payloadDevice: String(payloadInfo.dev), payloadInode: String(payloadInfo.ino) } });
|
|
5891
|
+
}
|
|
5892
|
+
}
|
|
5893
|
+
if (required.size && !preserveMissing)
|
|
5894
|
+
fail();
|
|
5895
|
+
for (const { file, ready } of changes) {
|
|
5896
|
+
const temp = file + ".restore-" + crypto.randomUUID();
|
|
5897
|
+
const fd = fs.openSync(temp, "wx", 384);
|
|
5898
|
+
try {
|
|
5899
|
+
fs.writeFileSync(fd, JSON.stringify(ready));
|
|
5900
|
+
fs.fsyncSync(fd);
|
|
5901
|
+
} finally {
|
|
5902
|
+
fs.closeSync(fd);
|
|
5903
|
+
}
|
|
5904
|
+
fs.renameSync(temp, file);
|
|
5905
|
+
const directory = fs.openSync(path.dirname(file), "r");
|
|
5906
|
+
try {
|
|
5907
|
+
fs.fsyncSync(directory);
|
|
5908
|
+
} finally {
|
|
5909
|
+
fs.closeSync(directory);
|
|
5910
|
+
}
|
|
5911
|
+
}
|
|
5912
|
+
}
|
|
5913
|
+
|
|
4519
5914
|
// src/backup/restore.ts
|
|
4520
|
-
|
|
4521
|
-
|
|
5915
|
+
async function restoreGenerationIdentities(directory, state, env, backupPath, options = {}) {
|
|
5916
|
+
await readManifest(backupPath, directory);
|
|
5917
|
+
const script = `
|
|
5918
|
+
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
|
5919
|
+
const root = path.resolve(process.env.OR3_STORAGE_FS_ROOT || '/data/storage');
|
|
5920
|
+
if (root !== '/data' && !root.startsWith('/data/')) throw new Error('Generation restore requires managed storage');
|
|
5921
|
+
{
|
|
5922
|
+
const Database = require('/app/.output/server/node_modules/better-sqlite3');
|
|
5923
|
+
const db = new Database(process.env.OR3_SQLITE_DB_PATH, { readonly: true, fileMustExist: true });
|
|
5924
|
+
try {
|
|
5925
|
+
if (db.pragma('quick_check', { simple: true }) !== 'ok') throw new Error('Restored SQLite integrity check failed');
|
|
5926
|
+
(${rebindRestoredGenerations.toString()})(fs, path, crypto, db, root, ${JSON.stringify({ previousSafetySnapshot: options.previousSafetySnapshot === true })});
|
|
5927
|
+
} finally { db.close(); }
|
|
5928
|
+
}`;
|
|
5929
|
+
const result = await run("docker", [
|
|
5930
|
+
"run",
|
|
5931
|
+
"--rm",
|
|
5932
|
+
"--network",
|
|
5933
|
+
"none",
|
|
5934
|
+
"--read-only",
|
|
5935
|
+
"--security-opt",
|
|
5936
|
+
"no-new-privileges:true",
|
|
5937
|
+
"--cap-drop",
|
|
5938
|
+
"ALL",
|
|
5939
|
+
"-v",
|
|
5940
|
+
`${state.volumeName}:/data`,
|
|
5941
|
+
"--env",
|
|
5942
|
+
`OR3_SQLITE_DB_PATH=${env.OR3_SQLITE_DB_PATH ?? ""}`,
|
|
5943
|
+
"--env",
|
|
5944
|
+
`OR3_STORAGE_FS_ROOT=${env.OR3_STORAGE_FS_ROOT ?? "/data/storage"}`,
|
|
5945
|
+
"--entrypoint",
|
|
5946
|
+
"sh",
|
|
5947
|
+
env.OR3_IMAGE,
|
|
5948
|
+
...containerNodeCommand(script).slice(1)
|
|
5949
|
+
], directory);
|
|
5950
|
+
if (!result.ok)
|
|
5951
|
+
throw new Error(`Restored generation verification failed. ${redact(result.stderr, secretValues(env))}`);
|
|
5952
|
+
}
|
|
5953
|
+
function assertFilesystemPayloadHash(key, readChunk, createHash5) {
|
|
4522
5954
|
const match = /^(sha256|md5)_([a-fA-F0-9]+)$/.exec(key);
|
|
4523
5955
|
const algorithm = match?.[1];
|
|
4524
5956
|
const expected = match?.[2];
|
|
4525
5957
|
if (algorithm !== "sha256" && algorithm !== "md5" || expected?.length !== (algorithm === "sha256" ? 64 : 32)) {
|
|
4526
5958
|
throw new Error("Invalid canonical filesystem payload key");
|
|
4527
5959
|
}
|
|
4528
|
-
const hash =
|
|
5960
|
+
const hash = createHash5(algorithm);
|
|
4529
5961
|
for (let chunk = readChunk();chunk !== undefined; chunk = readChunk())
|
|
4530
5962
|
hash.update(chunk);
|
|
4531
5963
|
if (hash.digest("hex") !== expected.toLowerCase())
|
|
@@ -4535,8 +5967,10 @@ var VERIFY_RESTORED_DATA_SCRIPT = `
|
|
|
4535
5967
|
const fs = require('node:fs');
|
|
4536
5968
|
const Database = require('/app/.output/server/node_modules/better-sqlite3');
|
|
4537
5969
|
const validFilesystemPayload = ${assertFilesystemPayloadHash.toString()};
|
|
4538
|
-
const
|
|
4539
|
-
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');
|
|
4540
5974
|
for (const path of paths) {
|
|
4541
5975
|
const databasePath = fs.realpathSync(path);
|
|
4542
5976
|
if (!fs.statSync(databasePath).isFile()) throw new Error('Restored database payload must be a regular file');
|
|
@@ -4557,7 +5991,7 @@ const path = require('node:path');
|
|
|
4557
5991
|
const crypto = require('node:crypto');
|
|
4558
5992
|
const storageRoot = path.resolve(process.env.OR3_STORAGE_FS_ROOT || '/data/storage');
|
|
4559
5993
|
const workspacesRoot = path.join(storageRoot, 'workspaces');
|
|
4560
|
-
if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
|
|
5994
|
+
if (storage === 'fs' && (storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
|
|
4561
5995
|
const chunk = Buffer.alloc(65536);
|
|
4562
5996
|
for (const workspace of fs.readdirSync(workspacesRoot, { withFileTypes: true }).filter(entry => /^[a-zA-Z0-9_-]+$/.test(entry.name))) {
|
|
4563
5997
|
const directory = path.join(workspacesRoot, workspace.name);
|
|
@@ -4579,7 +6013,7 @@ if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSy
|
|
|
4579
6013
|
}
|
|
4580
6014
|
`;
|
|
4581
6015
|
async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPath) {
|
|
4582
|
-
const archiveBytes = await gzipUncompressedBytes(
|
|
6016
|
+
const archiveBytes = await gzipUncompressedBytes(join24(backupPath, "data.tgz"));
|
|
4583
6017
|
const requiredBytes = Math.max(manifest2.dataBytes ?? 0, archiveBytes);
|
|
4584
6018
|
const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
|
|
4585
6019
|
const reclaimableBytes = await dataVolumeSize(directory, state.mode, env, "allocated");
|
|
@@ -4588,15 +6022,32 @@ async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPa
|
|
|
4588
6022
|
async function restoreBackupData(directory, state, env, backupPath, options = {}) {
|
|
4589
6023
|
const manifest2 = await readManifest(backupPath, directory);
|
|
4590
6024
|
assertRestorableManagedAssets(manifest2);
|
|
4591
|
-
const backupEnv = parseEnv(await readText(
|
|
6025
|
+
const backupEnv = parseEnv(await readText(join24(backupPath, "config.env")));
|
|
4592
6026
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
4593
6027
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
4594
6028
|
}
|
|
4595
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
|
+
}
|
|
4596
6035
|
if (manifest2.imageDigest)
|
|
4597
6036
|
await requireImageDigest(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
|
|
6037
|
+
await assertExistingVaultCustody(directory, state, manifest2, backupPath, backupEnv);
|
|
4598
6038
|
await validateVolumeArchive(directory, state.mode, env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
|
|
4599
|
-
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) {
|
|
4600
6051
|
await validateCanonicalBackupReferences(directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
|
|
4601
6052
|
} else {
|
|
4602
6053
|
console.warn("Restoring the recorded byte-preservation safety snapshot; canonical attachment completeness is not certified. Verify previously uploaded files.");
|
|
@@ -4605,10 +6056,10 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
|
|
|
4605
6056
|
await assertRestoreFreeSpace(directory, state, env, manifest2, backupPath);
|
|
4606
6057
|
const restoresOperatorOverlay = [3, MANAGED_ASSET_INVENTORY_VERSION].includes(manifest2.managedAssetInventoryVersion ?? 1) && Boolean(manifest2.managedAssetSha256?.["compose.operator.yaml"]);
|
|
4607
6058
|
const restoredEnv = restoresOperatorOverlay ? {
|
|
4608
|
-
...backupEnv,
|
|
6059
|
+
...restoreStorageConfiguration(backupEnv, env),
|
|
4609
6060
|
OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest)
|
|
4610
6061
|
} : withoutDashboardOperator({
|
|
4611
|
-
...backupEnv,
|
|
6062
|
+
...restoreStorageConfiguration(backupEnv, env),
|
|
4612
6063
|
OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest)
|
|
4613
6064
|
});
|
|
4614
6065
|
if (options.recreateDataVolume)
|
|
@@ -4638,6 +6089,10 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
|
|
|
4638
6089
|
"--env",
|
|
4639
6090
|
`OR3_SQLITE_DB_PATH=${restoredEnv.OR3_SQLITE_DB_PATH ?? ""}`,
|
|
4640
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",
|
|
4641
6096
|
`OR3_STORAGE_FS_ROOT=${restoredEnv.OR3_STORAGE_FS_ROOT ?? "/data/storage"}`,
|
|
4642
6097
|
"--entrypoint",
|
|
4643
6098
|
"sh",
|
|
@@ -4647,8 +6102,19 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
|
|
|
4647
6102
|
if (!snapshotCheck.ok) {
|
|
4648
6103
|
throw new Error(`Restored snapshot integrity verification failed. ${redact(snapshotCheck.stderr, secretValues(restoredEnv))}`);
|
|
4649
6104
|
}
|
|
6105
|
+
if (manifest2.schemaVersion === 1)
|
|
6106
|
+
await restoreGenerationIdentities(directory, state, restoredEnv, backupPath, { previousSafetySnapshot: options.previousSafetySnapshot });
|
|
4650
6107
|
await options.beforeStart?.();
|
|
4651
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
|
+
}
|
|
4652
6118
|
return manifest2;
|
|
4653
6119
|
}
|
|
4654
6120
|
async function restorePreMutationSnapshot(directory, state, env) {
|
|
@@ -4658,30 +6124,30 @@ async function restorePreMutationSnapshot(directory, state, env) {
|
|
|
4658
6124
|
const previous = await recordedBackupPath(directory, pending);
|
|
4659
6125
|
await updatePending(directory, state, { phase: "restoring-previous" });
|
|
4660
6126
|
if (pending.recreateDataVolume) {
|
|
4661
|
-
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" });
|
|
4662
6128
|
return previous;
|
|
4663
6129
|
}
|
|
4664
6130
|
if (pending.previousRootOwnership) {
|
|
4665
6131
|
await pullAndRequireImage(state.image, state.imageDigest, "Previous deployment");
|
|
4666
6132
|
await setManagedVolumeRootOwnership(directory, state, env, state.image, pending.previousRootOwnership);
|
|
4667
6133
|
}
|
|
4668
|
-
await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true });
|
|
6134
|
+
await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true, remoteReferencePolicy: "warn" });
|
|
4669
6135
|
return previous;
|
|
4670
6136
|
}
|
|
4671
6137
|
|
|
4672
6138
|
// src/deployment/observation.ts
|
|
4673
|
-
import { createHash as
|
|
4674
|
-
import { resolve as
|
|
6139
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
6140
|
+
import { resolve as resolve14 } from "node:path";
|
|
4675
6141
|
|
|
4676
6142
|
// src/deployment/lease.ts
|
|
4677
6143
|
import { randomBytes as randomBytes8 } from "node:crypto";
|
|
4678
|
-
import { mkdir as mkdir10, rename as rename3, rm as
|
|
4679
|
-
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";
|
|
4680
6146
|
|
|
4681
6147
|
// ../../shared/server/restarts/controller.ts
|
|
4682
|
-
import { randomUUID as
|
|
4683
|
-
import { mkdir as mkdir9, open as
|
|
4684
|
-
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";
|
|
4685
6151
|
|
|
4686
6152
|
// ../../node_modules/zod/v4/classic/external.js
|
|
4687
6153
|
var exports_external = {};
|
|
@@ -9730,7 +11196,7 @@ var recursive = /* @__PURE__ */ new WeakMap;
|
|
|
9730
11196
|
var NONE = 0;
|
|
9731
11197
|
var ASSUMED = 1;
|
|
9732
11198
|
var PROVEN = 2;
|
|
9733
|
-
function isRecursive(inst, stack,
|
|
11199
|
+
function isRecursive(inst, stack, resolve14) {
|
|
9734
11200
|
const cached2 = recursive.get(inst);
|
|
9735
11201
|
if (cached2 !== undefined)
|
|
9736
11202
|
return cached2 ? PROVEN : NONE;
|
|
@@ -9740,7 +11206,7 @@ function isRecursive(inst, stack, resolve13) {
|
|
|
9740
11206
|
let result = NONE;
|
|
9741
11207
|
const check = (child) => {
|
|
9742
11208
|
if (result !== PROVEN && child?._zod) {
|
|
9743
|
-
const answer = isRecursive(child, stack,
|
|
11209
|
+
const answer = isRecursive(child, stack, resolve14);
|
|
9744
11210
|
if (answer > result)
|
|
9745
11211
|
result = answer;
|
|
9746
11212
|
}
|
|
@@ -9751,7 +11217,7 @@ function isRecursive(inst, stack, resolve13) {
|
|
|
9751
11217
|
const desc = Object.getOwnPropertyDescriptor(sh, key);
|
|
9752
11218
|
if (spread && !desc.enumerable)
|
|
9753
11219
|
continue;
|
|
9754
|
-
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;
|
|
9755
11221
|
if (child > answer)
|
|
9756
11222
|
answer = child;
|
|
9757
11223
|
}
|
|
@@ -9814,7 +11280,7 @@ function isRecursive(inst, stack, resolve13) {
|
|
|
9814
11280
|
check(def.output);
|
|
9815
11281
|
break;
|
|
9816
11282
|
case "lazy": {
|
|
9817
|
-
const inner = def._cachedInner ?? (
|
|
11283
|
+
const inner = def._cachedInner ?? (resolve14 ? inst._zod.innerType : undefined);
|
|
9818
11284
|
merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
|
|
9819
11285
|
break;
|
|
9820
11286
|
}
|
|
@@ -9875,7 +11341,7 @@ function bucketFor(state, inst) {
|
|
|
9875
11341
|
return bucket;
|
|
9876
11342
|
}
|
|
9877
11343
|
var handoff;
|
|
9878
|
-
var
|
|
11344
|
+
var open3 = [];
|
|
9879
11345
|
var memo = {
|
|
9880
11346
|
alloc(_inst, payload, empty) {
|
|
9881
11347
|
const bucket = handoff;
|
|
@@ -9884,7 +11350,7 @@ var memo = {
|
|
|
9884
11350
|
handoff = undefined;
|
|
9885
11351
|
const entry = { value: empty, issues: null };
|
|
9886
11352
|
bucket.set(payload.value, entry);
|
|
9887
|
-
|
|
11353
|
+
open3.push(entry);
|
|
9888
11354
|
return empty;
|
|
9889
11355
|
},
|
|
9890
11356
|
guard(inst) {
|
|
@@ -9955,10 +11421,10 @@ var memo = {
|
|
|
9955
11421
|
return payload;
|
|
9956
11422
|
}
|
|
9957
11423
|
handoff = bucket;
|
|
9958
|
-
const depth =
|
|
11424
|
+
const depth = open3.length;
|
|
9959
11425
|
const result = base(payload, ctx);
|
|
9960
11426
|
handoff = undefined;
|
|
9961
|
-
const entry =
|
|
11427
|
+
const entry = open3.length > depth ? open3.pop() : undefined;
|
|
9962
11428
|
if (result instanceof Promise) {
|
|
9963
11429
|
return result.then((r) => {
|
|
9964
11430
|
if (entry)
|
|
@@ -24130,7 +25596,7 @@ var recordSchema = exports_external.object({
|
|
|
24130
25596
|
expected: exports_external.record(exports_external.string(), exports_external.union([exports_external.string().max(128), exports_external.boolean()])),
|
|
24131
25597
|
executionIntent: exports_external.boolean(),
|
|
24132
25598
|
runtimeIdentity: exports_external.string().max(512).optional()
|
|
24133
|
-
}).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");
|
|
24134
25600
|
|
|
24135
25601
|
class RestartError extends Error {
|
|
24136
25602
|
code;
|
|
@@ -24144,15 +25610,15 @@ class RestartError extends Error {
|
|
|
24144
25610
|
var resolved = new Set(["succeeded", "cancelled", "failed"]);
|
|
24145
25611
|
var replayWindowMs = 24 * 60 * 60 * 1000;
|
|
24146
25612
|
async function readRestartJournal(path) {
|
|
24147
|
-
const text = await
|
|
25613
|
+
const text = await readFile5(path, "utf8");
|
|
24148
25614
|
if (Buffer.byteLength(text) > 512 * 1024)
|
|
24149
25615
|
throw new RestartError("journal_invalid");
|
|
24150
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));
|
|
24151
25617
|
}
|
|
24152
25618
|
async function writePrivateJson(path, value) {
|
|
24153
|
-
const temporary = `${path}.${
|
|
25619
|
+
const temporary = `${path}.${randomUUID5()}.tmp`;
|
|
24154
25620
|
try {
|
|
24155
|
-
const file2 = await
|
|
25621
|
+
const file2 = await open4(temporary, "wx", 384);
|
|
24156
25622
|
try {
|
|
24157
25623
|
await file2.writeFile(JSON.stringify(value));
|
|
24158
25624
|
await file2.sync();
|
|
@@ -24160,14 +25626,14 @@ async function writePrivateJson(path, value) {
|
|
|
24160
25626
|
await file2.close();
|
|
24161
25627
|
}
|
|
24162
25628
|
await rename2(temporary, path);
|
|
24163
|
-
const directory = await
|
|
25629
|
+
const directory = await open4(dirname6(path), "r");
|
|
24164
25630
|
try {
|
|
24165
25631
|
await directory.sync();
|
|
24166
25632
|
} finally {
|
|
24167
25633
|
await directory.close();
|
|
24168
25634
|
}
|
|
24169
25635
|
} finally {
|
|
24170
|
-
await
|
|
25636
|
+
await rm9(temporary, { force: true });
|
|
24171
25637
|
}
|
|
24172
25638
|
}
|
|
24173
25639
|
function publicOperation(o) {
|
|
@@ -24194,7 +25660,7 @@ class RestartController {
|
|
|
24194
25660
|
constructor(directory, driver) {
|
|
24195
25661
|
this.directory = directory;
|
|
24196
25662
|
this.driver = driver;
|
|
24197
|
-
this.path =
|
|
25663
|
+
this.path = join25(directory, "restarts.json");
|
|
24198
25664
|
}
|
|
24199
25665
|
lock(action) {
|
|
24200
25666
|
const next = this.serial.then(action, action);
|
|
@@ -24284,7 +25750,7 @@ class RestartController {
|
|
|
24284
25750
|
if (!parsed.success)
|
|
24285
25751
|
throw new RestartError("invalid_request", 400);
|
|
24286
25752
|
const request = parsed.data;
|
|
24287
|
-
const fingerprint =
|
|
25753
|
+
const fingerprint = createHash5("sha256").update(JSON.stringify(request)).digest("hex");
|
|
24288
25754
|
return this.lock(async () => {
|
|
24289
25755
|
const old = this.operations.find((o2) => o2.requestId === request.requestId);
|
|
24290
25756
|
if (old) {
|
|
@@ -24410,7 +25876,7 @@ class RestartController {
|
|
|
24410
25876
|
});
|
|
24411
25877
|
return;
|
|
24412
25878
|
}
|
|
24413
|
-
await new Promise((
|
|
25879
|
+
await new Promise((resolve14) => setTimeout(resolve14, 250));
|
|
24414
25880
|
}
|
|
24415
25881
|
if (this.disposed)
|
|
24416
25882
|
return;
|
|
@@ -24433,7 +25899,7 @@ class RestartController {
|
|
|
24433
25899
|
throw new RestartError("configuration_unsupported", 501);
|
|
24434
25900
|
if ((await this.driver.configuration()).revision !== expectedRevision)
|
|
24435
25901
|
throw new RestartError("stale_revision");
|
|
24436
|
-
const release = await this.driver.acquire(
|
|
25902
|
+
const release = await this.driver.acquire(randomUUID5());
|
|
24437
25903
|
try {
|
|
24438
25904
|
await this.driver.save(updates, expectedRevision);
|
|
24439
25905
|
} finally {
|
|
@@ -24492,7 +25958,7 @@ class RestartController {
|
|
|
24492
25958
|
var DASHBOARD_LEASE_STALE_MS = 30000;
|
|
24493
25959
|
async function readLeaseOwner(leasePath) {
|
|
24494
25960
|
try {
|
|
24495
|
-
const owner = JSON.parse(await readText(
|
|
25961
|
+
const owner = JSON.parse(await readText(join26(leasePath, "owner.json")));
|
|
24496
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")
|
|
24497
25963
|
return;
|
|
24498
25964
|
return owner;
|
|
@@ -24540,7 +26006,7 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
|
|
|
24540
26006
|
const stalePath = `${leasePath}.stale-${randomBytes8(8).toString("hex")}`;
|
|
24541
26007
|
try {
|
|
24542
26008
|
await rename3(leasePath, stalePath);
|
|
24543
|
-
await
|
|
26009
|
+
await rm10(stalePath, { recursive: true, force: true });
|
|
24544
26010
|
await mkdir10(leasePath, { recursive: false, mode: 448 });
|
|
24545
26011
|
} catch (reclaimError) {
|
|
24546
26012
|
throw new Error(`Another OR3 Cloud operation owns ${directory}. Refusing to race its lifecycle lock. ${reclaimError instanceof Error ? reclaimError.message : String(reclaimError)}`);
|
|
@@ -24551,17 +26017,17 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
|
|
|
24551
26017
|
}
|
|
24552
26018
|
}
|
|
24553
26019
|
try {
|
|
24554
|
-
await writeSecure(
|
|
26020
|
+
await writeSecure(join26(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
|
|
24555
26021
|
`);
|
|
24556
26022
|
} catch (error62) {
|
|
24557
|
-
await
|
|
26023
|
+
await rm10(leasePath, { recursive: true, force: true }).catch(() => {
|
|
24558
26024
|
return;
|
|
24559
26025
|
});
|
|
24560
26026
|
throw error62;
|
|
24561
26027
|
}
|
|
24562
26028
|
const heartbeat = setInterval(() => {
|
|
24563
26029
|
owner.heartbeatAt = now();
|
|
24564
|
-
writeSecure(
|
|
26030
|
+
writeSecure(join26(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
|
|
24565
26031
|
`).catch(() => {
|
|
24566
26032
|
return;
|
|
24567
26033
|
});
|
|
@@ -24571,7 +26037,7 @@ async function acquireDeploymentLease(directory, command, dashboardJobId) {
|
|
|
24571
26037
|
clearInterval(heartbeat);
|
|
24572
26038
|
const current = await readLeaseOwner(leasePath);
|
|
24573
26039
|
if (current?.nonce === nonce)
|
|
24574
|
-
await
|
|
26040
|
+
await rm10(leasePath, { recursive: true, force: true });
|
|
24575
26041
|
};
|
|
24576
26042
|
}
|
|
24577
26043
|
async function withDeploymentLease(directory, command, action) {
|
|
@@ -24586,7 +26052,7 @@ async function withDeploymentLease(directory, command, action) {
|
|
|
24586
26052
|
}
|
|
24587
26053
|
async function assertRestartJournalSettled(directory) {
|
|
24588
26054
|
try {
|
|
24589
|
-
const journal = await readRestartJournal(
|
|
26055
|
+
const journal = await readRestartJournal(join26(directory, ".or3-cloud", "restarts", "restarts.json"));
|
|
24590
26056
|
if (journal.operations.some((operation) => !["succeeded", "cancelled", "failed"].includes(operation.phase))) {
|
|
24591
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.");
|
|
24592
26058
|
}
|
|
@@ -24600,8 +26066,19 @@ async function assertRestartJournalSettled(directory) {
|
|
|
24600
26066
|
}
|
|
24601
26067
|
|
|
24602
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
|
+
}
|
|
24603
26080
|
async function observeDeployment(directory, options = {}) {
|
|
24604
|
-
const resolved2 =
|
|
26081
|
+
const resolved2 = resolve14(directory);
|
|
24605
26082
|
const observedAt = now();
|
|
24606
26083
|
let state = null;
|
|
24607
26084
|
let stateError = null;
|
|
@@ -24728,6 +26205,7 @@ function publicStateProjection(state) {
|
|
|
24728
26205
|
dashboardJobId: pending.dashboardJobId ?? null,
|
|
24729
26206
|
targetVersion: pending.targetVersion ?? null,
|
|
24730
26207
|
targetImageDigest: pending.targetImageDigest ?? null,
|
|
26208
|
+
remoteReconcile: pending.remoteReconcile ?? null,
|
|
24731
26209
|
backupProgress: pending.backupProgress ? {
|
|
24732
26210
|
stage: pending.backupProgress.stage,
|
|
24733
26211
|
service: pending.backupProgress.service,
|
|
@@ -24757,7 +26235,7 @@ function observationFindings(observation) {
|
|
|
24757
26235
|
return findings;
|
|
24758
26236
|
}
|
|
24759
26237
|
function stateFingerprint(state) {
|
|
24760
|
-
return
|
|
26238
|
+
return createHash6("sha256").update(JSON.stringify({
|
|
24761
26239
|
schema: state.schemaVersion,
|
|
24762
26240
|
appVersion: state.appVersion,
|
|
24763
26241
|
image: state.image,
|
|
@@ -24789,6 +26267,9 @@ function decideRecoveryAction(state) {
|
|
|
24789
26267
|
}
|
|
24790
26268
|
return { action: "none", detail: "No incomplete operation is recorded." };
|
|
24791
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
|
+
}
|
|
24792
26273
|
if (pending.operation === "update" && pending.phase === "target-ready" && pending.evidence) {
|
|
24793
26274
|
return { action: "finish", detail: "A completed replacement carries durable target-ready evidence and can be committed without restoring data." };
|
|
24794
26275
|
}
|
|
@@ -24830,6 +26311,7 @@ async function reconcileOperatorHandoff(directory, state, report = console.log)
|
|
|
24830
26311
|
return detail;
|
|
24831
26312
|
}
|
|
24832
26313
|
async function collectTargetReadyChecks(directory, mode, env) {
|
|
26314
|
+
const profile = managedProviderProfile(env);
|
|
24833
26315
|
const checks3 = [];
|
|
24834
26316
|
let containerId;
|
|
24835
26317
|
const container = await run("docker", composeArgs(directory, mode, ["ps", "-q", "or3"]), directory);
|
|
@@ -24842,16 +26324,16 @@ async function collectTargetReadyChecks(directory, mode, env) {
|
|
|
24842
26324
|
if (databaseCheck.ok) {
|
|
24843
26325
|
try {
|
|
24844
26326
|
const databases = JSON.parse(databaseCheck.stdout.trim());
|
|
24845
|
-
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);
|
|
24846
26328
|
} catch {
|
|
24847
26329
|
databasesOk = false;
|
|
24848
26330
|
}
|
|
24849
26331
|
}
|
|
24850
|
-
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))}` });
|
|
24851
26333
|
if (mode === "public") {
|
|
24852
26334
|
const baseUrl = new URL(`https://${env.OR3_PUBLIC_DOMAIN}`);
|
|
24853
26335
|
try {
|
|
24854
|
-
validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"));
|
|
26336
|
+
validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"), profile);
|
|
24855
26337
|
checks3.push({ code: "public-health", status: "passed", detail: `${baseUrl.origin} deep health reports the managed profile.` });
|
|
24856
26338
|
} catch (error62) {
|
|
24857
26339
|
checks3.push({ code: "public-health", status: "failed", detail: redact(error62 instanceof Error ? error62.message : String(error62)) });
|
|
@@ -25049,6 +26531,8 @@ async function runRecovery(directory, context) {
|
|
|
25049
26531
|
const loaded = await loadManaged(directory);
|
|
25050
26532
|
const pending = loaded.state.incompleteOperation;
|
|
25051
26533
|
const decision = decideRecoveryAction(loaded.state);
|
|
26534
|
+
if (pending || decision.action === "reconcile-handoff")
|
|
26535
|
+
assertLegacyManagedProfile(loaded.env);
|
|
25052
26536
|
if (decision.action === "reconcile-handoff") {
|
|
25053
26537
|
if (explicitRestore)
|
|
25054
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.");
|
|
@@ -25075,7 +26559,18 @@ async function runRecovery(directory, context) {
|
|
|
25075
26559
|
const detail = await finishTargetReadyUpdate(loaded.directory, loaded.state, loaded.env, report);
|
|
25076
26560
|
return { kind: "recovered", operation: "update", detail };
|
|
25077
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);
|
|
25078
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
|
+
}
|
|
25079
26574
|
try {
|
|
25080
26575
|
if (pending.operation === "backup") {
|
|
25081
26576
|
const failures = [];
|
|
@@ -25196,6 +26691,7 @@ async function runRecovery(directory, context) {
|
|
|
25196
26691
|
if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
|
|
25197
26692
|
throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
|
|
25198
26693
|
}
|
|
26694
|
+
await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
|
|
25199
26695
|
await stopProject(loaded.directory, loaded.state.mode);
|
|
25200
26696
|
await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
|
|
25201
26697
|
}
|
|
@@ -25220,84 +26716,29 @@ async function runRecovery(directory, context) {
|
|
|
25220
26716
|
} catch (error62) {
|
|
25221
26717
|
const recoverySecrets = pending.operation === "credentials-reset" ? secretValues(pending.credentialReset?.nextEnv ?? loaded.env) : secretValues(loaded.env);
|
|
25222
26718
|
loaded.state.lastError = redact(error62 instanceof Error ? error62.message : String(error62), recoverySecrets);
|
|
25223
|
-
if (pending.operation === "adopt" && pending.sourceDirectory) {
|
|
25224
|
-
try {
|
|
25225
|
-
await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
|
|
25226
|
-
return;
|
|
25227
|
-
});
|
|
25228
|
-
const sourceEnv = parseEnv(await readText(join24(pending.sourceDirectory, ".env")));
|
|
25229
|
-
const sourceFiles = ["-f", join24(pending.sourceDirectory, "compose.yaml")];
|
|
25230
|
-
const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
|
|
25231
|
-
if (sourceModeValue === "public")
|
|
25232
|
-
sourceFiles.push("-f", join24(pending.sourceDirectory, "compose.public.yaml"));
|
|
25233
|
-
if (pending.sourceInitiallyRunning !== false) {
|
|
25234
|
-
await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
|
|
25235
|
-
}
|
|
25236
|
-
} catch (recovery) {
|
|
25237
|
-
loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
25238
|
-
}
|
|
25239
|
-
}
|
|
25240
26719
|
await writeState(loaded.directory, loaded.state);
|
|
25241
26720
|
throw new Error(`Recovery could not safely complete ${pending.operation}. Keep the operation record and run doctor. ${loaded.state.lastError}`);
|
|
25242
26721
|
}
|
|
25243
26722
|
}
|
|
25244
26723
|
|
|
25245
26724
|
// src/commands/remove.ts
|
|
25246
|
-
import { rm as rm10 } from "node:fs/promises";
|
|
25247
|
-
import { join as join25 } from "node:path";
|
|
25248
|
-
function purgeVolumesFromState(state) {
|
|
25249
|
-
const volumes = [state.volumeName];
|
|
25250
|
-
if (state.mode === "public") {
|
|
25251
|
-
if (state.caddyDataVolume)
|
|
25252
|
-
volumes.push(state.caddyDataVolume);
|
|
25253
|
-
if (state.caddyConfigVolume)
|
|
25254
|
-
volumes.push(state.caddyConfigVolume);
|
|
25255
|
-
}
|
|
25256
|
-
return volumes;
|
|
25257
|
-
}
|
|
25258
26725
|
async function removeCommand(directory, flags) {
|
|
25259
26726
|
if (boolFlag(flags, "purge-data") && !boolFlag(flags, "yes")) {
|
|
25260
|
-
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.");
|
|
25261
26728
|
}
|
|
25262
26729
|
await ensureDocker();
|
|
25263
26730
|
const loaded = await loadManaged(directory);
|
|
25264
26731
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
25265
26732
|
assertNoPending(loaded.state);
|
|
25266
|
-
|
|
25267
|
-
|
|
25268
|
-
await compose(directory, state.mode, ["down"]);
|
|
25269
|
-
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.`);
|
|
25270
|
-
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.");
|
|
25271
26735
|
}
|
|
25272
|
-
|
|
25273
|
-
|
|
25274
|
-
const managedFiles = [
|
|
25275
|
-
".env",
|
|
25276
|
-
".or3-cloud",
|
|
25277
|
-
...managedAssetNames(state.mode),
|
|
25278
|
-
".or3-initial-credentials"
|
|
25279
|
-
];
|
|
25280
|
-
console.log("Removing exactly these targets:");
|
|
25281
|
-
for (const volume of volumes)
|
|
25282
|
-
console.log(` docker volume ${volume}`);
|
|
25283
|
-
for (const file2 of managedFiles)
|
|
25284
|
-
console.log(` ${join25(directory, file2)}`);
|
|
25285
|
-
await compose(directory, state.mode, ["down"]);
|
|
25286
|
-
await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
|
|
25287
|
-
for (const volume of volumes) {
|
|
25288
|
-
const result = await run("docker", ["volume", "rm", volume], directory);
|
|
25289
|
-
if (!result.ok)
|
|
25290
|
-
throw new Error(`${result.command}
|
|
25291
|
-
${result.stderr}`);
|
|
25292
|
-
}
|
|
25293
|
-
for (const file2 of managedFiles) {
|
|
25294
|
-
await rm10(join25(directory, file2), { recursive: true, force: true });
|
|
25295
|
-
}
|
|
25296
|
-
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.`);
|
|
25297
26738
|
}
|
|
25298
26739
|
|
|
25299
26740
|
// src/commands/restore.ts
|
|
25300
|
-
import { join as
|
|
26741
|
+
import { join as join27 } from "node:path";
|
|
25301
26742
|
async function restoreCommand(directory, flags, positionals) {
|
|
25302
26743
|
if (!boolFlag(flags, "yes"))
|
|
25303
26744
|
throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
|
|
@@ -25310,18 +26751,26 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
25310
26751
|
const backupPath = await resolveBackup(loaded.directory, backupValue);
|
|
25311
26752
|
const manifest2 = await readManifest(backupPath, loaded.directory);
|
|
25312
26753
|
assertRestorableManagedAssets(manifest2);
|
|
25313
|
-
const backupEnv = parseEnv(await readText(
|
|
26754
|
+
const backupEnv = parseEnv(await readText(join27(backupPath, "config.env")));
|
|
25314
26755
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
25315
26756
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
25316
26757
|
}
|
|
25317
26758
|
assertBackupMatchesDeployment(manifest2, backupEnv, loaded.state, loaded.env);
|
|
26759
|
+
assertSupportedBackupProfile(loaded.env);
|
|
26760
|
+
await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
|
|
25318
26761
|
const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
|
|
25319
26762
|
if (manifest2.imageDigest)
|
|
25320
26763
|
await pullAndRequireImage(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
|
|
25321
26764
|
await assertSupportedHostArchitecture(manifest2.image);
|
|
26765
|
+
await assertExistingVaultCustody(loaded.directory, loaded.state, manifest2, backupPath, backupEnv);
|
|
25322
26766
|
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
|
|
25323
26767
|
await validateVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
|
|
25324
|
-
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.`);
|
|
25325
26774
|
const pending = {
|
|
25326
26775
|
id: id("restore"),
|
|
25327
26776
|
operation: "restore",
|
|
@@ -25335,6 +26784,7 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
25335
26784
|
targetImage: manifest2.image,
|
|
25336
26785
|
targetImageDigest: manifest2.imageDigest,
|
|
25337
26786
|
recreateDataVolume,
|
|
26787
|
+
remoteReconcile,
|
|
25338
26788
|
phase: "prepared"
|
|
25339
26789
|
};
|
|
25340
26790
|
await markPending(loaded.directory, loaded.state, pending);
|
|
@@ -25386,6 +26836,8 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
25386
26836
|
throw new Error(recoveredMessage);
|
|
25387
26837
|
}
|
|
25388
26838
|
try {
|
|
26839
|
+
if (managedVaultEnabled(loaded.directory, loaded.env))
|
|
26840
|
+
await assertVaultVolumesExist(loaded.directory, loaded.state);
|
|
25389
26841
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
25390
26842
|
loaded.state.lastError = `Restore of ${manifest2.backupId} stopped before data replacement: ${original}`;
|
|
25391
26843
|
await clearPending(loaded.directory, loaded.state);
|
|
@@ -25400,7 +26852,7 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
25400
26852
|
}
|
|
25401
26853
|
|
|
25402
26854
|
// src/commands/rollback.ts
|
|
25403
|
-
import { join as
|
|
26855
|
+
import { join as join28 } from "node:path";
|
|
25404
26856
|
async function rollbackCommand(directory, flags) {
|
|
25405
26857
|
if (!boolFlag(flags, "yes"))
|
|
25406
26858
|
throw new Error("Rollback restores the previous image and data snapshot. Re-run with --yes after confirming post-update data loss.");
|
|
@@ -25416,16 +26868,23 @@ async function rollbackCommand(directory, flags) {
|
|
|
25416
26868
|
if (manifest2.appVersion !== point.appVersion || manifest2.image !== point.image || manifest2.imageDigest !== point.imageDigest) {
|
|
25417
26869
|
throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
|
|
25418
26870
|
}
|
|
25419
|
-
const backupEnv = parseEnv(await readText(
|
|
26871
|
+
const backupEnv = parseEnv(await readText(join28(backupPath, "config.env")));
|
|
25420
26872
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
25421
26873
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
25422
26874
|
}
|
|
25423
26875
|
assertBackupMatchesDeployment(manifest2, backupEnv, loaded.state, loaded.env);
|
|
26876
|
+
assertSupportedBackupProfile(loaded.env);
|
|
26877
|
+
await assertBackupSourceProfile(loaded.directory, loaded.state, loaded.env);
|
|
25424
26878
|
const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
|
|
25425
26879
|
await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
|
|
25426
26880
|
await assertSupportedHostArchitecture(point.image);
|
|
26881
|
+
await assertExistingVaultCustody(loaded.directory, loaded.state, manifest2, backupPath, backupEnv);
|
|
25427
26882
|
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
|
|
25428
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.`);
|
|
25429
26888
|
const pending = {
|
|
25430
26889
|
id: id("rollback"),
|
|
25431
26890
|
operation: "rollback",
|
|
@@ -25439,6 +26898,7 @@ async function rollbackCommand(directory, flags) {
|
|
|
25439
26898
|
targetImage: point.image,
|
|
25440
26899
|
targetImageDigest: point.imageDigest,
|
|
25441
26900
|
recreateDataVolume,
|
|
26901
|
+
remoteReconcile,
|
|
25442
26902
|
phase: "prepared"
|
|
25443
26903
|
};
|
|
25444
26904
|
await markPending(loaded.directory, loaded.state, pending);
|
|
@@ -25490,6 +26950,8 @@ async function rollbackCommand(directory, flags) {
|
|
|
25490
26950
|
throw new Error(recoveredMessage);
|
|
25491
26951
|
}
|
|
25492
26952
|
try {
|
|
26953
|
+
if (managedVaultEnabled(loaded.directory, loaded.env))
|
|
26954
|
+
await assertVaultVolumesExist(loaded.directory, loaded.state);
|
|
25493
26955
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
25494
26956
|
loaded.state.lastError = `Rollback to ${point.appVersion} stopped before data replacement: ${original}`;
|
|
25495
26957
|
await clearPending(loaded.directory, loaded.state);
|
|
@@ -25527,6 +26989,7 @@ async function statusCommand(directory, flags = {}) {
|
|
|
25527
26989
|
identityMatches: observation.identityMatches,
|
|
25528
26990
|
lastReceipt: state?.lastReceipt ?? null,
|
|
25529
26991
|
backups: observation.backups,
|
|
26992
|
+
recoveryPoint: latestFullSnapshot(observation.backups, observation.observedAt),
|
|
25530
26993
|
findings
|
|
25531
26994
|
}, null, 2));
|
|
25532
26995
|
return;
|
|
@@ -25626,8 +27089,8 @@ ${result.stderr}`);
|
|
|
25626
27089
|
}
|
|
25627
27090
|
|
|
25628
27091
|
// src/commands/update.ts
|
|
25629
|
-
import { readFile as
|
|
25630
|
-
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";
|
|
25631
27094
|
async function assessUpdate(directory, targetVersion, options = {}) {
|
|
25632
27095
|
const checkDocker = options.checkDocker ?? false;
|
|
25633
27096
|
const observation = await observeDeployment(directory, { checkDocker, checkImage: false });
|
|
@@ -25692,7 +27155,7 @@ async function assessUpdate(directory, targetVersion, options = {}) {
|
|
|
25692
27155
|
for (const name of managedAssetNames(state?.mode ?? "local")) {
|
|
25693
27156
|
let desired;
|
|
25694
27157
|
try {
|
|
25695
|
-
desired = await
|
|
27158
|
+
desired = await readFile6(join29(ASSET_ROOT, name));
|
|
25696
27159
|
} catch (error62) {
|
|
25697
27160
|
findings.push({
|
|
25698
27161
|
code: "managed-asset-unreadable",
|
|
@@ -25703,12 +27166,12 @@ async function assessUpdate(directory, targetVersion, options = {}) {
|
|
|
25703
27166
|
continue;
|
|
25704
27167
|
}
|
|
25705
27168
|
try {
|
|
25706
|
-
const installedPath =
|
|
27169
|
+
const installedPath = join29(directory, name);
|
|
25707
27170
|
if (!await fileExists(installedPath)) {
|
|
25708
27171
|
changed.push(`${name} (add)`);
|
|
25709
27172
|
continue;
|
|
25710
27173
|
}
|
|
25711
|
-
const installed = await
|
|
27174
|
+
const installed = await readFile6(installedPath);
|
|
25712
27175
|
if (!installed.equals(desired))
|
|
25713
27176
|
changed.push(`${name} (update)`);
|
|
25714
27177
|
} catch (error62) {
|
|
@@ -25815,6 +27278,8 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
25815
27278
|
progress(`OR3 ${targetVersion} is already installed. Nothing to do.`);
|
|
25816
27279
|
return;
|
|
25817
27280
|
}
|
|
27281
|
+
assertSupportedBackupProfile(env);
|
|
27282
|
+
await assertBackupSourceProfile(loaded.directory, state, env);
|
|
25818
27283
|
const preflight = await assessUpdate(loaded.directory, targetVersion, { underLease: true, checkDocker: true });
|
|
25819
27284
|
const preflightBlockers = preflight.findings.filter((finding) => finding.severity === "blocker");
|
|
25820
27285
|
if (preflightBlockers.length > 0) {
|
|
@@ -25902,6 +27367,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
25902
27367
|
await copyAssets(loaded.directory, state.mode);
|
|
25903
27368
|
if (migrateLegacyVolume || recreateDataVolume) {
|
|
25904
27369
|
await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
|
|
27370
|
+
await restoreGenerationIdentities(loaded.directory, state, nextEnv, backup.backupDir);
|
|
25905
27371
|
}
|
|
25906
27372
|
} catch (error62) {
|
|
25907
27373
|
await updatePending(loaded.directory, state, { phase: "restoring-previous" });
|
|
@@ -25915,7 +27381,8 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
25915
27381
|
}
|
|
25916
27382
|
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, {
|
|
25917
27383
|
recreateDataVolume,
|
|
25918
|
-
previousSafetySnapshot: true
|
|
27384
|
+
previousSafetySnapshot: true,
|
|
27385
|
+
remoteReferencePolicy: "warn"
|
|
25919
27386
|
});
|
|
25920
27387
|
} catch (restoreError) {
|
|
25921
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)}`);
|
|
@@ -25938,7 +27405,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
25938
27405
|
const targetReadyEvidence = {
|
|
25939
27406
|
checkedAt: now(),
|
|
25940
27407
|
deploymentId: targetDeploymentId,
|
|
25941
|
-
deploymentRoot:
|
|
27408
|
+
deploymentRoot: resolve15(loaded.directory),
|
|
25942
27409
|
containerId: targetChecks.containerId,
|
|
25943
27410
|
imageDigest: digest,
|
|
25944
27411
|
configurationSha256: await sha256File(deploymentPaths(loaded.directory).env),
|
|
@@ -26012,7 +27479,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
26012
27479
|
state.image = targetImage;
|
|
26013
27480
|
state.imageDigest = digest;
|
|
26014
27481
|
state.deploymentId = targetDeploymentId;
|
|
26015
|
-
state.deploymentRoot =
|
|
27482
|
+
state.deploymentRoot = resolve15(loaded.directory);
|
|
26016
27483
|
state.lastSuccessfulOperation = "update";
|
|
26017
27484
|
state.lastError = undefined;
|
|
26018
27485
|
let commit;
|
|
@@ -26058,7 +27525,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
26058
27525
|
}
|
|
26059
27526
|
|
|
26060
27527
|
// src/commands/verify.ts
|
|
26061
|
-
import { join as
|
|
27528
|
+
import { join as join30, resolve as resolve16 } from "node:path";
|
|
26062
27529
|
async function verificationCredentials(directory, flags, env) {
|
|
26063
27530
|
const passwordFile = stringFlag(flags, "verification-password-file");
|
|
26064
27531
|
if (flags["verification-email"] !== undefined && !stringFlag(flags, "verification-email")) {
|
|
@@ -26070,7 +27537,7 @@ async function verificationCredentials(directory, flags, env) {
|
|
|
26070
27537
|
const configuredEmail = stringFlag(flags, "verification-email")?.trim() || env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
26071
27538
|
let initialCredentials = {};
|
|
26072
27539
|
if (!configuredEmail || !passwordFile && !env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD) {
|
|
26073
|
-
const initialCredentialsPath =
|
|
27540
|
+
const initialCredentialsPath = join30(directory, ".or3-initial-credentials");
|
|
26074
27541
|
if (await fileExists(initialCredentialsPath)) {
|
|
26075
27542
|
initialCredentials = parseEnv(await readOwnerOnlyText(initialCredentialsPath, "The initial credentials file"));
|
|
26076
27543
|
}
|
|
@@ -26079,7 +27546,7 @@ async function verificationCredentials(directory, flags, env) {
|
|
|
26079
27546
|
if (!email3)
|
|
26080
27547
|
throw new Error("A current owner email is required for verification.");
|
|
26081
27548
|
validateEmail(email3);
|
|
26082
|
-
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;
|
|
26083
27550
|
if (!password) {
|
|
26084
27551
|
throw new Error("A current owner password is required. Pass --verification-password-file after changing the bootstrap password.");
|
|
26085
27552
|
}
|
|
@@ -26190,11 +27657,134 @@ async function verifyReadOnlyCommand(directory, flags) {
|
|
|
26190
27657
|
}
|
|
26191
27658
|
}
|
|
26192
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
|
+
|
|
26193
27782
|
// src/cli/dispatch.ts
|
|
26194
27783
|
var MUTATING_COMMANDS = new Set([
|
|
26195
27784
|
"init",
|
|
26196
27785
|
"update",
|
|
26197
27786
|
"backup",
|
|
27787
|
+
"vault-key",
|
|
26198
27788
|
"verify",
|
|
26199
27789
|
"restore",
|
|
26200
27790
|
"rollback",
|
|
@@ -26208,10 +27798,10 @@ var MUTATING_COMMANDS = new Set([
|
|
|
26208
27798
|
]);
|
|
26209
27799
|
function mutationDirectory(command, positionals, flags) {
|
|
26210
27800
|
if (command === "init")
|
|
26211
|
-
return
|
|
27801
|
+
return resolve17(process.cwd(), positionals[0] ?? "or3-cloud");
|
|
26212
27802
|
if (command === "adopt") {
|
|
26213
27803
|
const sourceDirectory = requireStringFlag(flags, "from");
|
|
26214
|
-
return
|
|
27804
|
+
return resolve17(process.cwd(), positionals[0] ?? `${basename8(sourceDirectory)}-managed`);
|
|
26215
27805
|
}
|
|
26216
27806
|
return process.cwd();
|
|
26217
27807
|
}
|
|
@@ -26229,6 +27819,8 @@ async function dispatchCommand(command, parsed) {
|
|
|
26229
27819
|
}
|
|
26230
27820
|
if (command === "backup")
|
|
26231
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);
|
|
26232
27824
|
if (command === "restore")
|
|
26233
27825
|
return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
26234
27826
|
if (command === "rollback")
|
|
@@ -26270,6 +27862,7 @@ Usage:
|
|
|
26270
27862
|
npx @or3/cloud init [directory] --public --domain <hostname>
|
|
26271
27863
|
npx @or3/cloud update [--to <exact-version>] [--dry-run] [--json]
|
|
26272
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>
|
|
26273
27866
|
npx @or3/cloud restore <backup-id-or-path> --yes
|
|
26274
27867
|
npx @or3/cloud rollback --yes
|
|
26275
27868
|
npx @or3/cloud credentials reset --yes [--owner-password-file <path> --admin-password-file <path>]
|
|
@@ -26292,6 +27885,7 @@ Options:
|
|
|
26292
27885
|
--verification-password-file <path> Read the current owner password for this verification
|
|
26293
27886
|
--port <port> Local OR3 port (default: 3000)
|
|
26294
27887
|
--keep <n> Backups to retain when pruning (default: 5)
|
|
27888
|
+
--out <absolute-path> New owner-only vault-key export destination
|
|
26295
27889
|
--force Bypass suspect-entry deferral; protected recovery backups remain protected
|
|
26296
27890
|
--tail <n> Log lines to show (default: 200)
|
|
26297
27891
|
--public Require verification through the public HTTPS origin
|
|
@@ -26300,7 +27894,7 @@ Options:
|
|
|
26300
27894
|
--finish Commit a proven completed replacement without restoring data
|
|
26301
27895
|
--restore Explicitly restore the recorded snapshot (requires --yes)
|
|
26302
27896
|
--json Emit one machine-readable result object on stdout
|
|
26303
|
-
--purge-data
|
|
27897
|
+
--purge-data Currently disabled pending export reconstruction support
|
|
26304
27898
|
--yes Confirm a destructive restore, rollback, credentials reset, or purge
|
|
26305
27899
|
--help Show this help
|
|
26306
27900
|
--version Show the Cloud package version
|
|
@@ -26311,11 +27905,11 @@ The installer never changes firewall, DNS, Cloudflare, or Tailscale settings.`);
|
|
|
26311
27905
|
|
|
26312
27906
|
// src/commands/recover-restart.ts
|
|
26313
27907
|
import { request } from "node:http";
|
|
26314
|
-
import { join as
|
|
27908
|
+
import { join as join32 } from "node:path";
|
|
26315
27909
|
async function recoverRestart(directory, flags) {
|
|
26316
27910
|
let journal;
|
|
26317
27911
|
try {
|
|
26318
|
-
journal = await readRestartJournal(
|
|
27912
|
+
journal = await readRestartJournal(join32(directory, ".or3-cloud", "restarts", "restarts.json"));
|
|
26319
27913
|
} catch (error62) {
|
|
26320
27914
|
if (error62.code === "ENOENT")
|
|
26321
27915
|
return false;
|
|
@@ -26338,9 +27932,9 @@ async function recoverRestart(directory, flags) {
|
|
|
26338
27932
|
if (!boolFlag(flags, "finish") || boolFlag(flags, "restore"))
|
|
26339
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.");
|
|
26340
27934
|
const value = JSON.stringify({ requestId: operation.requestId });
|
|
26341
|
-
await new Promise((
|
|
27935
|
+
await new Promise((resolve18, reject) => {
|
|
26342
27936
|
const req = request({
|
|
26343
|
-
socketPath:
|
|
27937
|
+
socketPath: join32(deploymentPaths(directory).operatorIpc, "operator.sock"),
|
|
26344
27938
|
path: "/lifecycle/v1/reconcile",
|
|
26345
27939
|
method: "POST",
|
|
26346
27940
|
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(value) }
|
|
@@ -26355,7 +27949,7 @@ async function recoverRestart(directory, flags) {
|
|
|
26355
27949
|
response.on("end", () => {
|
|
26356
27950
|
clearTimeout(timer);
|
|
26357
27951
|
if (response.statusCode === 200)
|
|
26358
|
-
|
|
27952
|
+
resolve18();
|
|
26359
27953
|
else
|
|
26360
27954
|
reject(new Error("Restart recovery is unverified. Keep the receipt and inspect app/operator diagnostics; no additional restart was executed."));
|
|
26361
27955
|
});
|
|
@@ -26406,7 +28000,7 @@ async function assertDeploymentFolder(command, directory = process.cwd()) {
|
|
|
26406
28000
|
return;
|
|
26407
28001
|
throw new Error("No OR3 Cloud deployment here. Run this from the folder that init created (usually ./or3-cloud).");
|
|
26408
28002
|
}
|
|
26409
|
-
var invokedAsCli = process.argv[1] ? ["or3", "cli.mjs"].includes(
|
|
26410
|
-
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))) {
|
|
26411
28005
|
await main();
|
|
26412
28006
|
}
|