@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 +4 -0
- package/assets/compose.yaml +7 -4
- package/dist/cli.mjs +103 -13
- package/package.json +1 -1
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.
|
package/assets/compose.yaml
CHANGED
|
@@ -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
|
-
-
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
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", [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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);
|