@or3/cloud 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,10 @@ npx @or3/cloud restart
52
52
 
53
53
  Do not run `docker compose down --volumes` on a normal deployment: it deletes
54
54
  the application data. Use `backup` before an update or any destructive action.
55
+ When an adopted legacy deployment used a different container UID, `update`
56
+ rebuilds the data from its checksummed pre-update backup as the hardened
57
+ runtime user. It does not recursively rewrite file ownership in place, and a
58
+ failed migration restores the recorded legacy root owner and snapshot.
55
59
  For a destructive purge, export a fresh backup to a new directory on another
56
60
  filesystem first; `remove --purge-data --yes` verifies that export before it
57
61
  will delete anything.
@@ -51,10 +51,13 @@ services:
51
51
  # exits only. Operators should run `or3 doctor`/`or3 restart` for a
52
52
  # persistent degraded state.
53
53
  test:
54
- - CMD
55
- - /nodejs/bin/node
56
- - -e
57
- - "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async r=>{const b=await r.json().catch(()=>({}));if(!r.ok||b.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))"
54
+ - CMD-SHELL
55
+ - >-
56
+ if [ -x /nodejs/bin/node ]; then
57
+ exec /nodejs/bin/node -e "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);for(const p of [process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH].filter(Boolean)){const fd=fs.openSync(p,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async r=>{const b=await r.json().catch(()=>({}));if(!r.ok||b.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
58
+ elif [ -x /usr/local/bin/node ]; then
59
+ exec /usr/local/bin/node -e "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);for(const p of [process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH].filter(Boolean)){const fd=fs.openSync(p,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async r=>{const b=await r.json().catch(()=>({}));if(!r.ok||b.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
60
+ else exit 127; fi
58
61
  interval: 10s
59
62
  timeout: 5s
60
63
  retries: 12
package/dist/cli.mjs CHANGED
@@ -27,7 +27,7 @@ import { dirname, basename, isAbsolute, join, resolve, sep } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { createGunzip } from "node:zlib";
29
29
  var execFile = promisify(execFileCallback);
30
- var PACKAGE_VERSION = "0.1.34";
30
+ var PACKAGE_VERSION = "0.1.36";
31
31
  var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
32
32
  var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
33
33
  var STATE_SCHEMA_VERSION = 1;
@@ -489,14 +489,27 @@ async function assertSafeComposeBinding(directory, mode, env) {
489
489
  throw new Error("Resolved Compose configuration must publish OR3 port 3000 only on 127.0.0.1. Refusing to expose the application directly.");
490
490
  }
491
491
  }
492
- var HEALTH_SCRIPT = "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
492
+ var HEALTH_SCRIPT = "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);for(const path of [process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH].filter(Boolean)){const fd=fs.openSync(path,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
493
493
  var MAINTENANCE_SCRIPT = "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));const m=body?.providers?.sync?.details?.maintenance;if(m)console.log(JSON.stringify(m))}).catch(()=>{})";
494
494
  var CONTAINER_NODE = "/nodejs/bin/node";
495
+ var LEGACY_CONTAINER_NODE = "/usr/local/bin/node";
496
+ var CONTAINER_NODE_SHELL = `if [ -x ${CONTAINER_NODE} ]; then exec ${CONTAINER_NODE} -e "$1"; elif [ -x ${LEGACY_CONTAINER_NODE} ]; then exec ${LEGACY_CONTAINER_NODE} -e "$1"; else exit 127; fi`;
497
+ var MANAGED_RUNTIME_UID = 65532;
498
+ var MANAGED_RUNTIME_GID = 65532;
499
+ function containerNodeCommand(script) {
500
+ return ["sh", "-c", CONTAINER_NODE_SHELL, "or3-node", script];
501
+ }
495
502
  async function waitForDeepHealthWithArgs(composeCommand, directory, secrets = []) {
496
503
  const deadline = Date.now() + DEEP_HEALTH_TIMEOUT_MS;
497
504
  let lastError = "health check did not complete";
498
505
  while (Date.now() < deadline) {
499
- const result = await run("docker", [...composeCommand, "exec", "-T", "or3", CONTAINER_NODE, "-e", HEALTH_SCRIPT], directory);
506
+ const result = await run("docker", [
507
+ ...composeCommand,
508
+ "exec",
509
+ "-T",
510
+ "or3",
511
+ ...containerNodeCommand(HEALTH_SCRIPT)
512
+ ], directory);
500
513
  if (result.ok)
501
514
  return;
502
515
  lastError = redact(result.stderr, secrets);
@@ -508,6 +521,65 @@ async function ensureDocker() {
508
521
  await requireCommand("docker", ["info"], "Docker Engine");
509
522
  await requireCommand("docker", ["compose", "version"], "Docker Compose v2");
510
523
  }
524
+ async function managedVolumeRootOwnership(image, volume) {
525
+ const result = await run("docker", [
526
+ "run",
527
+ "--rm",
528
+ "--network",
529
+ "none",
530
+ "--read-only",
531
+ "--user",
532
+ "0:0",
533
+ "--cap-drop",
534
+ "ALL",
535
+ "-v",
536
+ `${volume}:/data:ro`,
537
+ "--entrypoint",
538
+ "sh",
539
+ image,
540
+ "-c",
541
+ "stat -c '%u:%g' /data"
542
+ ]);
543
+ if (!result.ok)
544
+ throw new Error(`Could not inspect managed volume ${volume}. ${result.stderr.trim()}`);
545
+ const match = result.stdout.trim().match(/^(\d+):(\d+)$/);
546
+ if (!match)
547
+ throw new Error(`Managed volume ${volume} returned an invalid root ownership value.`);
548
+ return { uid: Number(match[1]), gid: Number(match[2]) };
549
+ }
550
+ async function setManagedVolumeRootOwnership(image, volume, ownership) {
551
+ if (!Number.isSafeInteger(ownership.uid) || ownership.uid < 0 || !Number.isSafeInteger(ownership.gid) || ownership.gid < 0) {
552
+ throw new Error("Refusing an invalid managed volume root UID/GID.");
553
+ }
554
+ const result = await run("docker", [
555
+ "run",
556
+ "--rm",
557
+ "--network",
558
+ "none",
559
+ "--read-only",
560
+ "--user",
561
+ "0:0",
562
+ "--security-opt",
563
+ "no-new-privileges:true",
564
+ "--cap-drop",
565
+ "ALL",
566
+ "--cap-add",
567
+ "CHOWN",
568
+ "-v",
569
+ `${volume}:/data`,
570
+ "--entrypoint",
571
+ "sh",
572
+ image,
573
+ "-c",
574
+ `chown ${ownership.uid}:${ownership.gid} /data`
575
+ ]);
576
+ if (!result.ok)
577
+ throw new Error(`Could not set managed volume ${volume} root ownership. ${result.stderr.trim()}`);
578
+ const actual = await managedVolumeRootOwnership(image, volume);
579
+ if (actual.uid !== ownership.uid || actual.gid !== ownership.gid) {
580
+ throw new Error(`Managed volume ${volume} root ownership verification failed.`);
581
+ }
582
+ }
511
583
  async function portAvailable(port) {
512
584
  return new Promise((resolvePromise) => {
513
585
  const server = createServer();
@@ -1645,12 +1717,34 @@ async function updateCommand(directory, flags) {
1645
1717
  const backup = await createBackup(loaded.directory, state, env);
1646
1718
  await updatePending(loaded.directory, state, { backupId: backup.backupId });
1647
1719
  const nextEnv = { ...env, OR3_VERSION: targetVersion, OR3_IMAGE: targetImage };
1648
- await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
1720
+ const previousRootOwnership = await managedVolumeRootOwnership(targetImage, state.volumeName);
1721
+ const migrateLegacyVolume = previousRootOwnership.uid !== MANAGED_RUNTIME_UID || previousRootOwnership.gid !== MANAGED_RUNTIME_GID;
1649
1722
  try {
1723
+ await stopProject(loaded.directory, state.mode);
1724
+ if (migrateLegacyVolume) {
1725
+ await setManagedVolumeRootOwnership(targetImage, state.volumeName, {
1726
+ uid: MANAGED_RUNTIME_UID,
1727
+ gid: MANAGED_RUNTIME_GID
1728
+ });
1729
+ }
1730
+ await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
1731
+ if (migrateLegacyVolume) {
1732
+ await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
1733
+ }
1650
1734
  await startProject(loaded.directory, state.mode, nextEnv);
1651
1735
  } catch (error) {
1652
1736
  await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(oldEnv));
1653
- await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
1737
+ try {
1738
+ await stopProject(loaded.directory, state.mode).catch(() => {
1739
+ return;
1740
+ });
1741
+ if (migrateLegacyVolume) {
1742
+ await setManagedVolumeRootOwnership(targetImage, state.volumeName, previousRootOwnership);
1743
+ }
1744
+ await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
1745
+ } catch (restoreError) {
1746
+ 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)}`);
1747
+ }
1654
1748
  const oldDigest = await imageDigest(state.image);
1655
1749
  state.imageDigest = oldDigest;
1656
1750
  state.lastError = `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`;
@@ -2304,9 +2398,7 @@ async function runCredentialsResetScript(directory, mode, values) {
2304
2398
  "-e",
2305
2399
  `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
2306
2400
  "or3",
2307
- CONTAINER_NODE,
2308
- "-e",
2309
- script
2401
+ ...containerNodeCommand(script)
2310
2402
  ])
2311
2403
  ], directory);
2312
2404
  if (!result.ok)
@@ -2327,9 +2419,7 @@ async function verifyCredentialsInsideContainer(directory, mode, values) {
2327
2419
  "-e",
2328
2420
  `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
2329
2421
  "or3",
2330
- CONTAINER_NODE,
2331
- "-e",
2332
- CREDENTIALS_VERIFY_SCRIPT
2422
+ ...containerNodeCommand(CREDENTIALS_VERIFY_SCRIPT)
2333
2423
  ])
2334
2424
  ], directory);
2335
2425
  if (!result.ok)
@@ -2439,7 +2529,7 @@ async function statusCommand(directory) {
2439
2529
  await printMaintenanceSummary(directory, state.mode);
2440
2530
  }
2441
2531
  async function printMaintenanceSummary(directory, mode) {
2442
- const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", CONTAINER_NODE, "-e", MAINTENANCE_SCRIPT])], directory);
2532
+ const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", ...containerNodeCommand(MAINTENANCE_SCRIPT)])], directory);
2443
2533
  if (!result.ok || !result.stdout.trim())
2444
2534
  return;
2445
2535
  try {
@@ -2584,7 +2674,7 @@ ${result.stderr}`);
2584
2674
  console.log(`Purged: data volumes, backups, .env, managed state, and compose files were deleted. The verified export at ${exportReceipt.destination} is the remaining copy.`);
2585
2675
  }
2586
2676
  async function probeDeepHealth(directory, mode) {
2587
- const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", CONTAINER_NODE, "-e", HEALTH_SCRIPT])], directory);
2677
+ const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", ...containerNodeCommand(HEALTH_SCRIPT)])], directory);
2588
2678
  if (result.ok)
2589
2679
  return "ok";
2590
2680
  const ps = await run("docker", [...composeArgs(directory, mode, ["ps", "-q", "or3"])], directory);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@or3/cloud",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Install and operate the OR3 Cloud container distribution",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",