@or3/cloud 0.1.82 → 0.1.85
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/dist/cli.mjs +561 -140
- package/package.json +4 -4
package/dist/cli.mjs
CHANGED
|
@@ -125,7 +125,7 @@ import { createReadStream as createReadStream3 } from "node:fs";
|
|
|
125
125
|
import { statfs } from "node:fs/promises";
|
|
126
126
|
import { Writable } from "node:stream";
|
|
127
127
|
import { pipeline as pipeline2 } from "node:stream/promises";
|
|
128
|
-
import { dirname as dirname3, join as join7 } from "node:path";
|
|
128
|
+
import { dirname as dirname3, join as join7, posix } from "node:path";
|
|
129
129
|
import { createGunzip } from "node:zlib";
|
|
130
130
|
|
|
131
131
|
// src/deployment/env.ts
|
|
@@ -165,7 +165,7 @@ import { readFileSync } from "node:fs";
|
|
|
165
165
|
import { join, resolve } from "node:path";
|
|
166
166
|
import { fileURLToPath } from "node:url";
|
|
167
167
|
var PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url)));
|
|
168
|
-
var PACKAGE_VERSION = "0.1.
|
|
168
|
+
var PACKAGE_VERSION = "0.1.85";
|
|
169
169
|
var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
|
|
170
170
|
var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
|
|
171
171
|
var SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
@@ -944,10 +944,11 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
944
944
|
child.once("error", reject);
|
|
945
945
|
child.once("close", resolvePromise);
|
|
946
946
|
});
|
|
947
|
+
const transfer = pipeline(child.stdout, createWriteStream(temporary, { flags: "wx", mode: 384 }));
|
|
947
948
|
try {
|
|
948
949
|
const [exitCode] = await Promise.all([
|
|
949
950
|
exit,
|
|
950
|
-
|
|
951
|
+
transfer
|
|
951
952
|
]);
|
|
952
953
|
if (hasTimedOut())
|
|
953
954
|
throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
|
|
@@ -958,12 +959,18 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
958
959
|
await durableRename(temporary, destination);
|
|
959
960
|
} catch (error) {
|
|
960
961
|
terminateChildProcess(child);
|
|
961
|
-
await
|
|
962
|
-
|
|
963
|
-
})
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
962
|
+
await Promise.allSettled([exit, transfer]);
|
|
963
|
+
const detail = redact(error instanceof Error ? error.message : String(error), secrets);
|
|
964
|
+
const message = `${detail}${stderr && !detail.includes(stderr) ? `
|
|
965
|
+
${redact(stderr, secrets)}` : ""}`;
|
|
966
|
+
try {
|
|
967
|
+
await lifecycleFaults.beforeTemporaryArchiveDelete?.();
|
|
968
|
+
await rm2(temporary, { force: true });
|
|
969
|
+
} catch (cleanupError) {
|
|
970
|
+
throw new AggregateError([error, cleanupError], `${message}
|
|
971
|
+
Temporary archive cleanup failed: ${redact(cleanupError instanceof Error ? cleanupError.message : String(cleanupError), secrets)}. Removal is unverified for ${temporary}; inspect this path and its permissions before retrying.`);
|
|
972
|
+
}
|
|
973
|
+
throw new Error(message, { cause: error });
|
|
967
974
|
} finally {
|
|
968
975
|
clearTimeout(timeout);
|
|
969
976
|
}
|
|
@@ -1356,14 +1363,15 @@ ${existing.stderr}`)) {
|
|
|
1356
1363
|
throw new Error(`Volume ${state.volumeName} does not carry the expected managed deployment labels.`);
|
|
1357
1364
|
}
|
|
1358
1365
|
}
|
|
1359
|
-
async function dataVolumeSize(directory, mode, env) {
|
|
1366
|
+
async function dataVolumeSize(directory, mode, env, size = "apparent") {
|
|
1367
|
+
const script = size === "allocated" ? "du -sk /data 2>/dev/null" : "du -sb /data 2>/dev/null";
|
|
1360
1368
|
const parseDuOutput = (stdout) => {
|
|
1361
1369
|
const match = stdout.trim().match(/^(\d+)/);
|
|
1362
1370
|
if (!match)
|
|
1363
1371
|
throw new Error(`Could not parse the data volume size from "du" output.`);
|
|
1364
|
-
return Number(match[1]);
|
|
1372
|
+
return Number(match[1]) * (size === "allocated" ? 1024 : 1);
|
|
1365
1373
|
};
|
|
1366
|
-
const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c",
|
|
1374
|
+
const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", script])], directory);
|
|
1367
1375
|
if (exec.ok)
|
|
1368
1376
|
return parseDuOutput(exec.stdout);
|
|
1369
1377
|
const fallback = await run("docker", [
|
|
@@ -1380,7 +1388,7 @@ async function dataVolumeSize(directory, mode, env) {
|
|
|
1380
1388
|
"sh",
|
|
1381
1389
|
env.OR3_IMAGE,
|
|
1382
1390
|
"-c",
|
|
1383
|
-
|
|
1391
|
+
script
|
|
1384
1392
|
], directory);
|
|
1385
1393
|
if (fallback.ok)
|
|
1386
1394
|
return parseDuOutput(fallback.stdout);
|
|
@@ -1536,7 +1544,9 @@ async function restoreVolumeArchive(directory, mode, env, backupPath) {
|
|
|
1536
1544
|
"tar xzf - -C /data"
|
|
1537
1545
|
], join7(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1538
1546
|
}
|
|
1539
|
-
async function validateVolumeArchive(directory, _mode, env, backupPath) {
|
|
1547
|
+
async function validateVolumeArchive(directory, _mode, env, backupPath, archivedStorageRoot) {
|
|
1548
|
+
const storageRoot = posix.relative("/data", archivedStorageRoot ?? env.OR3_STORAGE_FS_ROOT ?? "/data/storage");
|
|
1549
|
+
const storagePrefix = storageRoot === ".." || storageRoot.startsWith("../") || posix.isAbsolute(storageRoot) ? "" : `${storageRoot ? `${storageRoot}/` : ""}workspaces/`;
|
|
1540
1550
|
await streamFileToCommand("docker", [
|
|
1541
1551
|
"run",
|
|
1542
1552
|
"--rm",
|
|
@@ -1552,7 +1562,46 @@ async function validateVolumeArchive(directory, _mode, env, backupPath) {
|
|
|
1552
1562
|
"sh",
|
|
1553
1563
|
env.OR3_IMAGE,
|
|
1554
1564
|
"-c",
|
|
1555
|
-
|
|
1565
|
+
`{ tar tzf -; printf '\\nOR3_TAR_RESULT:%s\\n' "$?"; } | awk -v prefix="$1" '
|
|
1566
|
+
{
|
|
1567
|
+
entry = $0
|
|
1568
|
+
sub(/^\\.\\//, "", entry)
|
|
1569
|
+
if (entry == "auth.sqlite") auth = 1
|
|
1570
|
+
if (entry == "sync.sqlite") sync = 1
|
|
1571
|
+
if (entry ~ /^OR3_TAR_RESULT:[0-9]+$/) {
|
|
1572
|
+
seen_status = 1
|
|
1573
|
+
sub(/^OR3_TAR_RESULT:/, "", entry)
|
|
1574
|
+
tar_status = entry
|
|
1575
|
+
next
|
|
1576
|
+
}
|
|
1577
|
+
if (prefix != "" && index(entry, prefix) == 1) {
|
|
1578
|
+
path = substr(entry, length(prefix) + 1)
|
|
1579
|
+
if (split(path, parts, "/") != 2 || parts[1] !~ /^[a-zA-Z0-9_-]+$/) next
|
|
1580
|
+
key = parts[2]
|
|
1581
|
+
sidecar = sub(/[.]meta[.]json$/, "", key)
|
|
1582
|
+
if ((key ~ /^sha256_[a-fA-F0-9]+$/ && length(key) == 71) || (key ~ /^md5_[a-fA-F0-9]+$/ && length(key) == 36)) {
|
|
1583
|
+
if (sidecar) metadata[entry] = 1
|
|
1584
|
+
else payloads[entry] = 1
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
END {
|
|
1589
|
+
if (!seen_status || tar_status != "0") exit 1
|
|
1590
|
+
if (!auth || !sync) {
|
|
1591
|
+
print "Backup archive is missing required managed database payloads (auth.sqlite and sync.sqlite)." > "/dev/stderr"
|
|
1592
|
+
exit 1
|
|
1593
|
+
}
|
|
1594
|
+
for (entry in metadata) {
|
|
1595
|
+
payload = entry
|
|
1596
|
+
sub(/[.]meta[.]json$/, "", payload)
|
|
1597
|
+
if (!(payload in payloads)) {
|
|
1598
|
+
print "Backup archive contains a committed filesystem metadata sidecar without its required blob payload." > "/dev/stderr"
|
|
1599
|
+
exit 1
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}'`,
|
|
1603
|
+
"or3-archive-validation",
|
|
1604
|
+
storagePrefix
|
|
1556
1605
|
], join7(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1557
1606
|
}
|
|
1558
1607
|
|
|
@@ -3190,12 +3239,82 @@ ${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "
|
|
|
3190
3239
|
}
|
|
3191
3240
|
|
|
3192
3241
|
// src/backup/create.ts
|
|
3193
|
-
import { chmod as chmod6, mkdir as mkdir6 } from "node:fs/promises";
|
|
3194
|
-
import { join as
|
|
3242
|
+
import { chmod as chmod6, lstat as lstat2, mkdir as mkdir6 } from "node:fs/promises";
|
|
3243
|
+
import { join as join15, resolve as resolve9 } from "node:path";
|
|
3244
|
+
|
|
3245
|
+
// src/backup/references.ts
|
|
3246
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
3247
|
+
import { join as join13 } from "node:path";
|
|
3248
|
+
var INSPECTOR = "/app/scripts/docker/backup-inspect.mjs";
|
|
3249
|
+
var NODE = 'if [ -x /nodejs/bin/node ]; then exec /nodejs/bin/node "$@"; else exec node "$@"; fi';
|
|
3250
|
+
async function validateCanonicalBackupReferences(directory, env, backupPath) {
|
|
3251
|
+
if (env.OR3_SYNC_PROVIDER !== "sqlite" || env.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs" || env.OR3_SQLITE_DRIVER && env.OR3_SQLITE_DRIVER !== "better-sqlite3") {
|
|
3252
|
+
throw new Error("Unsupported offline backup inspection provider profile; SQLite/FS is required.");
|
|
3253
|
+
}
|
|
3254
|
+
const helper = `or3-backup-inspection-${randomUUID2()}`;
|
|
3255
|
+
const checked = async (args) => {
|
|
3256
|
+
const result = await run("docker", args, directory);
|
|
3257
|
+
if (!result.ok)
|
|
3258
|
+
throw new Error(`Offline backup reference inspection failed or is unsupported. ${redact(result.stderr, secretValues(env))}`);
|
|
3259
|
+
return result.stdout.trim();
|
|
3260
|
+
};
|
|
3261
|
+
let primary;
|
|
3262
|
+
try {
|
|
3263
|
+
await checked([
|
|
3264
|
+
"run",
|
|
3265
|
+
"--pull",
|
|
3266
|
+
"never",
|
|
3267
|
+
"--rm",
|
|
3268
|
+
"--detach",
|
|
3269
|
+
"--name",
|
|
3270
|
+
helper,
|
|
3271
|
+
"--network",
|
|
3272
|
+
"none",
|
|
3273
|
+
"--read-only",
|
|
3274
|
+
"--security-opt",
|
|
3275
|
+
"no-new-privileges:true",
|
|
3276
|
+
"--cap-drop",
|
|
3277
|
+
"ALL",
|
|
3278
|
+
"--memory",
|
|
3279
|
+
"256m",
|
|
3280
|
+
"--cpus",
|
|
3281
|
+
"0.5",
|
|
3282
|
+
"--pids-limit",
|
|
3283
|
+
"32",
|
|
3284
|
+
"--tmpfs",
|
|
3285
|
+
"/scratch:rw,noexec,nosuid,nodev,size=512m,mode=700,uid=65532,gid=65532",
|
|
3286
|
+
"--entrypoint",
|
|
3287
|
+
"sh",
|
|
3288
|
+
env.OR3_IMAGE,
|
|
3289
|
+
"-c",
|
|
3290
|
+
"sleep 240"
|
|
3291
|
+
]);
|
|
3292
|
+
await streamFileToCommand("docker", ["exec", "-i", helper, "sh", "-c", "ulimit -f 16384; tar tvzf - > /scratch/inventory"], join13(backupPath, "data.tgz"), directory, secretValues(env));
|
|
3293
|
+
const inventory = JSON.parse(await checked(["exec", helper, "sh", "-c", NODE, "or3-backup-inspection", INSPECTOR, "inventory"]));
|
|
3294
|
+
if (!Array.isArray(inventory.selected) || !inventory.selected.includes("./sync.sqlite") && !inventory.selected.includes("sync.sqlite") || inventory.selected.some((name) => !/^(?:\.\/)?sync[.]sqlite(?:-wal)?$/.test(name)) || inventory.selected.length > 2 || !Number.isSafeInteger(inventory.bytes) || inventory.bytes < 0)
|
|
3295
|
+
throw new Error("Invalid offline backup inspection inventory response.");
|
|
3296
|
+
const freeKiB = Number(await checked(["exec", helper, "sh", "-c", "df -Pk /scratch | awk 'NR == 2 { print $4 }'"]));
|
|
3297
|
+
if (!Number.isFinite(freeKiB) || freeKiB < 0)
|
|
3298
|
+
throw new Error("Could not verify offline backup scratch capacity.");
|
|
3299
|
+
assertEnoughFreeSpace(freeKiB * 1024, inventory.bytes, "Offline backup inspection scratch");
|
|
3300
|
+
await streamFileToCommand("docker", ["exec", "-i", helper, "tar", "-xz", "-f", "-", "-C", "/scratch", ...inventory.selected], join13(backupPath, "data.tgz"), directory, secretValues(env));
|
|
3301
|
+
const result = JSON.parse(await checked(["exec", helper, "sh", "-c", NODE, "or3-backup-inspection", INSPECTOR, "references", env.OR3_STORAGE_FS_ROOT ?? "/data/storage"]));
|
|
3302
|
+
if (result.contract !== 1 || result.complete !== true || !Number.isSafeInteger(result.liveAttachments) || result.liveAttachments < 0)
|
|
3303
|
+
throw new Error("Offline canonical backup reference inspection did not complete.");
|
|
3304
|
+
} catch (error) {
|
|
3305
|
+
primary = error;
|
|
3306
|
+
}
|
|
3307
|
+
const cleanup = await run("docker", ["rm", "--force", helper], directory);
|
|
3308
|
+
if (!cleanup.ok && !/No such container/i.test(cleanup.stderr)) {
|
|
3309
|
+
throw new AggregateError(primary ? [primary] : [], `Offline backup inspection helper cleanup failed. ${redact(cleanup.stderr, secretValues(env))}`);
|
|
3310
|
+
}
|
|
3311
|
+
if (primary)
|
|
3312
|
+
throw primary;
|
|
3313
|
+
}
|
|
3195
3314
|
|
|
3196
3315
|
// src/backup/retention.ts
|
|
3197
3316
|
import { rm as rm5 } from "node:fs/promises";
|
|
3198
|
-
import { basename as basename4, dirname as dirname4, join as
|
|
3317
|
+
import { basename as basename4, dirname as dirname4, join as join14, resolve as resolve8 } from "node:path";
|
|
3199
3318
|
var BACKUP_RETENTION_KEEP = 5;
|
|
3200
3319
|
function assertRemovableArtifactName(name) {
|
|
3201
3320
|
if (!BACKUP_ID_PATTERN.test(name)) {
|
|
@@ -3211,7 +3330,7 @@ async function removeNamedBackupArtifact(directory, backupId) {
|
|
|
3211
3330
|
throw new Error(`Refusing to remove a path outside the backups directory: ${target}.`);
|
|
3212
3331
|
}
|
|
3213
3332
|
await rm5(target, { recursive: true, force: true });
|
|
3214
|
-
await rm5(
|
|
3333
|
+
await rm5(join14(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
|
|
3215
3334
|
}
|
|
3216
3335
|
async function removeEnumeratedBackupArtifact(directory, backup) {
|
|
3217
3336
|
await lifecycleFaults.beforeArtifactDelete?.();
|
|
@@ -3221,7 +3340,7 @@ async function removeEnumeratedBackupArtifact(directory, backup) {
|
|
|
3221
3340
|
throw new Error(`Refusing to prune an untrusted backup path ${backup.path}.`);
|
|
3222
3341
|
}
|
|
3223
3342
|
await rm5(target, { recursive: true, force: true });
|
|
3224
|
-
await rm5(
|
|
3343
|
+
await rm5(join14(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
|
|
3225
3344
|
}
|
|
3226
3345
|
function planRetention(entries, keep, protectedIds, options = {}) {
|
|
3227
3346
|
if (!Number.isInteger(keep) || keep < 1)
|
|
@@ -3307,6 +3426,35 @@ async function pruneBackups(directory, state, keep, force, options = {}) {
|
|
|
3307
3426
|
}
|
|
3308
3427
|
|
|
3309
3428
|
// src/backup/create.ts
|
|
3429
|
+
var asError = (error) => error instanceof Error ? error : new Error(String(error));
|
|
3430
|
+
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.';
|
|
3431
|
+
async function artifactStatus(path) {
|
|
3432
|
+
try {
|
|
3433
|
+
await lstat2(path);
|
|
3434
|
+
return { absent: false, detail: `Still present: ${path}.` };
|
|
3435
|
+
} catch (error) {
|
|
3436
|
+
if (error.code === "ENOENT")
|
|
3437
|
+
return { absent: true, detail: `Confirmed absent: ${path}.` };
|
|
3438
|
+
return { absent: false, detail: `Could not inspect ${path}: ${asError(error).message}. Its presence or absence is unverified.` };
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
async function cleanupPartialBackup(directory, backupId) {
|
|
3442
|
+
let error;
|
|
3443
|
+
try {
|
|
3444
|
+
await removeNamedBackupArtifact(directory, backupId);
|
|
3445
|
+
} catch (failure) {
|
|
3446
|
+
error = asError(failure);
|
|
3447
|
+
}
|
|
3448
|
+
const statuses = await Promise.all([
|
|
3449
|
+
artifactStatus(backupDirectory(directory, backupId)),
|
|
3450
|
+
artifactStatus(join15(deploymentPaths(directory).exports, `${backupId}.json`))
|
|
3451
|
+
]);
|
|
3452
|
+
const removed = statuses.every((status) => status.absent);
|
|
3453
|
+
const detail = statuses.map((status) => status.detail).join(" ");
|
|
3454
|
+
if (!removed && !error)
|
|
3455
|
+
error = new Error("Backup cleanup did not confirm removal of every artifact.");
|
|
3456
|
+
return { error, removed, detail };
|
|
3457
|
+
}
|
|
3310
3458
|
async function createBackup(directory, state, env, options = {}) {
|
|
3311
3459
|
const restartAfter = options.restartAfter ?? true;
|
|
3312
3460
|
await requireImageDigest(state.image, state.imageDigest, "Current deployment");
|
|
@@ -3315,90 +3463,172 @@ async function createBackup(directory, state, env, options = {}) {
|
|
|
3315
3463
|
throw new Error(`Backup ID ${backupId} is invalid.`);
|
|
3316
3464
|
const backupDir = backupDirectory(directory, backupId);
|
|
3317
3465
|
const initiallyRunning = options.initiallyRunning ?? await projectServiceRunning(directory, state.mode);
|
|
3466
|
+
let service = initiallyRunning ? "running" : "stopped";
|
|
3467
|
+
let artifact = "not-created";
|
|
3468
|
+
let downtimeMs;
|
|
3469
|
+
const report = async (stage, message) => {
|
|
3470
|
+
await options.onProgress?.({ stage, service, artifact, message, downtimeMs });
|
|
3471
|
+
};
|
|
3472
|
+
await report("preflight", "Preflight: checking image identity and backup disk space; service state is unchanged.");
|
|
3318
3473
|
const volumeSize = await dataVolumeSize(directory, state.mode, env);
|
|
3319
3474
|
await assertFreeSpaceForArchive(backupDir, volumeSize, "Backup");
|
|
3320
|
-
let
|
|
3475
|
+
let created = false;
|
|
3321
3476
|
let stopAttempted = false;
|
|
3322
|
-
let
|
|
3477
|
+
let stopStartedAt = 0;
|
|
3478
|
+
let capturedAt = now();
|
|
3479
|
+
let primaryFailure;
|
|
3480
|
+
let restartFailure;
|
|
3481
|
+
const progressFailures = [];
|
|
3482
|
+
let managedAssetSha256;
|
|
3483
|
+
let verifiedManifest;
|
|
3323
3484
|
try {
|
|
3324
|
-
await mkdir6(backupDir, {
|
|
3485
|
+
await mkdir6(backupDir, { mode: 448 });
|
|
3486
|
+
created = true;
|
|
3487
|
+
artifact = "partial";
|
|
3325
3488
|
await chmod6(backupDir, 448);
|
|
3489
|
+
await report("maintenance", "Maintenance: stopping OR3 to capture the databases and files together.");
|
|
3326
3490
|
stopAttempted = true;
|
|
3491
|
+
stopStartedAt = Date.now();
|
|
3492
|
+
service = "unknown";
|
|
3327
3493
|
await stopProject(directory, state.mode);
|
|
3494
|
+
service = "stopped";
|
|
3495
|
+
capturedAt = now();
|
|
3496
|
+
await report("capturing", `Backup capture: OR3 is stopped; writing ${backupDir}.`);
|
|
3328
3497
|
await volumeArchive(directory, state.mode, env, backupDir);
|
|
3329
|
-
await copySecure(deploymentPaths(directory).env,
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
schemaVersion: 1,
|
|
3333
|
-
backupId,
|
|
3334
|
-
createdAt: now(),
|
|
3335
|
-
appVersion: state.appVersion,
|
|
3336
|
-
image: state.image,
|
|
3337
|
-
imageDigest: state.imageDigest,
|
|
3338
|
-
dataSha256: await sha256File(join14(backupDir, "data.tgz")),
|
|
3339
|
-
dataBytes: volumeSize,
|
|
3340
|
-
configSha256: await sha256File(join14(backupDir, "config.env")),
|
|
3341
|
-
managedAssetSha256,
|
|
3342
|
-
managedAssetInventoryVersion: managedAssetSha256["compose.operator.yaml"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256["dashboard-operator.mjs"] ? 2 : undefined,
|
|
3343
|
-
mode: state.mode,
|
|
3344
|
-
domain: state.domain,
|
|
3345
|
-
composeProject: state.composeProject,
|
|
3346
|
-
volumeName: state.volumeName,
|
|
3347
|
-
caddyDataVolume: state.caddyDataVolume,
|
|
3348
|
-
caddyConfigVolume: state.caddyConfigVolume,
|
|
3349
|
-
deploymentId: state.deploymentId,
|
|
3350
|
-
port: state.port
|
|
3351
|
-
};
|
|
3352
|
-
const manifestContents = `${JSON.stringify(manifest2, null, 2)}
|
|
3353
|
-
`;
|
|
3354
|
-
await writeSecure(join14(backupDir, "manifest.json"), manifestContents);
|
|
3355
|
-
await writeBackupAuthentication(directory, backupDir, manifestContents);
|
|
3356
|
-
const verifiedManifest = await readManifest(backupDir, directory);
|
|
3357
|
-
manifestWritten = true;
|
|
3358
|
-
return { backupId, backupDir, manifest: verifiedManifest };
|
|
3498
|
+
await copySecure(deploymentPaths(directory).env, join15(backupDir, "config.env"));
|
|
3499
|
+
managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
|
|
3500
|
+
artifact = "captured";
|
|
3359
3501
|
} catch (error) {
|
|
3360
|
-
|
|
3361
|
-
await removeNamedBackupArtifact(directory, backupId).catch(() => {
|
|
3362
|
-
return;
|
|
3363
|
-
});
|
|
3364
|
-
backupFailure = new Error(`${error instanceof Error ? error.message : String(error)} The partial backup artifact at ${backupDir} was removed.`);
|
|
3365
|
-
throw backupFailure;
|
|
3366
|
-
}
|
|
3367
|
-
backupFailure = error instanceof Error ? error : new Error(String(error));
|
|
3368
|
-
throw error;
|
|
3502
|
+
primaryFailure = asError(error);
|
|
3369
3503
|
} finally {
|
|
3370
3504
|
if (stopAttempted && restartAfter && initiallyRunning) {
|
|
3505
|
+
service = "restarting";
|
|
3506
|
+
try {
|
|
3507
|
+
await report("restarting", "Restart: bringing OR3 back and checking deep health; backup verification is still pending.");
|
|
3508
|
+
} catch (error) {
|
|
3509
|
+
progressFailures.push(asError(error));
|
|
3510
|
+
}
|
|
3371
3511
|
try {
|
|
3372
3512
|
await startProject(directory, state.mode, env);
|
|
3513
|
+
service = "healthy";
|
|
3514
|
+
downtimeMs = Date.now() - stopStartedAt;
|
|
3373
3515
|
} catch (error) {
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
throw new AggregateError([backupFailure, restartFailure], `Backup failed and OR3 could not restart. Primary failure: ${backupFailure.message}. Restart failure: ${restartFailure.message}`);
|
|
3377
|
-
}
|
|
3378
|
-
throw new Error(`Backup was created but OR3 could not restart: ${error instanceof Error ? error.message : String(error)}`);
|
|
3516
|
+
service = "unknown";
|
|
3517
|
+
restartFailure = asError(error);
|
|
3379
3518
|
}
|
|
3380
3519
|
}
|
|
3381
3520
|
}
|
|
3521
|
+
if (!primaryFailure) {
|
|
3522
|
+
try {
|
|
3523
|
+
await report("verifying", `Verification: checking the captured backup; ${service === "healthy" ? "OR3 is deeply healthy" : service === "stopped" ? "OR3 remains stopped" : "service recovery is unverified"}.`);
|
|
3524
|
+
const manifest2 = {
|
|
3525
|
+
schemaVersion: 1,
|
|
3526
|
+
backupId,
|
|
3527
|
+
createdAt: capturedAt,
|
|
3528
|
+
appVersion: state.appVersion,
|
|
3529
|
+
image: state.image,
|
|
3530
|
+
imageDigest: state.imageDigest,
|
|
3531
|
+
dataSha256: await sha256File(join15(backupDir, "data.tgz")),
|
|
3532
|
+
dataBytes: volumeSize,
|
|
3533
|
+
configSha256: await sha256File(join15(backupDir, "config.env")),
|
|
3534
|
+
managedAssetSha256,
|
|
3535
|
+
managedAssetInventoryVersion: managedAssetSha256?.["compose.operator.yaml"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256?.["dashboard-operator.mjs"] ? 2 : undefined,
|
|
3536
|
+
mode: state.mode,
|
|
3537
|
+
domain: state.domain,
|
|
3538
|
+
composeProject: state.composeProject,
|
|
3539
|
+
volumeName: state.volumeName,
|
|
3540
|
+
caddyDataVolume: state.caddyDataVolume,
|
|
3541
|
+
caddyConfigVolume: state.caddyConfigVolume,
|
|
3542
|
+
deploymentId: state.deploymentId,
|
|
3543
|
+
port: state.port
|
|
3544
|
+
};
|
|
3545
|
+
const manifestContents = `${JSON.stringify(manifest2, null, 2)}
|
|
3546
|
+
`;
|
|
3547
|
+
await writeSecure(join15(backupDir, "manifest.json"), manifestContents);
|
|
3548
|
+
await writeBackupAuthentication(directory, backupDir, manifestContents);
|
|
3549
|
+
if (!options.safetySnapshot)
|
|
3550
|
+
await validateCanonicalBackupReferences(directory, { ...env, OR3_IMAGE: imageAtDigest(state.image, state.imageDigest) }, backupDir);
|
|
3551
|
+
verifiedManifest = await readManifest(backupDir, directory);
|
|
3552
|
+
artifact = "verified";
|
|
3553
|
+
} catch (error) {
|
|
3554
|
+
primaryFailure = asError(error);
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
let cleanupFailure;
|
|
3558
|
+
let cleanupDetail = "";
|
|
3559
|
+
if (primaryFailure && created && !verifiedManifest) {
|
|
3560
|
+
try {
|
|
3561
|
+
await report("cleanup", "Cleanup: removing only this operation’s unverified backup artifacts.");
|
|
3562
|
+
} catch (error) {
|
|
3563
|
+
progressFailures.push(asError(error));
|
|
3564
|
+
}
|
|
3565
|
+
const cleanup = await cleanupPartialBackup(directory, backupId);
|
|
3566
|
+
cleanupFailure = cleanup.error;
|
|
3567
|
+
cleanupDetail = cleanup.detail;
|
|
3568
|
+
artifact = cleanup.removed ? "removed" : "unknown";
|
|
3569
|
+
}
|
|
3570
|
+
const failures = [primaryFailure, cleanupFailure, restartFailure, ...progressFailures].filter((error) => Boolean(error));
|
|
3571
|
+
const serviceDetail = service === "healthy" ? `OR3 restarted and is deeply healthy (maintenance interval ${downtimeMs} ms).` : service === "stopped" ? "OR3 remains stopped as requested." : service === "running" ? "OR3 was not stopped; deep health has not been checked." : "Service recovery is unverified; OR3 may be unavailable.";
|
|
3572
|
+
if (failures.length > 0) {
|
|
3573
|
+
const message = [
|
|
3574
|
+
primaryFailure ? `Backup failed: ${primaryFailure.message}` : `Verified backup retained at ${backupDir}.`,
|
|
3575
|
+
cleanupFailure ? `Cleanup failed: ${cleanupFailure.message}` : "",
|
|
3576
|
+
cleanupDetail,
|
|
3577
|
+
restartFailure ? `Restart failed: ${restartFailure.message}` : "",
|
|
3578
|
+
...progressFailures.map((error) => `Progress recording failed: ${error.message}`),
|
|
3579
|
+
serviceDetail,
|
|
3580
|
+
RECOVERY_STEPS
|
|
3581
|
+
].filter(Boolean).join(" ");
|
|
3582
|
+
try {
|
|
3583
|
+
await report("failed", message);
|
|
3584
|
+
} catch (error) {
|
|
3585
|
+
failures.push(asError(error));
|
|
3586
|
+
}
|
|
3587
|
+
throw new AggregateError(failures, message);
|
|
3588
|
+
}
|
|
3589
|
+
try {
|
|
3590
|
+
await report("complete", `Backup verified at ${backupDir}. ${serviceDetail}`);
|
|
3591
|
+
} catch (error) {
|
|
3592
|
+
throw new Error(`Verified backup retained at ${backupDir}. ${serviceDetail} Could not record backup completion: ${asError(error).message}. ${RECOVERY_STEPS}`, { cause: error });
|
|
3593
|
+
}
|
|
3594
|
+
return { backupId, backupDir, manifest: verifiedManifest };
|
|
3595
|
+
}
|
|
3596
|
+
async function inspectUnclaimedBackupArtifacts(directory, backupId, backupPath) {
|
|
3597
|
+
if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
|
|
3598
|
+
return;
|
|
3599
|
+
const expectedPath = resolve9(backupDirectory(directory, backupId));
|
|
3600
|
+
if (resolve9(backupPath) !== expectedPath)
|
|
3601
|
+
return;
|
|
3602
|
+
const statuses = await Promise.all([
|
|
3603
|
+
artifactStatus(expectedPath),
|
|
3604
|
+
artifactStatus(join15(deploymentPaths(directory).exports, `${backupId}.json`))
|
|
3605
|
+
]);
|
|
3606
|
+
const detail = statuses.map((status) => status.detail).join(" ");
|
|
3607
|
+
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".`;
|
|
3382
3608
|
}
|
|
3383
3609
|
async function cleanupJournaledPartialBackup(directory, backupId, backupPath) {
|
|
3384
3610
|
if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
|
|
3385
3611
|
return;
|
|
3386
3612
|
const expectedPath = resolve9(backupDirectory(directory, backupId));
|
|
3387
|
-
if (resolve9(backupPath) !== expectedPath
|
|
3613
|
+
if (resolve9(backupPath) !== expectedPath)
|
|
3388
3614
|
return;
|
|
3389
3615
|
try {
|
|
3390
3616
|
await readManifest(expectedPath, directory);
|
|
3617
|
+
return { artifact: "verified", detail: `Verified backup retained at ${expectedPath}.` };
|
|
3391
3618
|
} catch {
|
|
3392
|
-
await
|
|
3619
|
+
const cleanup = await cleanupPartialBackup(directory, backupId);
|
|
3620
|
+
if (cleanup.error)
|
|
3621
|
+
throw new Error(`Partial backup cleanup failed: ${cleanup.error.message} ${cleanup.detail} ${RECOVERY_STEPS}`, { cause: cleanup.error });
|
|
3622
|
+
return { artifact: "removed", detail: cleanup.detail };
|
|
3393
3623
|
}
|
|
3394
3624
|
}
|
|
3395
3625
|
async function createPreMutationSnapshot(directory, state, env, backupId) {
|
|
3396
|
-
return await createBackup(directory, state, env, { restartAfter: false, backupId });
|
|
3626
|
+
return await createBackup(directory, state, env, { restartAfter: false, backupId, safetySnapshot: true });
|
|
3397
3627
|
}
|
|
3398
3628
|
|
|
3399
3629
|
// src/backup/export.ts
|
|
3400
3630
|
import { chmod as chmod7, mkdir as mkdir7, realpath, rm as rm6, stat as stat3 } from "node:fs/promises";
|
|
3401
|
-
import { basename as basename5, dirname as dirname5, isAbsolute as isAbsolute3, join as
|
|
3631
|
+
import { basename as basename5, dirname as dirname5, isAbsolute as isAbsolute3, join as join16, resolve as resolve10, sep } from "node:path";
|
|
3402
3632
|
var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
|
|
3403
3633
|
async function exportBackup(directory, backupId, destination) {
|
|
3404
3634
|
const backupPath = await resolveBackup(directory, backupId);
|
|
@@ -3412,7 +3642,7 @@ async function exportBackup(directory, backupId, destination) {
|
|
|
3412
3642
|
if (await fileExists(dest))
|
|
3413
3643
|
throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
|
|
3414
3644
|
await mkdir7(dirname5(dest), { recursive: true, mode: 448 });
|
|
3415
|
-
const canonicalDestination =
|
|
3645
|
+
const canonicalDestination = join16(await realpath(dirname5(dest)), basename5(dest));
|
|
3416
3646
|
if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`)) {
|
|
3417
3647
|
throw new Error("Backup exports must live outside the managed deployment directory so `remove --purge-data` can never delete the remaining copy.");
|
|
3418
3648
|
}
|
|
@@ -3423,19 +3653,19 @@ async function exportBackup(directory, backupId, destination) {
|
|
|
3423
3653
|
}
|
|
3424
3654
|
await chmod7(dest, 448);
|
|
3425
3655
|
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
|
|
3426
|
-
await copySecure(
|
|
3656
|
+
await copySecure(join16(backupPath, file), join16(dest, file));
|
|
3427
3657
|
}
|
|
3428
3658
|
if (manifest2.managedAssetSha256) {
|
|
3429
3659
|
for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
|
|
3430
|
-
await copySecure(
|
|
3660
|
+
await copySecure(join16(backupPath, "managed-assets", name), join16(dest, "managed-assets", name));
|
|
3431
3661
|
}
|
|
3432
3662
|
}
|
|
3433
|
-
const dataSha = await sha256File(
|
|
3663
|
+
const dataSha = await sha256File(join16(dest, "data.tgz"));
|
|
3434
3664
|
if (dataSha !== manifest2.dataSha256) {
|
|
3435
3665
|
throw new Error(`Exported data.tgz checksum mismatch for ${manifest2.backupId}. Expected ${manifest2.dataSha256}, got ${dataSha}.`);
|
|
3436
3666
|
}
|
|
3437
3667
|
if (manifest2.configSha256) {
|
|
3438
|
-
const configSha = await sha256File(
|
|
3668
|
+
const configSha = await sha256File(join16(dest, "config.env"));
|
|
3439
3669
|
if (configSha !== manifest2.configSha256) {
|
|
3440
3670
|
throw new Error(`Exported config.env checksum mismatch for ${manifest2.backupId}.`);
|
|
3441
3671
|
}
|
|
@@ -3452,12 +3682,12 @@ async function exportBackup(directory, backupId, destination) {
|
|
|
3452
3682
|
dataSha256: manifest2.dataSha256,
|
|
3453
3683
|
configSha256: manifest2.configSha256
|
|
3454
3684
|
};
|
|
3455
|
-
await writeSecure(
|
|
3685
|
+
await writeSecure(join16(deploymentPaths(directory).exports, `${manifest2.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
|
|
3456
3686
|
`);
|
|
3457
|
-
let bytes = (await stat3(
|
|
3687
|
+
let bytes = (await stat3(join16(dest, "data.tgz"))).size + (await stat3(join16(dest, "config.env"))).size + (await stat3(join16(dest, "manifest.json"))).size + (await stat3(join16(dest, "manifest.auth"))).size;
|
|
3458
3688
|
if (manifest2.managedAssetSha256) {
|
|
3459
3689
|
for (const name of managedAssetNamesForInventory(manifest2.mode, manifest2.managedAssetInventoryVersion)) {
|
|
3460
|
-
bytes += (await stat3(
|
|
3690
|
+
bytes += (await stat3(join16(dest, "managed-assets", name))).size;
|
|
3461
3691
|
}
|
|
3462
3692
|
}
|
|
3463
3693
|
console.log(`Exported backup ${manifest2.backupId} (${bytes} bytes) to ${dest}`);
|
|
@@ -3480,7 +3710,7 @@ function assertPurgeBackupFreshness(backups, nowMs) {
|
|
|
3480
3710
|
}
|
|
3481
3711
|
async function readBackupExportReceipt(directory, backupId) {
|
|
3482
3712
|
try {
|
|
3483
|
-
const receipt = JSON.parse(await readText(
|
|
3713
|
+
const receipt = JSON.parse(await readText(join16(deploymentPaths(directory).exports, `${backupId}.json`)));
|
|
3484
3714
|
if (receipt.schemaVersion !== 1 || receipt.backupId !== backupId || typeof receipt.destination !== "string" || !isAbsolute3(receipt.destination) || typeof receipt.destinationDevice !== "number" || !receipt.dataSha256)
|
|
3485
3715
|
return;
|
|
3486
3716
|
return receipt;
|
|
@@ -3530,26 +3760,57 @@ async function backupCreateCommand(directory) {
|
|
|
3530
3760
|
id: id("backup-operation"),
|
|
3531
3761
|
operation: "backup",
|
|
3532
3762
|
startedAt: now(),
|
|
3533
|
-
message: "
|
|
3763
|
+
message: "Preflight: preparing a stopped-volume backup",
|
|
3534
3764
|
backupId,
|
|
3535
3765
|
backupPath: backupDirectory(loaded.directory, backupId),
|
|
3536
3766
|
initialAppRunning,
|
|
3537
3767
|
phase: "prepared"
|
|
3538
3768
|
};
|
|
3539
3769
|
await markPending(loaded.directory, loaded.state, pending);
|
|
3770
|
+
let result;
|
|
3771
|
+
try {
|
|
3772
|
+
result = await createBackup(loaded.directory, loaded.state, loaded.env, {
|
|
3773
|
+
backupId,
|
|
3774
|
+
initiallyRunning: initialAppRunning,
|
|
3775
|
+
onProgress: async (progress) => {
|
|
3776
|
+
const message = redact(progress.message, secretValues(loaded.env));
|
|
3777
|
+
console.error(message);
|
|
3778
|
+
await updatePending(loaded.directory, loaded.state, { message, backupProgress: { ...progress, message } });
|
|
3779
|
+
}
|
|
3780
|
+
});
|
|
3781
|
+
const completed = structuredClone(loaded.state);
|
|
3782
|
+
completed.lastError = undefined;
|
|
3783
|
+
await clearPending(loaded.directory, completed);
|
|
3784
|
+
loaded.state = completed;
|
|
3785
|
+
} catch (error) {
|
|
3786
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3787
|
+
loaded.state.lastError = redact(result ? `Verified backup retained at ${result.backupDir}. Completion recording failed: ${detail}. Keep the operation record and run "npx @or3/cloud recover --dry-run", then "npx @or3/cloud recover".` : detail, secretValues(loaded.env));
|
|
3788
|
+
if (result && loaded.state.incompleteOperation?.backupProgress) {
|
|
3789
|
+
loaded.state.incompleteOperation.backupProgress = {
|
|
3790
|
+
...loaded.state.incompleteOperation.backupProgress,
|
|
3791
|
+
stage: "failed",
|
|
3792
|
+
message: loaded.state.lastError
|
|
3793
|
+
};
|
|
3794
|
+
}
|
|
3795
|
+
try {
|
|
3796
|
+
await writeState(loaded.directory, loaded.state);
|
|
3797
|
+
} catch (stateError) {
|
|
3798
|
+
throw new AggregateError([error, stateError], `${loaded.state.lastError} Could not record the backup failure: ${redact(stateError instanceof Error ? stateError.message : String(stateError), secretValues(loaded.env))}. Keep the existing operation record and run "npx @or3/cloud recover --dry-run".`);
|
|
3799
|
+
}
|
|
3800
|
+
if (result)
|
|
3801
|
+
throw new Error(loaded.state.lastError, { cause: error });
|
|
3802
|
+
throw error;
|
|
3803
|
+
}
|
|
3804
|
+
console.log(`Backup ${result.backupId} created at ${result.backupDir}`);
|
|
3805
|
+
console.log(`SHA-256: ${result.manifest.dataSha256}`);
|
|
3806
|
+
console.error("Maintenance: checking backup retention after snapshot verification and service recovery.");
|
|
3540
3807
|
try {
|
|
3541
|
-
const result = await createBackup(loaded.directory, loaded.state, loaded.env, { backupId, initiallyRunning: initialAppRunning });
|
|
3542
|
-
await clearPending(loaded.directory, loaded.state);
|
|
3543
3808
|
const prune = await pruneBackups(loaded.directory, loaded.state, BACKUP_RETENTION_KEEP, false, { automatic: true });
|
|
3544
|
-
console.log(`Backup ${result.backupId} created at ${result.backupDir}`);
|
|
3545
|
-
console.log(`SHA-256: ${result.manifest.dataSha256}`);
|
|
3546
3809
|
if (prune.deferred.length > 0) {
|
|
3547
3810
|
console.warn(`Maintenance warning: ${prune.deferred.length} backup entr${prune.deferred.length === 1 ? "y" : "ies"} need inspection; all backups were preserved. Run "npx @or3/cloud backup list" for details.`);
|
|
3548
3811
|
}
|
|
3549
3812
|
} catch (error) {
|
|
3550
|
-
|
|
3551
|
-
await writeState(loaded.directory, loaded.state);
|
|
3552
|
-
throw error;
|
|
3813
|
+
console.warn(`Maintenance warning: backup ${result.backupId} is verified and committed, but retention did not complete: ${redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env))}. Run "npx @or3/cloud backup list" to inspect the remaining backups.`);
|
|
3553
3814
|
}
|
|
3554
3815
|
}
|
|
3555
3816
|
async function backupListCommand(directory, flags = {}) {
|
|
@@ -3653,7 +3914,7 @@ async function backupCommand(directory, positionals, flags) {
|
|
|
3653
3914
|
|
|
3654
3915
|
// src/credentials/reset.ts
|
|
3655
3916
|
import { rm as rm7 } from "node:fs/promises";
|
|
3656
|
-
import { join as
|
|
3917
|
+
import { join as join17 } from "node:path";
|
|
3657
3918
|
|
|
3658
3919
|
// src/credentials/reset-script.ts
|
|
3659
3920
|
function buildCredentialsResetScript(input2) {
|
|
@@ -3846,7 +4107,7 @@ async function applyCredentialReset(directory, state, nextEnv) {
|
|
|
3846
4107
|
await runCredentialsResetScript(directory, state.mode, values);
|
|
3847
4108
|
const runtimeEnv = withoutProvisioningCredentials(nextEnv);
|
|
3848
4109
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
|
|
3849
|
-
const initialCredentials =
|
|
4110
|
+
const initialCredentials = join17(directory, ".or3-initial-credentials");
|
|
3850
4111
|
await rm7(initialCredentials, { force: true });
|
|
3851
4112
|
await stopProject(directory, state.mode);
|
|
3852
4113
|
await startProject(directory, state.mode, runtimeEnv);
|
|
@@ -3908,7 +4169,7 @@ async function credentialsCommand(directory, positionals, flags) {
|
|
|
3908
4169
|
// src/commands/doctor.ts
|
|
3909
4170
|
import { existsSync as existsSync3 } from "node:fs";
|
|
3910
4171
|
import { stat as stat4 } from "node:fs/promises";
|
|
3911
|
-
import { basename as basename6, join as
|
|
4172
|
+
import { basename as basename6, join as join18, resolve as resolve11 } from "node:path";
|
|
3912
4173
|
async function doctorCommand(directory) {
|
|
3913
4174
|
const resolved = resolve11(directory);
|
|
3914
4175
|
const paths = deploymentPaths(resolved);
|
|
@@ -3933,7 +4194,7 @@ async function doctorCommand(directory) {
|
|
|
3933
4194
|
failures.push(error instanceof Error ? error.message : String(error));
|
|
3934
4195
|
console.log("✗ Managed state or .env is missing/invalid");
|
|
3935
4196
|
}
|
|
3936
|
-
for (const file of [paths.env, paths.state,
|
|
4197
|
+
for (const file of [paths.env, paths.state, join18(resolved, ".or3-initial-credentials")]) {
|
|
3937
4198
|
if (!existsSync3(file))
|
|
3938
4199
|
continue;
|
|
3939
4200
|
const mode = (await stat4(file)).mode & 511;
|
|
@@ -4005,7 +4266,7 @@ async function doctorCommand(directory) {
|
|
|
4005
4266
|
}
|
|
4006
4267
|
assertDashboardOperatorMounts(container.Mounts ?? [], resolved);
|
|
4007
4268
|
const ipcDirectory = await stat4(paths.operatorIpc);
|
|
4008
|
-
const operatorSocket = await stat4(
|
|
4269
|
+
const operatorSocket = await stat4(join18(paths.operatorIpc, "operator.sock"));
|
|
4009
4270
|
const operatorUid = Number(env.OR3_OPERATOR_UID);
|
|
4010
4271
|
const operatorGid = Number(env.OR3_OPERATOR_GID);
|
|
4011
4272
|
if ((ipcDirectory.mode & 511) !== 456 || ipcDirectory.uid !== operatorUid || ipcDirectory.gid !== operatorGid || !operatorSocket.isSocket() || (operatorSocket.mode & 511) !== 432 || operatorSocket.uid !== operatorUid || operatorSocket.gid !== operatorGid) {
|
|
@@ -4064,7 +4325,7 @@ async function doctorCommand(directory) {
|
|
|
4064
4325
|
|
|
4065
4326
|
// src/commands/init.ts
|
|
4066
4327
|
import { chmod as chmod8, mkdir as mkdir8 } from "node:fs/promises";
|
|
4067
|
-
import { join as
|
|
4328
|
+
import { join as join19, resolve as resolve12 } from "node:path";
|
|
4068
4329
|
async function initCommand(positionals, flags) {
|
|
4069
4330
|
const hasLocal = boolFlag(flags, "local");
|
|
4070
4331
|
const hasPublic = boolFlag(flags, "public");
|
|
@@ -4072,7 +4333,7 @@ async function initCommand(positionals, flags) {
|
|
|
4072
4333
|
throw new Error("Choose exactly one of --local or --public.");
|
|
4073
4334
|
const mode = hasPublic ? "public" : "local";
|
|
4074
4335
|
const directory = resolve12(process.cwd(), positionals[0] ?? "or3-cloud");
|
|
4075
|
-
if (await fileExists(
|
|
4336
|
+
if (await fileExists(join19(directory, ".or3-cloud", "state.json"))) {
|
|
4076
4337
|
throw new Error(`${directory} is already a managed deployment. Use update, doctor, or adopt.`);
|
|
4077
4338
|
}
|
|
4078
4339
|
await readDirectoryEmpty(directory);
|
|
@@ -4123,7 +4384,7 @@ ${existing.stderr}`)) {
|
|
|
4123
4384
|
await copyAssets(directory, mode);
|
|
4124
4385
|
const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
|
|
4125
4386
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
|
|
4126
|
-
await writeSecure(
|
|
4387
|
+
await writeSecure(join19(directory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
4127
4388
|
bootstrapEmail: email,
|
|
4128
4389
|
bootstrapPassword: password,
|
|
4129
4390
|
adminUsername: email,
|
|
@@ -4146,7 +4407,7 @@ ${existing.stderr}`)) {
|
|
|
4146
4407
|
await clearPending(directory, state);
|
|
4147
4408
|
console.log(`
|
|
4148
4409
|
OR3 Cloud ${version} is running at ${mode === "public" ? `https://${domain}` : `http://127.0.0.1:${port}`}`);
|
|
4149
|
-
console.log(`Credentials were written to ${
|
|
4410
|
+
console.log(`Credentials were written to ${join19(directory, ".or3-initial-credentials")} (mode 0600). Move them to protected storage (a password manager), then remove that file.`);
|
|
4150
4411
|
console.log('The bootstrap owner signs in to the app; the admin account manages the deployment. Rotate either later with "npx @or3/cloud credentials reset --yes".');
|
|
4151
4412
|
console.log(`
|
|
4152
4413
|
Check: cd ${quote(directory)} && npx @or3/cloud doctor`);
|
|
@@ -4191,28 +4452,93 @@ async function restartCommand(directory) {
|
|
|
4191
4452
|
}
|
|
4192
4453
|
|
|
4193
4454
|
// src/commands/recover.ts
|
|
4194
|
-
import { join as
|
|
4455
|
+
import { join as join22 } from "node:path";
|
|
4195
4456
|
|
|
4196
4457
|
// src/backup/restore.ts
|
|
4197
|
-
import { join as
|
|
4458
|
+
import { join as join20 } from "node:path";
|
|
4459
|
+
function assertFilesystemPayloadHash(key, readChunk, createHash3) {
|
|
4460
|
+
const match = /^(sha256|md5)_([a-fA-F0-9]+)$/.exec(key);
|
|
4461
|
+
const algorithm = match?.[1];
|
|
4462
|
+
const expected = match?.[2];
|
|
4463
|
+
if (algorithm !== "sha256" && algorithm !== "md5" || expected?.length !== (algorithm === "sha256" ? 64 : 32)) {
|
|
4464
|
+
throw new Error("Invalid canonical filesystem payload key");
|
|
4465
|
+
}
|
|
4466
|
+
const hash = createHash3(algorithm);
|
|
4467
|
+
for (let chunk = readChunk();chunk !== undefined; chunk = readChunk())
|
|
4468
|
+
hash.update(chunk);
|
|
4469
|
+
if (hash.digest("hex") !== expected.toLowerCase())
|
|
4470
|
+
throw new Error("Filesystem payload hash mismatch");
|
|
4471
|
+
}
|
|
4472
|
+
var VERIFY_RESTORED_DATA_SCRIPT = `
|
|
4473
|
+
const fs = require('node:fs');
|
|
4474
|
+
const Database = require('/app/.output/server/node_modules/better-sqlite3');
|
|
4475
|
+
const validFilesystemPayload = ${assertFilesystemPayloadHash.toString()};
|
|
4476
|
+
const paths = [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH].filter(Boolean);
|
|
4477
|
+
if (paths.length !== 2) throw new Error('Both managed database paths must be configured');
|
|
4478
|
+
for (const path of paths) {
|
|
4479
|
+
const databasePath = fs.realpathSync(path);
|
|
4480
|
+
if (!fs.statSync(databasePath).isFile()) throw new Error('Restored database payload must be a regular file');
|
|
4481
|
+
const walPath = databasePath + '-wal';
|
|
4482
|
+
if (fs.existsSync(walPath)) {
|
|
4483
|
+
const info = fs.statSync(walPath);
|
|
4484
|
+
if (!info.isFile()) throw new Error('Restored WAL payload must be a regular file');
|
|
4485
|
+
|
|
4486
|
+
}
|
|
4487
|
+
const db = new Database(databasePath, { readonly: true, fileMustExist: true });
|
|
4488
|
+
try {
|
|
4489
|
+
if (db.pragma('quick_check', { simple: true }) !== 'ok') throw new Error('SQLite quick_check failed');
|
|
4490
|
+
const tables = db.prepare("select count(*) as count from sqlite_master where type = 'table'").get().count;
|
|
4491
|
+
if (tables < 1) throw new Error('Restored database has no tables');
|
|
4492
|
+
} finally { db.close(); }
|
|
4493
|
+
}
|
|
4494
|
+
const path = require('node:path');
|
|
4495
|
+
const crypto = require('node:crypto');
|
|
4496
|
+
const storageRoot = path.resolve(process.env.OR3_STORAGE_FS_ROOT || '/data/storage');
|
|
4497
|
+
const workspacesRoot = path.join(storageRoot, 'workspaces');
|
|
4498
|
+
if ((storageRoot === '/data' || storageRoot.startsWith('/data/')) && fs.existsSync(workspacesRoot)) {
|
|
4499
|
+
const chunk = Buffer.alloc(65536);
|
|
4500
|
+
for (const workspace of fs.readdirSync(workspacesRoot, { withFileTypes: true }).filter(entry => /^[a-zA-Z0-9_-]+$/.test(entry.name))) {
|
|
4501
|
+
const directory = path.join(workspacesRoot, workspace.name);
|
|
4502
|
+
if (!fs.statSync(directory).isDirectory()) continue;
|
|
4503
|
+
for (const entry of fs.readdirSync(directory)) {
|
|
4504
|
+
if (!/^(sha256_[a-fA-F0-9]{64}|md5_[a-fA-F0-9]{32})[.]meta[.]json$/.test(entry)) continue;
|
|
4505
|
+
const key = entry.slice(0, -10);
|
|
4506
|
+
const file = path.join(directory, key);
|
|
4507
|
+
if (!fs.statSync(file).isFile()) throw new Error('Restored filesystem payload must be a regular file');
|
|
4508
|
+
const fd = fs.openSync(file, 'r');
|
|
4509
|
+
try {
|
|
4510
|
+
validFilesystemPayload(key, () => {
|
|
4511
|
+
const count = fs.readSync(fd, chunk, 0, chunk.length, null);
|
|
4512
|
+
return count ? chunk.subarray(0, count) : undefined;
|
|
4513
|
+
}, algorithm => crypto.createHash(algorithm));
|
|
4514
|
+
} finally { fs.closeSync(fd); }
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4518
|
+
`;
|
|
4198
4519
|
async function assertRestoreFreeSpace(directory, state, env, manifest2, backupPath) {
|
|
4199
|
-
const archiveBytes = await gzipUncompressedBytes(
|
|
4520
|
+
const archiveBytes = await gzipUncompressedBytes(join20(backupPath, "data.tgz"));
|
|
4200
4521
|
const requiredBytes = Math.max(manifest2.dataBytes ?? 0, archiveBytes);
|
|
4201
4522
|
const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
|
|
4202
|
-
const reclaimableBytes = await dataVolumeSize(directory, state.mode, env);
|
|
4523
|
+
const reclaimableBytes = await dataVolumeSize(directory, state.mode, env, "allocated");
|
|
4203
4524
|
assertEnoughFreeSpace(freeBytes + reclaimableBytes, requiredBytes, "Restore");
|
|
4204
4525
|
}
|
|
4205
4526
|
async function restoreBackupData(directory, state, env, backupPath, options = {}) {
|
|
4206
4527
|
const manifest2 = await readManifest(backupPath, directory);
|
|
4207
4528
|
assertRestorableManagedAssets(manifest2);
|
|
4208
|
-
const backupEnv = parseEnv(await readText(
|
|
4529
|
+
const backupEnv = parseEnv(await readText(join20(backupPath, "config.env")));
|
|
4209
4530
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
4210
4531
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
4211
4532
|
}
|
|
4212
4533
|
assertBackupMatchesDeployment(manifest2, backupEnv, state, env);
|
|
4213
4534
|
if (manifest2.imageDigest)
|
|
4214
4535
|
await requireImageDigest(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
|
|
4215
|
-
await validateVolumeArchive(directory, state.mode, env, backupPath);
|
|
4536
|
+
await validateVolumeArchive(directory, state.mode, env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
|
|
4537
|
+
if (!options.previousSafetySnapshot) {
|
|
4538
|
+
await validateCanonicalBackupReferences(directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
|
|
4539
|
+
} else {
|
|
4540
|
+
console.warn("Restoring the recorded byte-preservation safety snapshot; canonical attachment completeness is not certified. Verify previously uploaded files.");
|
|
4541
|
+
}
|
|
4216
4542
|
await ensureManagedDataVolume(directory, state.mode, state, env);
|
|
4217
4543
|
await assertRestoreFreeSpace(directory, state, env, manifest2, backupPath);
|
|
4218
4544
|
const restoresOperatorOverlay = manifest2.managedAssetInventoryVersion === MANAGED_ASSET_INVENTORY_VERSION && Boolean(manifest2.managedAssetSha256?.["compose.operator.yaml"]);
|
|
@@ -4233,6 +4559,33 @@ async function restoreBackupData(directory, state, env, backupPath, options = {}
|
|
|
4233
4559
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(restoredEnv));
|
|
4234
4560
|
await restoreManagedAssets(directory, backupPath, manifest2);
|
|
4235
4561
|
await restoreVolumeArchive(directory, state.mode, restoredEnv, backupPath);
|
|
4562
|
+
const snapshotCheck = await run("docker", [
|
|
4563
|
+
"run",
|
|
4564
|
+
"--rm",
|
|
4565
|
+
"--network",
|
|
4566
|
+
"none",
|
|
4567
|
+
"--read-only",
|
|
4568
|
+
"--security-opt",
|
|
4569
|
+
"no-new-privileges:true",
|
|
4570
|
+
"--cap-drop",
|
|
4571
|
+
"ALL",
|
|
4572
|
+
"-v",
|
|
4573
|
+
`${state.volumeName}:/data`,
|
|
4574
|
+
"--env",
|
|
4575
|
+
`OR3_BASIC_AUTH_DB_PATH=${restoredEnv.OR3_BASIC_AUTH_DB_PATH ?? ""}`,
|
|
4576
|
+
"--env",
|
|
4577
|
+
`OR3_SQLITE_DB_PATH=${restoredEnv.OR3_SQLITE_DB_PATH ?? ""}`,
|
|
4578
|
+
"--env",
|
|
4579
|
+
`OR3_STORAGE_FS_ROOT=${restoredEnv.OR3_STORAGE_FS_ROOT ?? "/data/storage"}`,
|
|
4580
|
+
"--entrypoint",
|
|
4581
|
+
"sh",
|
|
4582
|
+
restoredEnv.OR3_IMAGE,
|
|
4583
|
+
...containerNodeCommand(VERIFY_RESTORED_DATA_SCRIPT).slice(1)
|
|
4584
|
+
], directory);
|
|
4585
|
+
if (!snapshotCheck.ok) {
|
|
4586
|
+
throw new Error(`Restored snapshot integrity verification failed. ${redact(snapshotCheck.stderr, secretValues(restoredEnv))}`);
|
|
4587
|
+
}
|
|
4588
|
+
await options.beforeStart?.();
|
|
4236
4589
|
await startProject(directory, state.mode, restoredEnv);
|
|
4237
4590
|
return manifest2;
|
|
4238
4591
|
}
|
|
@@ -4243,14 +4596,14 @@ async function restorePreMutationSnapshot(directory, state, env) {
|
|
|
4243
4596
|
const previous = await recordedBackupPath(directory, pending);
|
|
4244
4597
|
await updatePending(directory, state, { phase: "restoring-previous" });
|
|
4245
4598
|
if (pending.recreateDataVolume) {
|
|
4246
|
-
await restoreBackupData(directory, state, env, previous.path, { recreateDataVolume: true });
|
|
4599
|
+
await restoreBackupData(directory, state, env, previous.path, { recreateDataVolume: true, previousSafetySnapshot: true });
|
|
4247
4600
|
return previous;
|
|
4248
4601
|
}
|
|
4249
4602
|
if (pending.previousRootOwnership) {
|
|
4250
4603
|
await pullAndRequireImage(state.image, state.imageDigest, "Previous deployment");
|
|
4251
4604
|
await setManagedVolumeRootOwnership(directory, state, env, state.image, pending.previousRootOwnership);
|
|
4252
4605
|
}
|
|
4253
|
-
await restoreBackupData(directory, state, env, previous.path);
|
|
4606
|
+
await restoreBackupData(directory, state, env, previous.path, { previousSafetySnapshot: true });
|
|
4254
4607
|
return previous;
|
|
4255
4608
|
}
|
|
4256
4609
|
|
|
@@ -4261,11 +4614,11 @@ import { resolve as resolve13 } from "node:path";
|
|
|
4261
4614
|
// src/deployment/lease.ts
|
|
4262
4615
|
import { randomBytes as randomBytes8 } from "node:crypto";
|
|
4263
4616
|
import { mkdir as mkdir9, rename as rename2, rm as rm8 } from "node:fs/promises";
|
|
4264
|
-
import { dirname as dirname6, join as
|
|
4617
|
+
import { dirname as dirname6, join as join21 } from "node:path";
|
|
4265
4618
|
var DASHBOARD_LEASE_STALE_MS = 30000;
|
|
4266
4619
|
async function readLeaseOwner(leasePath) {
|
|
4267
4620
|
try {
|
|
4268
|
-
const owner = JSON.parse(await readText(
|
|
4621
|
+
const owner = JSON.parse(await readText(join21(leasePath, "owner.json")));
|
|
4269
4622
|
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")
|
|
4270
4623
|
return;
|
|
4271
4624
|
return owner;
|
|
@@ -4324,7 +4677,7 @@ async function acquireDeploymentLease(directory, command) {
|
|
|
4324
4677
|
}
|
|
4325
4678
|
}
|
|
4326
4679
|
try {
|
|
4327
|
-
await writeSecure(
|
|
4680
|
+
await writeSecure(join21(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
|
|
4328
4681
|
`);
|
|
4329
4682
|
} catch (error) {
|
|
4330
4683
|
await rm8(leasePath, { recursive: true, force: true }).catch(() => {
|
|
@@ -4334,7 +4687,7 @@ async function acquireDeploymentLease(directory, command) {
|
|
|
4334
4687
|
}
|
|
4335
4688
|
const heartbeat = setInterval(() => {
|
|
4336
4689
|
owner.heartbeatAt = now();
|
|
4337
|
-
writeSecure(
|
|
4690
|
+
writeSecure(join21(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
|
|
4338
4691
|
`).catch(() => {
|
|
4339
4692
|
return;
|
|
4340
4693
|
});
|
|
@@ -4484,7 +4837,14 @@ function publicStateProjection(state) {
|
|
|
4484
4837
|
origin: pending.origin ?? null,
|
|
4485
4838
|
dashboardJobId: pending.dashboardJobId ?? null,
|
|
4486
4839
|
targetVersion: pending.targetVersion ?? null,
|
|
4487
|
-
targetImageDigest: pending.targetImageDigest ?? null
|
|
4840
|
+
targetImageDigest: pending.targetImageDigest ?? null,
|
|
4841
|
+
backupProgress: pending.backupProgress ? {
|
|
4842
|
+
stage: pending.backupProgress.stage,
|
|
4843
|
+
service: pending.backupProgress.service,
|
|
4844
|
+
artifact: pending.backupProgress.artifact,
|
|
4845
|
+
downtimeMs: pending.backupProgress.downtimeMs ?? null,
|
|
4846
|
+
message: redact(pending.backupProgress.message)
|
|
4847
|
+
} : null
|
|
4488
4848
|
} : null,
|
|
4489
4849
|
lastReceipt: state.lastReceipt ?? null,
|
|
4490
4850
|
lastError: state.lastError ? redact(state.lastError) : null
|
|
@@ -4827,8 +5187,51 @@ async function runRecovery(directory, context) {
|
|
|
4827
5187
|
}
|
|
4828
5188
|
const requiresExplicitRestore = decision.action === "require-explicit-restore";
|
|
4829
5189
|
try {
|
|
5190
|
+
if (pending.operation === "backup") {
|
|
5191
|
+
const failures = [];
|
|
5192
|
+
let service = pending.initialAppRunning === false ? "stopped" : "unknown";
|
|
5193
|
+
let artifact = pending.backupProgress?.artifact ?? "not-created";
|
|
5194
|
+
let artifactDetail = "";
|
|
5195
|
+
if (pending.initialAppRunning !== false) {
|
|
5196
|
+
report("Restart: recovering OR3 and checking deep health before backup cleanup.");
|
|
5197
|
+
try {
|
|
5198
|
+
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
5199
|
+
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
5200
|
+
service = "healthy";
|
|
5201
|
+
} catch (error) {
|
|
5202
|
+
failures.push(error instanceof Error ? error : new Error(String(error)));
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
try {
|
|
5206
|
+
if (artifact === "not-created") {
|
|
5207
|
+
artifactDetail = await inspectUnclaimedBackupArtifacts(loaded.directory, pending.backupId, pending.backupPath) ?? "";
|
|
5208
|
+
} else {
|
|
5209
|
+
const cleanup = await cleanupJournaledPartialBackup(loaded.directory, pending.backupId, pending.backupPath);
|
|
5210
|
+
if (cleanup) {
|
|
5211
|
+
artifact = cleanup.artifact;
|
|
5212
|
+
report(cleanup.detail);
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
} catch (error) {
|
|
5216
|
+
if (artifact !== "not-created")
|
|
5217
|
+
artifact = "unknown";
|
|
5218
|
+
failures.push(error instanceof Error ? error : new Error(String(error)));
|
|
5219
|
+
}
|
|
5220
|
+
const serviceDetail = service === "healthy" ? "OR3 is deeply healthy." : service === "stopped" ? "The intentionally stopped OR3 service was preserved." : "Service recovery is unverified; OR3 may be unavailable.";
|
|
5221
|
+
if (failures.length > 0) {
|
|
5222
|
+
const message = [failures.map((error) => error.message).join(" "), serviceDetail, artifactDetail].filter(Boolean).join(" ");
|
|
5223
|
+
pending.backupProgress = { stage: "failed", service, artifact, message: redact(message, secretValues(loaded.env)) };
|
|
5224
|
+
throw new AggregateError(failures, message);
|
|
5225
|
+
}
|
|
5226
|
+
const completed = structuredClone(loaded.state);
|
|
5227
|
+
completed.lastError = undefined;
|
|
5228
|
+
await clearPending(loaded.directory, completed);
|
|
5229
|
+
const detail2 = [`Recovered the incomplete backup operation. ${serviceDetail}`, artifactDetail].filter(Boolean).join(" ");
|
|
5230
|
+
report(detail2);
|
|
5231
|
+
return { kind: "recovered", operation: "backup", detail: detail2 };
|
|
5232
|
+
}
|
|
4830
5233
|
if (pending.phase === "prepared") {
|
|
4831
|
-
if (pending.operation === "
|
|
5234
|
+
if (pending.operation === "update" || pending.operation === "adopt") {
|
|
4832
5235
|
await cleanupJournaledPartialBackup(loaded.directory, pending.backupId, pending.backupPath);
|
|
4833
5236
|
}
|
|
4834
5237
|
if (pending.operation === "restore" || pending.operation === "rollback") {
|
|
@@ -4906,17 +5309,10 @@ async function runRecovery(directory, context) {
|
|
|
4906
5309
|
await stopProject(loaded.directory, loaded.state.mode);
|
|
4907
5310
|
await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
|
|
4908
5311
|
}
|
|
4909
|
-
if (pending.operation === "backup" && pending.initialAppRunning === false) {
|
|
4910
|
-
loaded.state.lastError = undefined;
|
|
4911
|
-
await clearPending(loaded.directory, loaded.state);
|
|
4912
|
-
const detail2 = "Recovered the incomplete backup operation and preserved the intentionally stopped OR3 service.";
|
|
4913
|
-
report(detail2);
|
|
4914
|
-
return { kind: "recovered", operation: "backup", detail: detail2 };
|
|
4915
|
-
}
|
|
4916
5312
|
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
4917
5313
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
4918
5314
|
let recoveredEnv = loaded.env;
|
|
4919
|
-
if (
|
|
5315
|
+
if (loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL && loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD && loaded.env.OR3_ADMIN_PASSWORD) {
|
|
4920
5316
|
await provisionManagedCredentials(loaded.state.mode === "public" ? new URL(`https://${loaded.env.OR3_PUBLIC_DOMAIN}`) : new URL(`http://127.0.0.1:${loaded.env.OR3_PORT}`), loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL, loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD, loaded.env.OR3_ADMIN_USERNAME, loaded.env.OR3_ADMIN_PASSWORD);
|
|
4921
5317
|
recoveredEnv = withoutProvisioningCredentials(loaded.env);
|
|
4922
5318
|
await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(recoveredEnv));
|
|
@@ -4939,11 +5335,11 @@ async function runRecovery(directory, context) {
|
|
|
4939
5335
|
await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
|
|
4940
5336
|
return;
|
|
4941
5337
|
});
|
|
4942
|
-
const sourceEnv = parseEnv(await readText(
|
|
4943
|
-
const sourceFiles = ["-f",
|
|
5338
|
+
const sourceEnv = parseEnv(await readText(join22(pending.sourceDirectory, ".env")));
|
|
5339
|
+
const sourceFiles = ["-f", join22(pending.sourceDirectory, "compose.yaml")];
|
|
4944
5340
|
const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
|
|
4945
5341
|
if (sourceModeValue === "public")
|
|
4946
|
-
sourceFiles.push("-f",
|
|
5342
|
+
sourceFiles.push("-f", join22(pending.sourceDirectory, "compose.public.yaml"));
|
|
4947
5343
|
if (pending.sourceInitiallyRunning !== false) {
|
|
4948
5344
|
await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
|
|
4949
5345
|
}
|
|
@@ -4958,7 +5354,7 @@ async function runRecovery(directory, context) {
|
|
|
4958
5354
|
|
|
4959
5355
|
// src/commands/remove.ts
|
|
4960
5356
|
import { rm as rm9 } from "node:fs/promises";
|
|
4961
|
-
import { join as
|
|
5357
|
+
import { join as join23 } from "node:path";
|
|
4962
5358
|
function purgeVolumesFromState(state) {
|
|
4963
5359
|
const volumes = [state.volumeName];
|
|
4964
5360
|
if (state.mode === "public") {
|
|
@@ -4995,7 +5391,7 @@ async function removeCommand(directory, flags) {
|
|
|
4995
5391
|
for (const volume of volumes)
|
|
4996
5392
|
console.log(` docker volume ${volume}`);
|
|
4997
5393
|
for (const file of managedFiles)
|
|
4998
|
-
console.log(` ${
|
|
5394
|
+
console.log(` ${join23(directory, file)}`);
|
|
4999
5395
|
await compose(directory, state.mode, ["down"]);
|
|
5000
5396
|
await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
|
|
5001
5397
|
for (const volume of volumes) {
|
|
@@ -5005,13 +5401,13 @@ async function removeCommand(directory, flags) {
|
|
|
5005
5401
|
${result.stderr}`);
|
|
5006
5402
|
}
|
|
5007
5403
|
for (const file of managedFiles) {
|
|
5008
|
-
await rm9(
|
|
5404
|
+
await rm9(join23(directory, file), { recursive: true, force: true });
|
|
5009
5405
|
}
|
|
5010
5406
|
console.log(`Purged: data volumes, backups, .env, managed state, and compose files were deleted. The verified export at ${exportReceipt.destination} is the remaining copy.`);
|
|
5011
5407
|
}
|
|
5012
5408
|
|
|
5013
5409
|
// src/commands/restore.ts
|
|
5014
|
-
import { join as
|
|
5410
|
+
import { join as join24 } from "node:path";
|
|
5015
5411
|
async function restoreCommand(directory, flags, positionals) {
|
|
5016
5412
|
if (!boolFlag(flags, "yes"))
|
|
5017
5413
|
throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
|
|
@@ -5024,7 +5420,7 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
5024
5420
|
const backupPath = await resolveBackup(loaded.directory, backupValue);
|
|
5025
5421
|
const manifest2 = await readManifest(backupPath, loaded.directory);
|
|
5026
5422
|
assertRestorableManagedAssets(manifest2);
|
|
5027
|
-
const backupEnv = parseEnv(await readText(
|
|
5423
|
+
const backupEnv = parseEnv(await readText(join24(backupPath, "config.env")));
|
|
5028
5424
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
5029
5425
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
5030
5426
|
}
|
|
@@ -5034,6 +5430,8 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
5034
5430
|
await pullAndRequireImage(manifest2.image, manifest2.imageDigest, `Backup ${manifest2.backupId}`);
|
|
5035
5431
|
await assertSupportedHostArchitecture(manifest2.image);
|
|
5036
5432
|
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
|
|
5433
|
+
await validateVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
|
|
5434
|
+
await validateCanonicalBackupReferences(loaded.directory, { ...backupEnv, OR3_IMAGE: imageAtDigest(manifest2.image, manifest2.imageDigest) }, backupPath);
|
|
5037
5435
|
const pending = {
|
|
5038
5436
|
id: id("restore"),
|
|
5039
5437
|
operation: "restore",
|
|
@@ -5063,7 +5461,10 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
5063
5461
|
phase: "snapshot-created"
|
|
5064
5462
|
});
|
|
5065
5463
|
await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
|
|
5066
|
-
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath, {
|
|
5464
|
+
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath, {
|
|
5465
|
+
recreateDataVolume,
|
|
5466
|
+
beforeStart: () => updatePending(loaded.directory, loaded.state, { phase: "starting-target" })
|
|
5467
|
+
});
|
|
5067
5468
|
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
5068
5469
|
const digest = await imageDigest(restoredEnv.OR3_IMAGE);
|
|
5069
5470
|
const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", digest);
|
|
@@ -5074,7 +5475,12 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
5074
5475
|
} catch (error) {
|
|
5075
5476
|
const original = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
|
|
5076
5477
|
const phase = loaded.state.incompleteOperation?.phase;
|
|
5077
|
-
|
|
5478
|
+
if (phase === "starting-target") {
|
|
5479
|
+
loaded.state.lastError = `Restore of ${manifest2.backupId} started the target but did not complete: ${original}. Writes may have been accepted, so the deployment was NOT restored automatically. Keep the operation record and run "npx @or3/cloud recover --dry-run", then "npx @or3/cloud recover --restore --yes" only after confirming loss of post-replacement writes.`;
|
|
5480
|
+
await writeState(loaded.directory, loaded.state);
|
|
5481
|
+
throw new Error(loaded.state.lastError, { cause: error });
|
|
5482
|
+
}
|
|
5483
|
+
const targetMayHaveMutated = phase === "target-mutating" || phase === "restoring-previous";
|
|
5078
5484
|
if (targetMayHaveMutated && (loaded.state.incompleteOperation?.previousBackupPath || loaded.state.incompleteOperation?.previousBackupId)) {
|
|
5079
5485
|
let recoveredMessage;
|
|
5080
5486
|
try {
|
|
@@ -5104,7 +5510,7 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
5104
5510
|
}
|
|
5105
5511
|
|
|
5106
5512
|
// src/commands/rollback.ts
|
|
5107
|
-
import { join as
|
|
5513
|
+
import { join as join25 } from "node:path";
|
|
5108
5514
|
async function rollbackCommand(directory, flags) {
|
|
5109
5515
|
if (!boolFlag(flags, "yes"))
|
|
5110
5516
|
throw new Error("Rollback restores the previous image and data snapshot. Re-run with --yes after confirming post-update data loss.");
|
|
@@ -5120,7 +5526,7 @@ async function rollbackCommand(directory, flags) {
|
|
|
5120
5526
|
if (manifest2.appVersion !== point.appVersion || manifest2.image !== point.image || manifest2.imageDigest !== point.imageDigest) {
|
|
5121
5527
|
throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
|
|
5122
5528
|
}
|
|
5123
|
-
const backupEnv = parseEnv(await readText(
|
|
5529
|
+
const backupEnv = parseEnv(await readText(join25(backupPath, "config.env")));
|
|
5124
5530
|
if (backupEnv.OR3_VERSION !== manifest2.appVersion || backupEnv.OR3_IMAGE !== manifest2.image) {
|
|
5125
5531
|
throw new Error(`Backup ${manifest2.backupId} configuration does not match its manifest.`);
|
|
5126
5532
|
}
|
|
@@ -5129,6 +5535,7 @@ async function rollbackCommand(directory, flags) {
|
|
|
5129
5535
|
await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
|
|
5130
5536
|
await assertSupportedHostArchitecture(point.image);
|
|
5131
5537
|
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest2, backupPath);
|
|
5538
|
+
await validateVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, backupPath, backupEnv.OR3_STORAGE_FS_ROOT);
|
|
5132
5539
|
const pending = {
|
|
5133
5540
|
id: id("rollback"),
|
|
5134
5541
|
operation: "rollback",
|
|
@@ -5158,7 +5565,11 @@ async function rollbackCommand(directory, flags) {
|
|
|
5158
5565
|
phase: "snapshot-created"
|
|
5159
5566
|
});
|
|
5160
5567
|
await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
|
|
5161
|
-
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath, {
|
|
5568
|
+
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath, {
|
|
5569
|
+
recreateDataVolume,
|
|
5570
|
+
previousSafetySnapshot: true,
|
|
5571
|
+
beforeStart: () => updatePending(loaded.directory, loaded.state, { phase: "starting-target" })
|
|
5572
|
+
});
|
|
5162
5573
|
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
5163
5574
|
const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
|
|
5164
5575
|
nextState.rollback = undefined;
|
|
@@ -5168,7 +5579,12 @@ async function rollbackCommand(directory, flags) {
|
|
|
5168
5579
|
} catch (error) {
|
|
5169
5580
|
const original = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
|
|
5170
5581
|
const phase = loaded.state.incompleteOperation?.phase;
|
|
5171
|
-
|
|
5582
|
+
if (phase === "starting-target") {
|
|
5583
|
+
loaded.state.lastError = `Rollback to ${point.appVersion} started the target but did not complete: ${original}. Writes may have been accepted, so the deployment was NOT restored automatically. Keep the operation record and run "npx @or3/cloud recover --dry-run", then "npx @or3/cloud recover --restore --yes" only after confirming loss of post-replacement writes.`;
|
|
5584
|
+
await writeState(loaded.directory, loaded.state);
|
|
5585
|
+
throw new Error(loaded.state.lastError, { cause: error });
|
|
5586
|
+
}
|
|
5587
|
+
const targetMayHaveMutated = phase === "target-mutating" || phase === "restoring-previous";
|
|
5172
5588
|
if (targetMayHaveMutated && (loaded.state.incompleteOperation?.previousBackupPath || loaded.state.incompleteOperation?.previousBackupId)) {
|
|
5173
5589
|
let recoveredMessage;
|
|
5174
5590
|
try {
|
|
@@ -5240,6 +5656,8 @@ async function statusCommand(directory, flags = {}) {
|
|
|
5240
5656
|
console.log(` last successful operation: ${state.lastSuccessfulOperation} (${state.updatedAt})`);
|
|
5241
5657
|
if (state.incompleteOperation)
|
|
5242
5658
|
console.log(` incomplete operation: ${state.incompleteOperation.operation} (${state.incompleteOperation.id})`);
|
|
5659
|
+
if (state.incompleteOperation?.backupProgress)
|
|
5660
|
+
console.log(` last backup milestone: ${redact(state.incompleteOperation.backupProgress.message)}`);
|
|
5243
5661
|
if (state.lastError)
|
|
5244
5662
|
console.log(` last error: ${state.lastError}`);
|
|
5245
5663
|
}
|
|
@@ -5319,7 +5737,7 @@ ${result.stderr}`);
|
|
|
5319
5737
|
|
|
5320
5738
|
// src/commands/update.ts
|
|
5321
5739
|
import { readFile as readFile3, statfs as statfs2 } from "node:fs/promises";
|
|
5322
|
-
import { join as
|
|
5740
|
+
import { join as join26, resolve as resolve14 } from "node:path";
|
|
5323
5741
|
async function assessUpdate(directory, targetVersion, options = {}) {
|
|
5324
5742
|
const checkDocker = options.checkDocker ?? false;
|
|
5325
5743
|
const observation = await observeDeployment(directory, { checkDocker, checkImage: false });
|
|
@@ -5384,7 +5802,7 @@ async function assessUpdate(directory, targetVersion, options = {}) {
|
|
|
5384
5802
|
for (const name of managedAssetNames(state?.mode ?? "local")) {
|
|
5385
5803
|
let desired;
|
|
5386
5804
|
try {
|
|
5387
|
-
desired = await readFile3(
|
|
5805
|
+
desired = await readFile3(join26(ASSET_ROOT, name));
|
|
5388
5806
|
} catch (error) {
|
|
5389
5807
|
findings.push({
|
|
5390
5808
|
code: "managed-asset-unreadable",
|
|
@@ -5395,7 +5813,7 @@ async function assessUpdate(directory, targetVersion, options = {}) {
|
|
|
5395
5813
|
continue;
|
|
5396
5814
|
}
|
|
5397
5815
|
try {
|
|
5398
|
-
const installedPath =
|
|
5816
|
+
const installedPath = join26(directory, name);
|
|
5399
5817
|
if (!await fileExists(installedPath)) {
|
|
5400
5818
|
changed.push(`${name} (add)`);
|
|
5401
5819
|
continue;
|
|
@@ -5537,7 +5955,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
5537
5955
|
const startedAtMs = Date.now();
|
|
5538
5956
|
try {
|
|
5539
5957
|
const operator = await prepareVerifiedDashboardOperator(loaded.directory, targetVersion);
|
|
5540
|
-
const backup = await createBackup(loaded.directory, state, env, { restartAfter: false, backupId });
|
|
5958
|
+
const backup = await createBackup(loaded.directory, state, env, { restartAfter: false, backupId, safetySnapshot: true });
|
|
5541
5959
|
const writeSchema = writeStateSchema();
|
|
5542
5960
|
if (writeSchema >= 2) {
|
|
5543
5961
|
const minimumSource = packagedMinimumSourceVersion();
|
|
@@ -5605,7 +6023,10 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
5605
6023
|
if (migrateLegacyVolume && !recreateDataVolume) {
|
|
5606
6024
|
await setManagedVolumeRootOwnership(loaded.directory, state, oldEnv, targetImage, previousRootOwnership);
|
|
5607
6025
|
}
|
|
5608
|
-
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, {
|
|
6026
|
+
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, {
|
|
6027
|
+
recreateDataVolume,
|
|
6028
|
+
previousSafetySnapshot: true
|
|
6029
|
+
});
|
|
5609
6030
|
} catch (restoreError) {
|
|
5610
6031
|
throw new Error(`Update to ${targetVersion} failed, and automatic backup restoration also failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Original update error: ${error instanceof Error ? error.message : String(error)}`);
|
|
5611
6032
|
}
|
|
@@ -5747,7 +6168,7 @@ async function runUpdateCommand(directory, flags, asJson) {
|
|
|
5747
6168
|
}
|
|
5748
6169
|
|
|
5749
6170
|
// src/commands/verify.ts
|
|
5750
|
-
import { join as
|
|
6171
|
+
import { join as join27, resolve as resolve15 } from "node:path";
|
|
5751
6172
|
async function verificationCredentials(directory, flags, env) {
|
|
5752
6173
|
const passwordFile = stringFlag(flags, "verification-password-file");
|
|
5753
6174
|
if (flags["verification-email"] !== undefined && !stringFlag(flags, "verification-email")) {
|
|
@@ -5759,7 +6180,7 @@ async function verificationCredentials(directory, flags, env) {
|
|
|
5759
6180
|
const configuredEmail = stringFlag(flags, "verification-email")?.trim() || env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
5760
6181
|
let initialCredentials = {};
|
|
5761
6182
|
if (!configuredEmail || !passwordFile && !env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD) {
|
|
5762
|
-
const initialCredentialsPath =
|
|
6183
|
+
const initialCredentialsPath = join27(directory, ".or3-initial-credentials");
|
|
5763
6184
|
if (await fileExists(initialCredentialsPath)) {
|
|
5764
6185
|
initialCredentials = parseEnv(await readOwnerOnlyText(initialCredentialsPath, "The initial credentials file"));
|
|
5765
6186
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@or3/cloud",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.85",
|
|
4
4
|
"description": "Install and operate the OR3 Cloud container distribution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"dashboardUpdateProtocol": 1,
|
|
20
20
|
"dashboardUpdateMinimumSourceVersion": "0.1.40",
|
|
21
21
|
"stateSchema": 1,
|
|
22
|
-
"imageDigest": "sha256:
|
|
23
|
-
"operatorImageDigest": "sha256:
|
|
24
|
-
"sourceRevision": "
|
|
22
|
+
"imageDigest": "sha256:306702ef2d4aad247d2308f09e0a11e9343008bc28207dd08202e40020bd4da7",
|
|
23
|
+
"operatorImageDigest": "sha256:fd70c4fb96272387200fb9ba9e9a888246b62cfd492030084212ab928821ede8",
|
|
24
|
+
"sourceRevision": "33c76a7551045fbfb3e2aa2e1412f117118ddb9e"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"dist",
|