@or3/cloud 0.1.36 → 0.1.38
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 +8 -0
- package/assets/compose.yaml +4 -0
- package/dist/cli.mjs +128 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ npx @or3/cloud init --public --domain cloud.example.com
|
|
|
32
32
|
|
|
33
33
|
OR3 Cloud uses Caddy to obtain and renew HTTPS certificates. It does not
|
|
34
34
|
change your DNS, firewall, Cloudflare, or Tailscale settings.
|
|
35
|
+
The managed public profile forwards its proxy-trust setting into Nuxt runtime
|
|
36
|
+
configuration so HTTPS API requests are not redirected back to themselves.
|
|
35
37
|
|
|
36
38
|
## Day-to-day commands
|
|
37
39
|
|
|
@@ -56,6 +58,12 @@ When an adopted legacy deployment used a different container UID, `update`
|
|
|
56
58
|
rebuilds the data from its checksummed pre-update backup as the hardened
|
|
57
59
|
runtime user. It does not recursively rewrite file ownership in place, and a
|
|
58
60
|
failed migration restores the recorded legacy root owner and snapshot.
|
|
61
|
+
Updates also replace the generated Compose/Caddy files from the target CLI.
|
|
62
|
+
Those files are checksummed into the pre-update backup and restored on a failed
|
|
63
|
+
update, rollback, restore, or interrupted-operation recovery.
|
|
64
|
+
When selecting an exact version, run the matching CLI package, for example
|
|
65
|
+
`npx --yes @or3/cloud@0.1.38 update --to 0.1.38`; the CLI refuses mismatched
|
|
66
|
+
package and image versions so their generated assets cannot drift.
|
|
59
67
|
For a destructive purge, export a fresh backup to a new directory on another
|
|
60
68
|
filesystem first; `remove --purge-data --yes` verifies that export before it
|
|
61
69
|
will delete anything.
|
package/assets/compose.yaml
CHANGED
|
@@ -8,6 +8,10 @@ services:
|
|
|
8
8
|
environment:
|
|
9
9
|
HOST: 0.0.0.0
|
|
10
10
|
PORT: 3000
|
|
11
|
+
# Nuxt only applies nested runtime-config overrides from NUXT_* names.
|
|
12
|
+
# Bridge the managed OR3 setting so Caddy's sanitized forwarding headers
|
|
13
|
+
# are trusted and HTTPS enforcement cannot redirect back to itself.
|
|
14
|
+
NUXT_SECURITY_PROXY_TRUST_PROXY: "${OR3_TRUST_PROXY:-false}"
|
|
11
15
|
OR3_BASIC_AUTH_DB_PATH: /data/auth.sqlite
|
|
12
16
|
OR3_SQLITE_DB_PATH: /data/sync.sqlite
|
|
13
17
|
OR3_STORAGE_FS_ROOT: /data/storage
|
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.38";
|
|
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;
|
|
@@ -794,15 +794,107 @@ async function readPassword(flags) {
|
|
|
794
794
|
validatePassword(password);
|
|
795
795
|
return password;
|
|
796
796
|
}
|
|
797
|
+
function managedAssetNames(mode) {
|
|
798
|
+
return ["compose.yaml", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
|
|
799
|
+
}
|
|
800
|
+
async function installManagedAssets(directory, assets) {
|
|
801
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
802
|
+
const token = randomBytes(4).toString("hex");
|
|
803
|
+
const staged = [];
|
|
804
|
+
try {
|
|
805
|
+
for (const [name, contents] of assets) {
|
|
806
|
+
const destination = join(directory, name);
|
|
807
|
+
const replacement = `${destination}.next-${token}`;
|
|
808
|
+
const entry = {
|
|
809
|
+
destination,
|
|
810
|
+
replacement
|
|
811
|
+
};
|
|
812
|
+
staged.push(entry);
|
|
813
|
+
await writeFile(replacement, contents, { mode: 420 });
|
|
814
|
+
await chmod(replacement, 420);
|
|
815
|
+
if (await fileExists(destination)) {
|
|
816
|
+
entry.rollback = `${destination}.previous-${token}`;
|
|
817
|
+
await copyFile(destination, entry.rollback);
|
|
818
|
+
await chmod(entry.rollback, 420);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const applied = [];
|
|
822
|
+
try {
|
|
823
|
+
for (const entry of staged) {
|
|
824
|
+
await rename(entry.replacement, entry.destination);
|
|
825
|
+
applied.push(entry);
|
|
826
|
+
await chmod(entry.destination, 420);
|
|
827
|
+
}
|
|
828
|
+
} catch (error) {
|
|
829
|
+
for (const entry of applied.reverse()) {
|
|
830
|
+
if (entry.rollback)
|
|
831
|
+
await rename(entry.rollback, entry.destination);
|
|
832
|
+
else
|
|
833
|
+
await rm(entry.destination, { force: true });
|
|
834
|
+
}
|
|
835
|
+
throw error;
|
|
836
|
+
}
|
|
837
|
+
} finally {
|
|
838
|
+
for (const entry of staged) {
|
|
839
|
+
await rm(entry.replacement, { force: true }).catch(() => {
|
|
840
|
+
return;
|
|
841
|
+
});
|
|
842
|
+
if (entry.rollback)
|
|
843
|
+
await rm(entry.rollback, { force: true }).catch(() => {
|
|
844
|
+
return;
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
797
849
|
async function copyAssets(directory, mode) {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
850
|
+
const assets = new Map;
|
|
851
|
+
for (const name of managedAssetNames(mode)) {
|
|
852
|
+
assets.set(name, await readFile(join(ASSET_ROOT, name)));
|
|
853
|
+
}
|
|
854
|
+
await installManagedAssets(directory, assets);
|
|
855
|
+
}
|
|
856
|
+
async function snapshotManagedAssets(directory, mode, backupDir) {
|
|
857
|
+
const assetDir = join(backupDir, "managed-assets");
|
|
858
|
+
await mkdir(assetDir, { recursive: true, mode: 448 });
|
|
859
|
+
await chmod(assetDir, 448);
|
|
860
|
+
const checksums = {};
|
|
861
|
+
for (const name of managedAssetNames(mode)) {
|
|
862
|
+
const destination = join(assetDir, name);
|
|
863
|
+
await copySecure(join(directory, name), destination);
|
|
864
|
+
checksums[name] = await sha256File(destination);
|
|
865
|
+
}
|
|
866
|
+
return checksums;
|
|
867
|
+
}
|
|
868
|
+
async function verifiedManagedAssetContents(backupPath, manifest) {
|
|
869
|
+
const checksums = manifest.managedAssetSha256;
|
|
870
|
+
if (!checksums)
|
|
871
|
+
return;
|
|
872
|
+
const expected = managedAssetNames(manifest.mode).sort();
|
|
873
|
+
const actual = Object.keys(checksums).sort();
|
|
874
|
+
if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
|
|
875
|
+
throw new Error(`Backup ${manifest.backupId} has an invalid managed asset inventory.`);
|
|
876
|
+
}
|
|
877
|
+
const assets = new Map;
|
|
878
|
+
for (const name of expected) {
|
|
879
|
+
const expectedSha = checksums[name];
|
|
880
|
+
if (!expectedSha || !/^[0-9a-f]{64}$/i.test(expectedSha)) {
|
|
881
|
+
throw new Error(`Backup ${manifest.backupId} has an invalid checksum for managed asset ${name}.`);
|
|
882
|
+
}
|
|
883
|
+
const source = join(backupPath, "managed-assets", name);
|
|
884
|
+
const actualSha = await sha256File(source);
|
|
885
|
+
if (actualSha !== expectedSha) {
|
|
886
|
+
throw new Error(`Backup managed asset checksum mismatch for ${name}. Expected ${expectedSha}, got ${actualSha}.`);
|
|
887
|
+
}
|
|
888
|
+
assets.set(name, await readFile(source));
|
|
805
889
|
}
|
|
890
|
+
return assets;
|
|
891
|
+
}
|
|
892
|
+
async function restoreManagedAssets(directory, backupPath, manifest) {
|
|
893
|
+
const assets = await verifiedManagedAssetContents(backupPath, manifest);
|
|
894
|
+
if (!assets)
|
|
895
|
+
return false;
|
|
896
|
+
await installManagedAssets(directory, assets);
|
|
897
|
+
return true;
|
|
806
898
|
}
|
|
807
899
|
async function checkPublicPrerequisites(domain) {
|
|
808
900
|
validateDomain(domain);
|
|
@@ -1161,6 +1253,7 @@ async function createBackup(directory, state, env) {
|
|
|
1161
1253
|
await stopProject(directory, state.mode);
|
|
1162
1254
|
await volumeArchive(directory, state.mode, env, backupDir);
|
|
1163
1255
|
await copySecure(deploymentPaths(directory).env, join(backupDir, "config.env"));
|
|
1256
|
+
const managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
|
|
1164
1257
|
const manifest = {
|
|
1165
1258
|
schemaVersion: 1,
|
|
1166
1259
|
backupId,
|
|
@@ -1171,6 +1264,7 @@ async function createBackup(directory, state, env) {
|
|
|
1171
1264
|
dataSha256: await sha256File(join(backupDir, "data.tgz")),
|
|
1172
1265
|
dataBytes: volumeSize,
|
|
1173
1266
|
configSha256: await sha256File(join(backupDir, "config.env")),
|
|
1267
|
+
managedAssetSha256,
|
|
1174
1268
|
mode: state.mode,
|
|
1175
1269
|
domain: state.domain,
|
|
1176
1270
|
composeProject: state.composeProject,
|
|
@@ -1219,6 +1313,7 @@ async function readManifest(backupPath) {
|
|
|
1219
1313
|
if (configActual !== manifest.configSha256)
|
|
1220
1314
|
throw new Error(`Backup configuration checksum mismatch for ${manifest.backupId}.`);
|
|
1221
1315
|
}
|
|
1316
|
+
await verifiedManagedAssetContents(backupPath, manifest);
|
|
1222
1317
|
return manifest;
|
|
1223
1318
|
}
|
|
1224
1319
|
async function dataVolumeFreeBytes(directory, mode, env) {
|
|
@@ -1259,6 +1354,7 @@ async function restoreBackupData(directory, state, env, backupPath) {
|
|
|
1259
1354
|
stopAttempted = true;
|
|
1260
1355
|
await stopProject(directory, state.mode);
|
|
1261
1356
|
await copySecure(join(backupPath, "config.env"), deploymentPaths(directory).env);
|
|
1357
|
+
await restoreManagedAssets(directory, backupPath, manifest);
|
|
1262
1358
|
await restoreVolumeArchive(directory, state.mode, backupEnv, backupPath);
|
|
1263
1359
|
const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
1264
1360
|
await startProject(directory, state.mode, restoredEnv);
|
|
@@ -1490,9 +1586,13 @@ async function recoverCommand(directory) {
|
|
|
1490
1586
|
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1491
1587
|
}
|
|
1492
1588
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1589
|
+
if (pending.targetVersion !== PACKAGE_VERSION) {
|
|
1590
|
+
throw new Error(`This interrupted update targets OR3 ${pending.targetVersion}. Run \`npx --yes @or3/cloud@${pending.targetVersion} recover\` so the image and generated assets match.`);
|
|
1591
|
+
}
|
|
1493
1592
|
try {
|
|
1494
1593
|
if (pending.targetImageDigest)
|
|
1495
1594
|
await pullAndRequireImage(pending.targetImage, pending.targetImageDigest, `OR3 ${pending.targetVersion}`);
|
|
1595
|
+
await copyAssets(loaded.directory, loaded.state.mode);
|
|
1496
1596
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1497
1597
|
} catch (replacementError) {
|
|
1498
1598
|
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
|
|
@@ -1627,6 +1727,11 @@ async function backupExportCommand(directory, backupId, destination) {
|
|
|
1627
1727
|
for (const file of ["data.tgz", "config.env", "manifest.json"]) {
|
|
1628
1728
|
await copySecure(join(backupPath, file), join(dest, file));
|
|
1629
1729
|
}
|
|
1730
|
+
if (manifest.managedAssetSha256) {
|
|
1731
|
+
for (const name of managedAssetNames(manifest.mode)) {
|
|
1732
|
+
await copySecure(join(backupPath, "managed-assets", name), join(dest, "managed-assets", name));
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1630
1735
|
const dataSha = await sha256File(join(dest, "data.tgz"));
|
|
1631
1736
|
if (dataSha !== manifest.dataSha256) {
|
|
1632
1737
|
throw new Error(`Exported data.tgz checksum mismatch for ${manifest.backupId}. Expected ${manifest.dataSha256}, got ${dataSha}.`);
|
|
@@ -1637,6 +1742,7 @@ async function backupExportCommand(directory, backupId, destination) {
|
|
|
1637
1742
|
throw new Error(`Exported config.env checksum mismatch for ${manifest.backupId}.`);
|
|
1638
1743
|
}
|
|
1639
1744
|
}
|
|
1745
|
+
await readManifest(dest);
|
|
1640
1746
|
const destinationDevice = (await stat(dest)).dev;
|
|
1641
1747
|
await mkdir(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
|
|
1642
1748
|
const receipt = {
|
|
@@ -1650,7 +1756,12 @@ async function backupExportCommand(directory, backupId, destination) {
|
|
|
1650
1756
|
};
|
|
1651
1757
|
await writeSecure(join(deploymentPaths(directory).exports, `${manifest.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
|
|
1652
1758
|
`);
|
|
1653
|
-
|
|
1759
|
+
let bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size;
|
|
1760
|
+
if (manifest.managedAssetSha256) {
|
|
1761
|
+
for (const name of managedAssetNames(manifest.mode)) {
|
|
1762
|
+
bytes += (await stat(join(dest, "managed-assets", name))).size;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1654
1765
|
console.log(`Exported backup ${manifest.backupId} (${bytes} bytes) to ${dest}`);
|
|
1655
1766
|
console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
|
|
1656
1767
|
if ((await stat(backupPath)).dev === destinationDevice) {
|
|
@@ -1697,6 +1808,9 @@ async function updateCommand(directory, flags) {
|
|
|
1697
1808
|
const targetVersion = stringFlag(flags, "to")?.trim() ?? PACKAGE_VERSION;
|
|
1698
1809
|
if (!isVersion(targetVersion))
|
|
1699
1810
|
throw new Error(`--to must be a complete semantic version such as ${PACKAGE_VERSION}.`);
|
|
1811
|
+
if (targetVersion !== PACKAGE_VERSION) {
|
|
1812
|
+
throw new Error(`This CLI contains deployment assets for OR3 ${PACKAGE_VERSION}. Run \`npx --yes @or3/cloud@${targetVersion} update --to ${targetVersion}\` so the image and generated assets match.`);
|
|
1813
|
+
}
|
|
1700
1814
|
if (targetVersion === state.appVersion)
|
|
1701
1815
|
throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
|
|
1702
1816
|
const targetImage = imageFor(targetVersion);
|
|
@@ -1728,6 +1842,7 @@ async function updateCommand(directory, flags) {
|
|
|
1728
1842
|
});
|
|
1729
1843
|
}
|
|
1730
1844
|
await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
|
|
1845
|
+
await copyAssets(loaded.directory, state.mode);
|
|
1731
1846
|
if (migrateLegacyVolume) {
|
|
1732
1847
|
await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
|
|
1733
1848
|
}
|
|
@@ -2738,15 +2853,19 @@ export {
|
|
|
2738
2853
|
validatePassword,
|
|
2739
2854
|
supportedImageArchitectures,
|
|
2740
2855
|
stateFromEnv,
|
|
2856
|
+
snapshotManagedAssets,
|
|
2741
2857
|
serializeInitialCredentials,
|
|
2742
2858
|
serializeEnv,
|
|
2743
2859
|
selectPruneTargets,
|
|
2860
|
+
restoreManagedAssets,
|
|
2744
2861
|
requiredArchiveSpace,
|
|
2745
2862
|
redact,
|
|
2746
2863
|
purgeVolumesFromState,
|
|
2747
2864
|
parseFlags,
|
|
2748
2865
|
parseEnv,
|
|
2866
|
+
managedAssetNames,
|
|
2749
2867
|
isVersion,
|
|
2868
|
+
copyAssets,
|
|
2750
2869
|
checkResolvedLoopbackBinding,
|
|
2751
2870
|
buildEnv,
|
|
2752
2871
|
buildCredentialsResetScript,
|