@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.
Files changed (2) hide show
  1. package/dist/cli.mjs +561 -140
  2. 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.82";
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
- pipeline(child.stdout, createWriteStream(temporary, { flags: "wx", mode: 384 }))
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 rm2(temporary, { force: true }).catch(() => {
962
- return;
963
- });
964
- const detail = error instanceof Error ? error.message : String(error);
965
- throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
966
- ${redact(stderr, secrets)}` : ""}`);
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", "du -sb /data 2>/dev/null"])], directory);
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
- "du -sb /data 2>/dev/null"
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
- "tar tzf - >/dev/null"
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 join14, resolve as resolve9 } from "node:path";
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 join13, resolve as resolve8 } from "node:path";
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(join13(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
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(join13(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
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 manifestWritten = false;
3475
+ let created = false;
3321
3476
  let stopAttempted = false;
3322
- let backupFailure;
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, { recursive: true, mode: 448 });
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, join14(backupDir, "config.env"));
3330
- const managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
3331
- const manifest2 = {
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
- if (!manifestWritten) {
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
- const restartFailure = error instanceof Error ? error : new Error(String(error));
3375
- if (backupFailure) {
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 || !await fileExists(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 removeNamedBackupArtifact(directory, backupId);
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 join15, resolve as resolve10, sep } from "node:path";
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 = join15(await realpath(dirname5(dest)), basename5(dest));
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(join15(backupPath, file), join15(dest, file));
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(join15(backupPath, "managed-assets", name), join15(dest, "managed-assets", name));
3660
+ await copySecure(join16(backupPath, "managed-assets", name), join16(dest, "managed-assets", name));
3431
3661
  }
3432
3662
  }
3433
- const dataSha = await sha256File(join15(dest, "data.tgz"));
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(join15(dest, "config.env"));
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(join15(deploymentPaths(directory).exports, `${manifest2.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
3685
+ await writeSecure(join16(deploymentPaths(directory).exports, `${manifest2.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
3456
3686
  `);
3457
- let bytes = (await stat3(join15(dest, "data.tgz"))).size + (await stat3(join15(dest, "config.env"))).size + (await stat3(join15(dest, "manifest.json"))).size + (await stat3(join15(dest, "manifest.auth"))).size;
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(join15(dest, "managed-assets", name))).size;
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(join15(deploymentPaths(directory).exports, `${backupId}.json`)));
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: "Creating a stopped-volume backup",
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
- loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
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 join16 } from "node:path";
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 = join16(directory, ".or3-initial-credentials");
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 join17, resolve as resolve11 } from "node:path";
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, join17(resolved, ".or3-initial-credentials")]) {
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(join17(paths.operatorIpc, "operator.sock"));
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 join18, resolve as resolve12 } from "node:path";
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(join18(directory, ".or3-cloud", "state.json"))) {
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(join18(directory, ".or3-initial-credentials"), serializeInitialCredentials({
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 ${join18(directory, ".or3-initial-credentials")} (mode 0600). Move them to protected storage (a password manager), then remove that file.`);
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 join21 } from "node:path";
4455
+ import { join as join22 } from "node:path";
4195
4456
 
4196
4457
  // src/backup/restore.ts
4197
- import { join as join19 } from "node:path";
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(join19(backupPath, "data.tgz"));
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(join19(backupPath, "config.env")));
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 join20 } from "node:path";
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(join20(leasePath, "owner.json")));
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(join20(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
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(join20(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
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 === "backup" || pending.operation === "update" || pending.operation === "adopt") {
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 ((pending.operation === "init" || pending.operation === "adopt") && loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL && loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD && loaded.env.OR3_ADMIN_PASSWORD) {
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(join21(pending.sourceDirectory, ".env")));
4943
- const sourceFiles = ["-f", join21(pending.sourceDirectory, "compose.yaml")];
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", join21(pending.sourceDirectory, "compose.public.yaml"));
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 join22 } from "node:path";
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(` ${join22(directory, file)}`);
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(join22(directory, file), { recursive: true, force: true });
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 join23 } from "node:path";
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(join23(backupPath, "config.env")));
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, { recreateDataVolume });
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
- const targetMayHaveMutated = phase === "target-mutating" || phase === "restoring-previous" || phase === "starting-target";
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 join24 } from "node:path";
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(join24(backupPath, "config.env")));
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, { recreateDataVolume });
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
- const targetMayHaveMutated = phase === "target-mutating" || phase === "restoring-previous" || phase === "starting-target";
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 join25, resolve as resolve14 } from "node:path";
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(join25(ASSET_ROOT, name));
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 = join25(directory, name);
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, { recreateDataVolume });
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 join26, resolve as resolve15 } from "node:path";
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 = join26(directory, ".or3-initial-credentials");
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.82",
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:22a0dad8e014c7eba9e295643eb8e99c497b3dae6303ef462db44dbda986255f",
23
- "operatorImageDigest": "sha256:50e1c9ccb51f867f9daf02a00891d70faa36556b6b3026c38de3f1855d8b01bb",
24
- "sourceRevision": "3768e7f43b67b574c4eecb3aea196849b2c0f16e"
22
+ "imageDigest": "sha256:306702ef2d4aad247d2308f09e0a11e9343008bc28207dd08202e40020bd4da7",
23
+ "operatorImageDigest": "sha256:fd70c4fb96272387200fb9ba9e9a888246b62cfd492030084212ab928821ede8",
24
+ "sourceRevision": "33c76a7551045fbfb3e2aa2e1412f117118ddb9e"
25
25
  },
26
26
  "files": [
27
27
  "dist",