@or3/cloud 0.1.15 → 0.1.32
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 +80 -0
- package/assets/Caddyfile +11 -0
- package/assets/compose.public.yaml +26 -1
- package/assets/compose.yaml +30 -2
- package/dist/cli.mjs +1032 -77
- package/package.json +3 -2
package/dist/cli.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
-
import { execFile as execFileCallback } from "node:child_process";
|
|
5
|
+
import { execFile as execFileCallback, spawn } from "node:child_process";
|
|
6
6
|
import { createServer } from "node:net";
|
|
7
7
|
import { lookup } from "node:dns/promises";
|
|
8
8
|
import readline from "node:readline/promises";
|
|
9
9
|
import { stdin as input, stdout as output } from "node:process";
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
10
|
+
import { createReadStream, createWriteStream, existsSync } from "node:fs";
|
|
11
11
|
import {
|
|
12
12
|
chmod,
|
|
13
13
|
copyFile,
|
|
@@ -17,22 +17,31 @@ import {
|
|
|
17
17
|
rename,
|
|
18
18
|
rm,
|
|
19
19
|
stat,
|
|
20
|
+
statfs,
|
|
20
21
|
writeFile
|
|
21
22
|
} from "node:fs/promises";
|
|
23
|
+
import { Writable } from "node:stream";
|
|
24
|
+
import { pipeline } from "node:stream/promises";
|
|
22
25
|
import { promisify } from "node:util";
|
|
23
|
-
import { dirname, basename, isAbsolute, join, resolve } from "node:path";
|
|
26
|
+
import { dirname, basename, isAbsolute, join, resolve, sep } from "node:path";
|
|
24
27
|
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { createGunzip } from "node:zlib";
|
|
25
29
|
var execFile = promisify(execFileCallback);
|
|
26
|
-
var PACKAGE_VERSION = "0.1.
|
|
30
|
+
var PACKAGE_VERSION = "0.1.32";
|
|
27
31
|
var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
|
|
28
32
|
var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
|
|
29
33
|
var STATE_SCHEMA_VERSION = 1;
|
|
30
34
|
var DEFAULT_PORT = 3000;
|
|
31
35
|
var DEEP_HEALTH_TIMEOUT_MS = 180000;
|
|
36
|
+
var BACKUP_RETENTION_KEEP = 5;
|
|
37
|
+
var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
var FREE_SPACE_HEADROOM_BYTES = 64 * 1024 * 1024;
|
|
39
|
+
var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
|
|
32
40
|
var SECRET_KEYS = [
|
|
33
41
|
"OR3_BASIC_AUTH_JWT_SECRET",
|
|
34
42
|
"OR3_BASIC_AUTH_REFRESH_SECRET",
|
|
35
43
|
"OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
|
|
44
|
+
"OR3_AUTH_INVITE_TOKEN_SECRET",
|
|
36
45
|
"OR3_STORAGE_FS_TOKEN_SECRET",
|
|
37
46
|
"OR3_ADMIN_JWT_SECRET",
|
|
38
47
|
"OR3_ADMIN_PASSWORD"
|
|
@@ -49,6 +58,8 @@ var ALLOWED_ENV_KEYS = new Set([
|
|
|
49
58
|
"SSR_AUTH_ENABLED",
|
|
50
59
|
"AUTH_PROVIDER",
|
|
51
60
|
"OR3_AUTH_PROVIDER",
|
|
61
|
+
"OR3_AUTH_REGISTRATION_MODE",
|
|
62
|
+
"OR3_AUTH_AUTO_PROVISION",
|
|
52
63
|
"OR3_GUEST_ACCESS_ENABLED",
|
|
53
64
|
"OR3_BASIC_AUTH_JWT_SECRET",
|
|
54
65
|
"OR3_BASIC_AUTH_REFRESH_SECRET",
|
|
@@ -93,6 +104,17 @@ function randomSecret() {
|
|
|
93
104
|
function randomPassword() {
|
|
94
105
|
return `A${randomBytes(20).toString("base64url")}a1`;
|
|
95
106
|
}
|
|
107
|
+
function serializeInitialCredentials(input2) {
|
|
108
|
+
return [
|
|
109
|
+
"# OR3 first-run credentials — move to a password manager, then delete this file.",
|
|
110
|
+
`OR3_BASIC_AUTH_BOOTSTRAP_EMAIL=${serializeCredentialValue(input2.bootstrapEmail)}`,
|
|
111
|
+
`OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD=${serializeCredentialValue(input2.bootstrapPassword)}`,
|
|
112
|
+
`OR3_ADMIN_USERNAME=${serializeCredentialValue(input2.adminUsername)}`,
|
|
113
|
+
`OR3_ADMIN_PASSWORD=${serializeCredentialValue(input2.adminPassword)}`,
|
|
114
|
+
""
|
|
115
|
+
].join(`
|
|
116
|
+
`);
|
|
117
|
+
}
|
|
96
118
|
function validatePassword(password) {
|
|
97
119
|
if (password.length < 12 || !/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
|
|
98
120
|
throw new Error("The administrator password must be at least 12 characters and contain uppercase, lowercase, and numeric characters.");
|
|
@@ -135,12 +157,19 @@ function parseFlags(argv) {
|
|
|
135
157
|
var COMMAND_FLAGS = {
|
|
136
158
|
init: ["local", "public", "domain", "admin-email", "admin-password", "admin-password-file", "port"],
|
|
137
159
|
update: ["to"],
|
|
138
|
-
backup: [],
|
|
160
|
+
backup: ["keep", "force", "yes"],
|
|
139
161
|
restore: ["yes"],
|
|
140
162
|
rollback: ["yes"],
|
|
141
163
|
doctor: [],
|
|
142
164
|
recover: [],
|
|
143
|
-
adopt: ["from"]
|
|
165
|
+
adopt: ["from"],
|
|
166
|
+
credentials: ["yes", "owner-password", "admin-password"],
|
|
167
|
+
status: [],
|
|
168
|
+
logs: ["tail"],
|
|
169
|
+
start: [],
|
|
170
|
+
stop: [],
|
|
171
|
+
restart: [],
|
|
172
|
+
remove: ["purge-data", "yes"]
|
|
144
173
|
};
|
|
145
174
|
function assertCommandFlags(command, flags) {
|
|
146
175
|
const allowed = new Set(["help", ...COMMAND_FLAGS[command] ?? []]);
|
|
@@ -214,14 +243,22 @@ async function run(command, args, cwd) {
|
|
|
214
243
|
encoding: "utf8",
|
|
215
244
|
env: process.env
|
|
216
245
|
});
|
|
217
|
-
return {
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
stdout: result.stdout ?? "",
|
|
249
|
+
stderr: result.stderr ?? "",
|
|
250
|
+
command: printable,
|
|
251
|
+
exitCode: 0
|
|
252
|
+
};
|
|
218
253
|
} catch (error) {
|
|
219
254
|
const failure = error;
|
|
255
|
+
const stdout = failure.stdout ?? "";
|
|
220
256
|
return {
|
|
221
257
|
ok: false,
|
|
258
|
+
stdout,
|
|
222
259
|
command: printable,
|
|
223
260
|
exitCode: typeof failure.code === "number" ? failure.code : null,
|
|
224
|
-
stderr: redact(failure.stderr
|
|
261
|
+
stderr: redact(failure.stderr || stdout || String(error))
|
|
225
262
|
};
|
|
226
263
|
}
|
|
227
264
|
}
|
|
@@ -256,6 +293,24 @@ async function fileExists(path) {
|
|
|
256
293
|
async function readText(path) {
|
|
257
294
|
return readFile(path, "utf8");
|
|
258
295
|
}
|
|
296
|
+
function requiredArchiveSpace(requiredBytes) {
|
|
297
|
+
return requiredBytes + Math.max(Math.ceil(requiredBytes / 2), FREE_SPACE_HEADROOM_BYTES);
|
|
298
|
+
}
|
|
299
|
+
function assertEnoughFreeSpace(freeBytes, requiredBytes, label) {
|
|
300
|
+
const needed = requiredArchiveSpace(requiredBytes);
|
|
301
|
+
if (freeBytes < needed) {
|
|
302
|
+
throw new Error(`${label} needs at least ${needed} bytes of free space (${requiredBytes} required plus reserve headroom) but only ${freeBytes} bytes are available. Free disk space or move backups off-host before retrying.`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function assertFreeSpaceForArchive(targetPath, requiredBytes, label) {
|
|
306
|
+
let stats;
|
|
307
|
+
try {
|
|
308
|
+
stats = await statfs(dirname(targetPath));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new Error(`Could not check free disk space for ${dirname(targetPath)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
311
|
+
}
|
|
312
|
+
assertEnoughFreeSpace(Number(stats.bavail) * Number(stats.bsize), requiredBytes, label);
|
|
313
|
+
}
|
|
259
314
|
function parseEnv(text) {
|
|
260
315
|
const values = {};
|
|
261
316
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -281,6 +336,11 @@ function serializeEnvValue(value) {
|
|
|
281
336
|
}
|
|
282
337
|
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
283
338
|
}
|
|
339
|
+
function serializeCredentialValue(value) {
|
|
340
|
+
if (/^[A-Za-z0-9._:@%+=/-]+$/.test(value))
|
|
341
|
+
return value;
|
|
342
|
+
return serializeEnvValue(value);
|
|
343
|
+
}
|
|
284
344
|
function serializeEnv(values) {
|
|
285
345
|
return `${Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${serializeEnvValue(value)}`).join(`
|
|
286
346
|
`)}
|
|
@@ -292,7 +352,8 @@ function deploymentPaths(directory) {
|
|
|
292
352
|
env: join(directory, ".env"),
|
|
293
353
|
state: join(cloud, "state.json"),
|
|
294
354
|
operations: join(cloud, "operations"),
|
|
295
|
-
backups: join(cloud, "backups")
|
|
355
|
+
backups: join(cloud, "backups"),
|
|
356
|
+
exports: join(cloud, "exports")
|
|
296
357
|
};
|
|
297
358
|
}
|
|
298
359
|
async function readState(directory) {
|
|
@@ -337,11 +398,38 @@ function diagnostics(directory, mode) {
|
|
|
337
398
|
const files = `-f ${quote(join(directory, "compose.yaml"))}${mode === "public" ? ` -f ${quote(join(directory, "compose.public.yaml"))}` : ""}`;
|
|
338
399
|
return `cd ${quote(directory)} && docker compose --env-file ${quote(join(directory, ".env"))} ${files} ps && docker compose --env-file ${quote(join(directory, ".env"))} ${files} logs --tail=200`;
|
|
339
400
|
}
|
|
401
|
+
async function captureComposeDiagnostics(directory, mode, secrets) {
|
|
402
|
+
const sections = [];
|
|
403
|
+
for (const [label, command] of [
|
|
404
|
+
["compose ps", ["ps", "-a"]],
|
|
405
|
+
["compose logs", ["logs", "--tail=200"]]
|
|
406
|
+
]) {
|
|
407
|
+
const result = await run("docker", composeArgs(directory, mode, [...command]), directory);
|
|
408
|
+
const detail = [result.stdout, result.stderr].filter(Boolean).join(`
|
|
409
|
+
`).trim();
|
|
410
|
+
sections.push(`${label}:
|
|
411
|
+
${detail || "(no output)"}`);
|
|
412
|
+
}
|
|
413
|
+
const containers = await run("docker", composeArgs(directory, mode, ["ps", "-aq"]), directory);
|
|
414
|
+
for (const container of containers.stdout.split(/\s+/).filter(Boolean)) {
|
|
415
|
+
const state = await run("docker", ["inspect", "--format", "{{json .State}}", container], directory);
|
|
416
|
+
const detail = [state.stdout, state.stderr].filter(Boolean).join(`
|
|
417
|
+
`).trim();
|
|
418
|
+
sections.push(`container ${container} state:
|
|
419
|
+
${detail || "(no output)"}`);
|
|
420
|
+
}
|
|
421
|
+
return redact(sections.join(`
|
|
422
|
+
|
|
423
|
+
`), secrets);
|
|
424
|
+
}
|
|
340
425
|
async function compose(directory, mode, command, secrets = []) {
|
|
341
426
|
const result = await run("docker", composeArgs(directory, mode, command), directory);
|
|
342
427
|
if (!result.ok) {
|
|
343
|
-
|
|
428
|
+
const captured = await captureComposeDiagnostics(directory, mode, secrets).catch((error) => redact(`Diagnostics capture failed: ${error instanceof Error ? error.message : String(error)}`, secrets));
|
|
429
|
+
throw new Error(`${redact(result.command, secrets)}
|
|
344
430
|
${redact(result.stderr, secrets)}
|
|
431
|
+
Captured Docker diagnostics:
|
|
432
|
+
${captured}
|
|
345
433
|
Diagnostics: ${diagnostics(directory, mode)}`);
|
|
346
434
|
}
|
|
347
435
|
return result.stdout;
|
|
@@ -402,11 +490,13 @@ async function assertSafeComposeBinding(directory, mode, env) {
|
|
|
402
490
|
}
|
|
403
491
|
}
|
|
404
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))";
|
|
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
|
+
var CONTAINER_NODE = "/nodejs/bin/node";
|
|
405
495
|
async function waitForDeepHealthWithArgs(composeCommand, directory, secrets = []) {
|
|
406
496
|
const deadline = Date.now() + DEEP_HEALTH_TIMEOUT_MS;
|
|
407
497
|
let lastError = "health check did not complete";
|
|
408
498
|
while (Date.now() < deadline) {
|
|
409
|
-
const result = await run("docker", [...composeCommand, "exec", "-T", "or3",
|
|
499
|
+
const result = await run("docker", [...composeCommand, "exec", "-T", "or3", CONTAINER_NODE, "-e", HEALTH_SCRIPT], directory);
|
|
410
500
|
if (result.ok)
|
|
411
501
|
return;
|
|
412
502
|
lastError = redact(result.stderr, secrets);
|
|
@@ -430,7 +520,10 @@ async function waitForDeepHealth(directory, mode, secrets = []) {
|
|
|
430
520
|
try {
|
|
431
521
|
await waitForDeepHealthWithArgs(composeArgs(directory, mode), directory, secrets);
|
|
432
522
|
} catch (error) {
|
|
523
|
+
const captured = await captureComposeDiagnostics(directory, mode, secrets).catch((captureError) => redact(`Diagnostics capture failed: ${captureError instanceof Error ? captureError.message : String(captureError)}`, secrets));
|
|
433
524
|
throw new Error(`${error instanceof Error ? error.message : String(error)}
|
|
525
|
+
Captured Docker diagnostics:
|
|
526
|
+
${captured}
|
|
434
527
|
Diagnostics: ${diagnostics(directory, mode)}`);
|
|
435
528
|
}
|
|
436
529
|
}
|
|
@@ -487,6 +580,50 @@ ${result.stderr}`);
|
|
|
487
580
|
throw new Error(`Could not resolve a digest for ${image}.`);
|
|
488
581
|
return idResult.stdout.trim();
|
|
489
582
|
}
|
|
583
|
+
function supportedImageArchitectures(manifest) {
|
|
584
|
+
if (manifest && Array.isArray(manifest.manifests)) {
|
|
585
|
+
const architectures = manifest.manifests.map((entry) => entry.platform?.architecture).filter((value) => typeof value === "string");
|
|
586
|
+
if (architectures.length > 0)
|
|
587
|
+
return [...new Set(architectures)];
|
|
588
|
+
}
|
|
589
|
+
if (manifest && typeof manifest.architecture === "string")
|
|
590
|
+
return [manifest.architecture];
|
|
591
|
+
throw new Error("The OR3 image manifest has no recognizable architecture list. Refusing to continue without confirming the image supports this machine.");
|
|
592
|
+
}
|
|
593
|
+
function assertSupportedArchitecture(manifest, hostArch) {
|
|
594
|
+
const supported = supportedImageArchitectures(manifest);
|
|
595
|
+
if (!supported.includes(hostArch)) {
|
|
596
|
+
throw new Error(`OR3 does not publish a ${hostArch} image for this version yet. Supported architectures: ${supported.join(", ") || "none detected"}. Install on a supported machine or wait for the next release.`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function hostArchitecture() {
|
|
600
|
+
if (process.arch === "arm64")
|
|
601
|
+
return "arm64";
|
|
602
|
+
if (process.arch === "x64")
|
|
603
|
+
return "amd64";
|
|
604
|
+
throw new Error(`OR3 supports only linux/amd64 and linux/arm64 hosts; this machine reports ${process.arch}. Install on a supported machine.`);
|
|
605
|
+
}
|
|
606
|
+
async function assertSupportedHostArchitecture(image) {
|
|
607
|
+
const hostArch = hostArchitecture();
|
|
608
|
+
if (process.env.OR3_CLOUD_SKIP_PULL === "true") {
|
|
609
|
+
const local = await run("docker", ["image", "inspect", "--format", "{{.Architecture}}", image]);
|
|
610
|
+
if (local.ok && local.stdout.trim()) {
|
|
611
|
+
assertSupportedArchitecture({ architecture: local.stdout.trim() }, hostArch);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const result = await run("docker", ["manifest", "inspect", image]);
|
|
616
|
+
if (!result.ok) {
|
|
617
|
+
throw new Error(`Could not inspect the OR3 image manifest for ${image}. ${result.stderr.trim()}`);
|
|
618
|
+
}
|
|
619
|
+
let manifest;
|
|
620
|
+
try {
|
|
621
|
+
manifest = JSON.parse(result.stdout.trim());
|
|
622
|
+
} catch {
|
|
623
|
+
throw new Error(`The OR3 image manifest for ${image} could not be parsed. Refusing to continue without confirming the image supports this machine.`);
|
|
624
|
+
}
|
|
625
|
+
assertSupportedArchitecture(manifest, hostArch);
|
|
626
|
+
}
|
|
490
627
|
function composeProjectNames(directory) {
|
|
491
628
|
const base = sanitizeName(basename(directory));
|
|
492
629
|
return {
|
|
@@ -511,7 +648,11 @@ function buildEnv(input2) {
|
|
|
511
648
|
SSR_AUTH_ENABLED: "true",
|
|
512
649
|
AUTH_PROVIDER: "basic-auth",
|
|
513
650
|
OR3_AUTH_PROVIDER: "basic-auth",
|
|
651
|
+
OR3_AUTH_REGISTRATION_MODE: "invite_only",
|
|
652
|
+
OR3_AUTH_AUTO_PROVISION: "false",
|
|
514
653
|
OR3_GUEST_ACCESS_ENABLED: "false",
|
|
654
|
+
OR3_PLUGIN_ZIP_INSTALL_ENABLED: "false",
|
|
655
|
+
OR3_ADMIN_ALLOW_REBUILD: "false",
|
|
515
656
|
OR3_BASIC_AUTH_JWT_SECRET: secrets.OR3_BASIC_AUTH_JWT_SECRET ?? randomSecret(),
|
|
516
657
|
OR3_BASIC_AUTH_REFRESH_SECRET: secrets.OR3_BASIC_AUTH_REFRESH_SECRET ?? randomSecret(),
|
|
517
658
|
OR3_BASIC_AUTH_ACCESS_TTL_SECONDS: "900",
|
|
@@ -519,6 +660,7 @@ function buildEnv(input2) {
|
|
|
519
660
|
OR3_BASIC_AUTH_DB_PATH: "/data/auth.sqlite",
|
|
520
661
|
OR3_BASIC_AUTH_BOOTSTRAP_EMAIL: input2.email,
|
|
521
662
|
OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: input2.password,
|
|
663
|
+
OR3_AUTH_INVITE_TOKEN_SECRET: secrets.OR3_AUTH_INVITE_TOKEN_SECRET ?? randomSecret(),
|
|
522
664
|
OR3_SYNC_ENABLED: "true",
|
|
523
665
|
OR3_CLOUD_SYNC_ENABLED: "true",
|
|
524
666
|
OR3_SYNC_PROVIDER: "sqlite",
|
|
@@ -638,6 +780,111 @@ async function startProject(directory, mode, env) {
|
|
|
638
780
|
function backupDirectory(directory, backupId) {
|
|
639
781
|
return join(deploymentPaths(directory).backups, backupId);
|
|
640
782
|
}
|
|
783
|
+
async function dataVolumeSize(directory, mode, env) {
|
|
784
|
+
const parseDuOutput = (stdout) => {
|
|
785
|
+
const match = stdout.trim().match(/^(\d+)/);
|
|
786
|
+
if (!match)
|
|
787
|
+
throw new Error(`Could not parse the data volume size from "du" output.`);
|
|
788
|
+
return Number(match[1]);
|
|
789
|
+
};
|
|
790
|
+
const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", "du -sb /data 2>/dev/null"])], directory);
|
|
791
|
+
if (exec.ok)
|
|
792
|
+
return parseDuOutput(exec.stdout);
|
|
793
|
+
const fallback = await run("docker", [...composeArgs(directory, mode, ["run", "--rm", "--no-deps", "--entrypoint", "sh", "or3", "-c", "du -sb /data 2>/dev/null"])], directory);
|
|
794
|
+
if (fallback.ok)
|
|
795
|
+
return parseDuOutput(fallback.stdout);
|
|
796
|
+
throw new Error(`Could not measure the data volume size for the free-space preflight. Start the deployment and retry. ${redact(`${exec.stderr} ${fallback.stderr}`)}`);
|
|
797
|
+
}
|
|
798
|
+
function assertRemovableArtifactName(name) {
|
|
799
|
+
if (!BACKUP_ID_PATTERN.test(name)) {
|
|
800
|
+
throw new Error(`Refusing to remove a backup artifact named "${name}". Only paths matching an OR3-generated backup ID may be removed.`);
|
|
801
|
+
}
|
|
802
|
+
return name;
|
|
803
|
+
}
|
|
804
|
+
async function removeNamedBackupArtifact(directory, backupId) {
|
|
805
|
+
assertRemovableArtifactName(backupId);
|
|
806
|
+
const target = backupDirectory(directory, backupId);
|
|
807
|
+
if (dirname(target) !== deploymentPaths(directory).backups) {
|
|
808
|
+
throw new Error(`Refusing to remove a path outside the backups directory: ${target}.`);
|
|
809
|
+
}
|
|
810
|
+
await rm(target, { recursive: true, force: true });
|
|
811
|
+
await rm(join(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
|
|
812
|
+
}
|
|
813
|
+
async function readBackupManifestMetadata(backupPath) {
|
|
814
|
+
try {
|
|
815
|
+
const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
|
|
816
|
+
if (manifest.schemaVersion !== 1 || !manifest.backupId || !manifest.createdAt)
|
|
817
|
+
return null;
|
|
818
|
+
return manifest;
|
|
819
|
+
} catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async function enumerateBackups(directory) {
|
|
824
|
+
const backupsRoot = deploymentPaths(directory).backups;
|
|
825
|
+
let entries = [];
|
|
826
|
+
try {
|
|
827
|
+
entries = await readdir(backupsRoot);
|
|
828
|
+
} catch {
|
|
829
|
+
return [];
|
|
830
|
+
}
|
|
831
|
+
const result = [];
|
|
832
|
+
for (const entry of entries) {
|
|
833
|
+
const path = join(backupsRoot, entry);
|
|
834
|
+
const manifest = await readBackupManifestMetadata(path);
|
|
835
|
+
if (!manifest)
|
|
836
|
+
continue;
|
|
837
|
+
let bytes = 0;
|
|
838
|
+
try {
|
|
839
|
+
for (const file of ["data.tgz", "config.env", "manifest.json"]) {
|
|
840
|
+
bytes += (await stat(join(path, file))).size;
|
|
841
|
+
}
|
|
842
|
+
} catch {
|
|
843
|
+
bytes = 0;
|
|
844
|
+
}
|
|
845
|
+
result.push({
|
|
846
|
+
backupId: manifest.backupId,
|
|
847
|
+
createdAt: manifest.createdAt,
|
|
848
|
+
appVersion: manifest.appVersion,
|
|
849
|
+
path,
|
|
850
|
+
bytes,
|
|
851
|
+
dataSha256: manifest.dataSha256
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
result.sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
|
|
855
|
+
return result;
|
|
856
|
+
}
|
|
857
|
+
function selectPruneTargets(backups, keep, protectedIds, force = false) {
|
|
858
|
+
if (!Number.isInteger(keep) || keep < 1)
|
|
859
|
+
throw new Error("Backup retention must be an integer of at least 1.");
|
|
860
|
+
const sorted = [...backups].sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
|
|
861
|
+
const deletable = force ? sorted : sorted.filter((backup) => !protectedIds.has(backup.backupId));
|
|
862
|
+
return deletable.slice(keep).map((backup) => backup.backupId);
|
|
863
|
+
}
|
|
864
|
+
function parseKeep(flags) {
|
|
865
|
+
const value = stringFlag(flags, "keep") ?? String(BACKUP_RETENTION_KEEP);
|
|
866
|
+
const keep = Number(value);
|
|
867
|
+
if (!Number.isInteger(keep) || keep < 1)
|
|
868
|
+
throw new Error("--keep must be an integer of at least 1.");
|
|
869
|
+
return keep;
|
|
870
|
+
}
|
|
871
|
+
async function pruneBackups(directory, state, keep, force) {
|
|
872
|
+
const backups = await enumerateBackups(directory);
|
|
873
|
+
const protectedIds = new Set;
|
|
874
|
+
if (state.rollback?.backupId)
|
|
875
|
+
protectedIds.add(state.rollback.backupId);
|
|
876
|
+
if (state.incompleteOperation?.backupId)
|
|
877
|
+
protectedIds.add(state.incompleteOperation.backupId);
|
|
878
|
+
const targets = selectPruneTargets(backups, keep, protectedIds, force);
|
|
879
|
+
if (force && targets.length > 0) {
|
|
880
|
+
console.log(`--force --yes will permanently delete: ${targets.join(", ")}`);
|
|
881
|
+
}
|
|
882
|
+
for (const backupId of targets) {
|
|
883
|
+
await removeNamedBackupArtifact(directory, backupId);
|
|
884
|
+
console.log(`Deleted backup ${backupId} at ${backupDirectory(directory, backupId)}`);
|
|
885
|
+
}
|
|
886
|
+
return targets.length;
|
|
887
|
+
}
|
|
641
888
|
function assertDeploymentIdentity(state, env) {
|
|
642
889
|
const expected = {
|
|
643
890
|
OR3_COMPOSE_PROJECT: state.composeProject,
|
|
@@ -655,6 +902,26 @@ function assertDeploymentIdentity(state, env) {
|
|
|
655
902
|
}
|
|
656
903
|
}
|
|
657
904
|
}
|
|
905
|
+
function assertDeploymentDirectoryIdentity(directory, state) {
|
|
906
|
+
const resolved = resolve(directory);
|
|
907
|
+
const names = composeProjectNames(resolved);
|
|
908
|
+
const mismatches = [];
|
|
909
|
+
if (state.composeProject !== names.project) {
|
|
910
|
+
mismatches.push(`compose project "${state.composeProject}" (this directory resolves to "${names.project}")`);
|
|
911
|
+
}
|
|
912
|
+
if (state.volumeName !== names.volume) {
|
|
913
|
+
mismatches.push(`volume "${state.volumeName}" (this directory resolves to "${names.volume}")`);
|
|
914
|
+
}
|
|
915
|
+
if (state.mode === "public") {
|
|
916
|
+
if (state.caddyDataVolume !== names.caddyData)
|
|
917
|
+
mismatches.push(`Caddy data volume "${state.caddyDataVolume}"`);
|
|
918
|
+
if (state.caddyConfigVolume !== names.caddyConfig)
|
|
919
|
+
mismatches.push(`Caddy config volume "${state.caddyConfigVolume}"`);
|
|
920
|
+
}
|
|
921
|
+
if (mismatches.length) {
|
|
922
|
+
throw new Error(`Managed state does not match the deployment directory identity: ${mismatches.join("; ")}. Refusing to operate on an unrelated project. Run doctor for diagnostics.`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
658
925
|
function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
|
|
659
926
|
if (manifest.mode !== state.mode || manifest.appVersion === undefined || manifest.image === undefined) {
|
|
660
927
|
throw new Error(`Backup ${manifest.backupId} does not match the managed deployment mode.`);
|
|
@@ -696,69 +963,128 @@ function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
|
|
|
696
963
|
}
|
|
697
964
|
async function sha256File(path) {
|
|
698
965
|
const digest = createHash("sha256");
|
|
699
|
-
|
|
966
|
+
await new Promise((resolvePromise, reject) => {
|
|
967
|
+
const stream = createReadStream(path);
|
|
968
|
+
stream.on("data", (chunk) => digest.update(chunk));
|
|
969
|
+
stream.once("error", reject);
|
|
970
|
+
stream.once("end", resolvePromise);
|
|
971
|
+
});
|
|
700
972
|
return digest.digest("hex");
|
|
701
973
|
}
|
|
974
|
+
async function streamCommandToFile(command, args, destination, cwd, secrets = []) {
|
|
975
|
+
if (await fileExists(destination)) {
|
|
976
|
+
throw new Error(`Refusing to overwrite existing archive ${destination}.`);
|
|
977
|
+
}
|
|
978
|
+
await mkdir(dirname(destination), { recursive: true, mode: 448 });
|
|
979
|
+
const temporary = `${destination}.${randomBytes(8).toString("hex")}.partial`;
|
|
980
|
+
const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
981
|
+
let stderr = "";
|
|
982
|
+
child.stderr.setEncoding("utf8");
|
|
983
|
+
child.stderr.on("data", (chunk) => {
|
|
984
|
+
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
985
|
+
});
|
|
986
|
+
const exit = new Promise((resolvePromise, reject) => {
|
|
987
|
+
child.once("error", reject);
|
|
988
|
+
child.once("close", resolvePromise);
|
|
989
|
+
});
|
|
990
|
+
try {
|
|
991
|
+
const [exitCode] = await Promise.all([
|
|
992
|
+
exit,
|
|
993
|
+
pipeline(child.stdout, createWriteStream(temporary, { flags: "wx", mode: 384 }))
|
|
994
|
+
]);
|
|
995
|
+
if (exitCode !== 0) {
|
|
996
|
+
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
997
|
+
}
|
|
998
|
+
await chmod(temporary, 384);
|
|
999
|
+
await rename(temporary, destination);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (!child.killed)
|
|
1002
|
+
child.kill("SIGTERM");
|
|
1003
|
+
await rm(temporary, { force: true }).catch(() => {
|
|
1004
|
+
return;
|
|
1005
|
+
});
|
|
1006
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1007
|
+
throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
|
|
1008
|
+
${redact(stderr, secrets)}` : ""}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
1012
|
+
const child = spawn(command, args, { cwd, stdio: ["pipe", "ignore", "pipe"] });
|
|
1013
|
+
let stderr = "";
|
|
1014
|
+
child.stderr.setEncoding("utf8");
|
|
1015
|
+
child.stderr.on("data", (chunk) => {
|
|
1016
|
+
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
1017
|
+
});
|
|
1018
|
+
const exit = new Promise((resolvePromise, reject) => {
|
|
1019
|
+
child.once("error", reject);
|
|
1020
|
+
child.once("close", resolvePromise);
|
|
1021
|
+
});
|
|
1022
|
+
try {
|
|
1023
|
+
const [exitCode] = await Promise.all([
|
|
1024
|
+
exit,
|
|
1025
|
+
pipeline(createReadStream(source), child.stdin)
|
|
1026
|
+
]);
|
|
1027
|
+
if (exitCode !== 0) {
|
|
1028
|
+
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
1029
|
+
}
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
if (!child.killed)
|
|
1032
|
+
child.kill("SIGTERM");
|
|
1033
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1034
|
+
throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
|
|
1035
|
+
${redact(stderr, secrets)}` : ""}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
async function gzipUncompressedBytes(path) {
|
|
1039
|
+
let bytes = 0;
|
|
1040
|
+
const counter = new Writable({
|
|
1041
|
+
write(chunk, _encoding, callback) {
|
|
1042
|
+
bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
|
|
1043
|
+
callback();
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
await pipeline(createReadStream(path), createGunzip(), counter);
|
|
1047
|
+
return bytes;
|
|
1048
|
+
}
|
|
702
1049
|
async function volumeArchive(directory, mode, env, backupDir) {
|
|
703
|
-
|
|
704
|
-
await compose(directory, mode, [
|
|
1050
|
+
await streamCommandToFile("docker", composeArgs(directory, mode, [
|
|
705
1051
|
"run",
|
|
706
1052
|
"--rm",
|
|
707
1053
|
"--no-deps",
|
|
708
|
-
"--user",
|
|
709
|
-
"0:0",
|
|
710
|
-
"-v",
|
|
711
|
-
mount,
|
|
712
1054
|
"--entrypoint",
|
|
713
1055
|
"sh",
|
|
714
1056
|
"or3",
|
|
715
1057
|
"-c",
|
|
716
|
-
"
|
|
717
|
-
], secretValues(env));
|
|
1058
|
+
"tar czf - -C /data ."
|
|
1059
|
+
]), join(backupDir, "data.tgz"), directory, secretValues(env));
|
|
718
1060
|
}
|
|
719
1061
|
async function archiveExternalVolume(image, volume, backupDir) {
|
|
720
|
-
|
|
721
|
-
"run",
|
|
722
|
-
"--rm",
|
|
723
|
-
"--user",
|
|
724
|
-
"0:0",
|
|
725
|
-
"-v",
|
|
726
|
-
`${volume}:/source:ro`,
|
|
727
|
-
"-v",
|
|
728
|
-
`${backupDir}:/backup`,
|
|
729
|
-
image,
|
|
730
|
-
"sh",
|
|
731
|
-
"-c",
|
|
732
|
-
"umask 077 && tar czf /backup/data.tgz -C /source . && tar tzf /backup/data.tgz >/dev/null"
|
|
733
|
-
]);
|
|
734
|
-
if (!result.ok)
|
|
735
|
-
throw new Error(`${result.command}
|
|
736
|
-
${result.stderr}`);
|
|
1062
|
+
await streamCommandToFile("docker", ["run", "--rm", "--user", "0:0", "-v", `${volume}:/source:ro`, image, "sh", "-c", "tar czf - -C /source ."], join(backupDir, "data.tgz"));
|
|
737
1063
|
}
|
|
738
|
-
async function
|
|
739
|
-
await
|
|
1064
|
+
async function restoreVolumeArchive(directory, mode, env, backupPath) {
|
|
1065
|
+
await streamFileToCommand("docker", composeArgs(directory, mode, [
|
|
740
1066
|
"run",
|
|
741
1067
|
"--rm",
|
|
1068
|
+
"-T",
|
|
742
1069
|
"--no-deps",
|
|
743
|
-
"--user",
|
|
744
|
-
"0:0",
|
|
745
|
-
"-v",
|
|
746
|
-
`${backupPath}:/backup:ro`,
|
|
747
1070
|
"--entrypoint",
|
|
748
1071
|
"sh",
|
|
749
1072
|
"or3",
|
|
750
1073
|
"-c",
|
|
751
|
-
"find /data -mindepth 1 -delete && tar xzf
|
|
752
|
-
], secretValues(env));
|
|
1074
|
+
"find /data -mindepth 1 -delete && tar xzf - -C /data"
|
|
1075
|
+
]), join(backupPath, "data.tgz"), directory, secretValues(env));
|
|
753
1076
|
}
|
|
754
1077
|
async function createBackup(directory, state, env) {
|
|
755
1078
|
await requireImageDigest(state.image, state.imageDigest, "Current deployment");
|
|
756
1079
|
const backupId = id("backup");
|
|
757
1080
|
const backupDir = backupDirectory(directory, backupId);
|
|
758
|
-
|
|
759
|
-
await
|
|
1081
|
+
const volumeSize = await dataVolumeSize(directory, state.mode, env);
|
|
1082
|
+
await assertFreeSpaceForArchive(backupDir, volumeSize, "Backup");
|
|
1083
|
+
let manifestWritten = false;
|
|
760
1084
|
let stopAttempted = false;
|
|
761
1085
|
try {
|
|
1086
|
+
await mkdir(backupDir, { recursive: true, mode: 448 });
|
|
1087
|
+
await chmod(backupDir, 448);
|
|
762
1088
|
stopAttempted = true;
|
|
763
1089
|
await stopProject(directory, state.mode);
|
|
764
1090
|
await volumeArchive(directory, state.mode, env, backupDir);
|
|
@@ -771,6 +1097,7 @@ async function createBackup(directory, state, env) {
|
|
|
771
1097
|
image: state.image,
|
|
772
1098
|
imageDigest: state.imageDigest,
|
|
773
1099
|
dataSha256: await sha256File(join(backupDir, "data.tgz")),
|
|
1100
|
+
dataBytes: volumeSize,
|
|
774
1101
|
configSha256: await sha256File(join(backupDir, "config.env")),
|
|
775
1102
|
mode: state.mode,
|
|
776
1103
|
domain: state.domain,
|
|
@@ -782,7 +1109,21 @@ async function createBackup(directory, state, env) {
|
|
|
782
1109
|
};
|
|
783
1110
|
await writeSecure(join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
784
1111
|
`);
|
|
1112
|
+
manifestWritten = true;
|
|
1113
|
+
try {
|
|
1114
|
+
await pruneBackups(directory, state, BACKUP_RETENTION_KEEP, false);
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
console.log(`Warning: automatic backup retention could not run: ${error instanceof Error ? error.message : String(error)}`);
|
|
1117
|
+
}
|
|
785
1118
|
return { backupId, backupDir, manifest };
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
if (!manifestWritten) {
|
|
1121
|
+
await removeNamedBackupArtifact(directory, backupId).catch(() => {
|
|
1122
|
+
return;
|
|
1123
|
+
});
|
|
1124
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} The partial backup artifact at ${backupDir} was removed.`);
|
|
1125
|
+
}
|
|
1126
|
+
throw error;
|
|
786
1127
|
} finally {
|
|
787
1128
|
if (stopAttempted) {
|
|
788
1129
|
try {
|
|
@@ -795,7 +1136,7 @@ async function createBackup(directory, state, env) {
|
|
|
795
1136
|
}
|
|
796
1137
|
async function readManifest(backupPath) {
|
|
797
1138
|
const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
|
|
798
|
-
if (manifest.schemaVersion !== 1 || !manifest.backupId || !isVersion(manifest.appVersion) || !manifest.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest.imageDigest)) {
|
|
1139
|
+
if (manifest.schemaVersion !== 1 || !manifest.backupId || !isVersion(manifest.appVersion) || !manifest.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest.imageDigest) || manifest.dataBytes !== undefined && (!Number.isSafeInteger(manifest.dataBytes) || manifest.dataBytes < 0)) {
|
|
799
1140
|
throw new Error(`Invalid backup manifest at ${backupPath}.`);
|
|
800
1141
|
}
|
|
801
1142
|
const actual = await sha256File(join(backupPath, "data.tgz"));
|
|
@@ -808,6 +1149,28 @@ async function readManifest(backupPath) {
|
|
|
808
1149
|
}
|
|
809
1150
|
return manifest;
|
|
810
1151
|
}
|
|
1152
|
+
async function dataVolumeFreeBytes(directory, mode, env) {
|
|
1153
|
+
const parse = (stdout) => {
|
|
1154
|
+
const blocks = Number(stdout.trim());
|
|
1155
|
+
if (!Number.isSafeInteger(blocks) || blocks < 0)
|
|
1156
|
+
throw new Error("Could not parse free blocks from the data volume.");
|
|
1157
|
+
return blocks * 1024;
|
|
1158
|
+
};
|
|
1159
|
+
const script = "df -Pk /data | awk 'NR == 2 { print $4 }'";
|
|
1160
|
+
const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", script])], directory);
|
|
1161
|
+
if (exec.ok)
|
|
1162
|
+
return parse(exec.stdout);
|
|
1163
|
+
const fallback = await run("docker", [...composeArgs(directory, mode, ["run", "--rm", "--no-deps", "--entrypoint", "sh", "or3", "-c", script])], directory);
|
|
1164
|
+
if (fallback.ok)
|
|
1165
|
+
return parse(fallback.stdout);
|
|
1166
|
+
throw new Error(`Could not measure free space in the Docker data volume. ${redact(`${exec.stderr} ${fallback.stderr}`, secretValues(env))}`);
|
|
1167
|
+
}
|
|
1168
|
+
async function assertRestoreFreeSpace(directory, state, env, manifest, backupPath) {
|
|
1169
|
+
const archiveBytes = await gzipUncompressedBytes(join(backupPath, "data.tgz"));
|
|
1170
|
+
const requiredBytes = Math.max(manifest.dataBytes ?? 0, archiveBytes);
|
|
1171
|
+
const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
|
|
1172
|
+
assertEnoughFreeSpace(freeBytes, requiredBytes, "Restore");
|
|
1173
|
+
}
|
|
811
1174
|
async function restoreBackupData(directory, state, env, backupPath) {
|
|
812
1175
|
const manifest = await readManifest(backupPath);
|
|
813
1176
|
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
@@ -817,26 +1180,14 @@ async function restoreBackupData(directory, state, env, backupPath) {
|
|
|
817
1180
|
assertBackupMatchesDeployment(manifest, backupEnv, state, env);
|
|
818
1181
|
if (manifest.imageDigest)
|
|
819
1182
|
await requireImageDigest(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
1183
|
+
await assertRestoreFreeSpace(directory, state, env, manifest, backupPath);
|
|
820
1184
|
let started = false;
|
|
821
1185
|
let stopAttempted = false;
|
|
822
1186
|
try {
|
|
823
1187
|
stopAttempted = true;
|
|
824
1188
|
await stopProject(directory, state.mode);
|
|
825
1189
|
await copySecure(join(backupPath, "config.env"), deploymentPaths(directory).env);
|
|
826
|
-
await
|
|
827
|
-
"run",
|
|
828
|
-
"--rm",
|
|
829
|
-
"--no-deps",
|
|
830
|
-
"--user",
|
|
831
|
-
"0:0",
|
|
832
|
-
"-v",
|
|
833
|
-
`${backupPath}:/backup:ro`,
|
|
834
|
-
"--entrypoint",
|
|
835
|
-
"sh",
|
|
836
|
-
"or3",
|
|
837
|
-
"-c",
|
|
838
|
-
"find /data -mindepth 1 -delete && tar xzf /backup/data.tgz -C /data"
|
|
839
|
-
], secretValues(backupEnv));
|
|
1190
|
+
await restoreVolumeArchive(directory, state.mode, backupEnv, backupPath);
|
|
840
1191
|
const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
841
1192
|
await startProject(directory, state.mode, restoredEnv);
|
|
842
1193
|
started = true;
|
|
@@ -878,19 +1229,29 @@ Usage:
|
|
|
878
1229
|
npx @or3/cloud init [directory] --local
|
|
879
1230
|
npx @or3/cloud init [directory] --public --domain <hostname>
|
|
880
1231
|
npx @or3/cloud update [--to <exact-version>]
|
|
881
|
-
npx @or3/cloud backup
|
|
1232
|
+
npx @or3/cloud backup [list|prune [--keep <n>]|export <backup-id> <destination-dir>]
|
|
882
1233
|
npx @or3/cloud restore <backup-id-or-path> --yes
|
|
883
1234
|
npx @or3/cloud rollback --yes
|
|
1235
|
+
npx @or3/cloud credentials reset --yes [--owner-password <p> --admin-password <p>]
|
|
884
1236
|
npx @or3/cloud doctor
|
|
885
1237
|
npx @or3/cloud recover
|
|
886
1238
|
npx @or3/cloud adopt --from <v1-directory> [directory]
|
|
1239
|
+
npx @or3/cloud status
|
|
1240
|
+
npx @or3/cloud logs [--tail <n>] [service]
|
|
1241
|
+
npx @or3/cloud start | stop | restart
|
|
1242
|
+
npx @or3/cloud remove [--purge-data --yes]
|
|
887
1243
|
|
|
888
1244
|
Options:
|
|
889
1245
|
--admin-email <email> Administrator email for first login
|
|
890
|
-
--admin-password <password> Explicit password (prefer --admin-password-file)
|
|
1246
|
+
--admin-password <password> Explicit password (prefer --admin-password-file); for credentials reset, the new admin password
|
|
891
1247
|
--admin-password-file <path> Read the bootstrap password without shell history
|
|
1248
|
+
--owner-password <password> New owner (basic auth) password for credentials reset
|
|
892
1249
|
--port <port> Local OR3 port (default: 3000)
|
|
893
|
-
--
|
|
1250
|
+
--keep <n> Backups to retain when pruning (default: 5)
|
|
1251
|
+
--force Prune backups even when referenced by the rollback point
|
|
1252
|
+
--tail <n> Log lines to show (default: 200)
|
|
1253
|
+
--purge-data Remove data volumes and managed files (with remove)
|
|
1254
|
+
--yes Confirm a destructive restore, rollback, credentials reset, or purge
|
|
894
1255
|
--help Show this help
|
|
895
1256
|
--version Show the Cloud package version
|
|
896
1257
|
|
|
@@ -938,6 +1299,7 @@ async function initCommand(positionals, flags) {
|
|
|
938
1299
|
const version = PACKAGE_VERSION;
|
|
939
1300
|
const image = imageFor(version);
|
|
940
1301
|
await pullImage(image);
|
|
1302
|
+
await assertSupportedHostArchitecture(image);
|
|
941
1303
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
942
1304
|
await chmod(directory, 448);
|
|
943
1305
|
await mkdir(deploymentPaths(directory).operations, { recursive: true, mode: 448 });
|
|
@@ -945,9 +1307,12 @@ async function initCommand(positionals, flags) {
|
|
|
945
1307
|
await copyAssets(directory, mode);
|
|
946
1308
|
const env = buildEnv({ mode, version, directory, email, password, domain, port });
|
|
947
1309
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
|
|
948
|
-
await writeSecure(join(directory, ".or3-initial-credentials"),
|
|
949
|
-
|
|
950
|
-
|
|
1310
|
+
await writeSecure(join(directory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
1311
|
+
bootstrapEmail: email,
|
|
1312
|
+
bootstrapPassword: password,
|
|
1313
|
+
adminUsername: email,
|
|
1314
|
+
adminPassword: password
|
|
1315
|
+
}));
|
|
951
1316
|
const digest = await imageDigest(image);
|
|
952
1317
|
const state = stateFromEnv(directory, env, mode, "init", digest);
|
|
953
1318
|
await markPending(directory, state, {
|
|
@@ -999,6 +1364,16 @@ async function recoverCommand(directory) {
|
|
|
999
1364
|
return;
|
|
1000
1365
|
}
|
|
1001
1366
|
try {
|
|
1367
|
+
if (pending.operation === "credentials-reset") {
|
|
1368
|
+
const nextEnv = pending.credentialReset?.nextEnv;
|
|
1369
|
+
if (!nextEnv)
|
|
1370
|
+
throw new Error("The incomplete credential reset has no protected recovery data. Restore a backup rather than guessing credentials.");
|
|
1371
|
+
await applyCredentialReset(loaded.directory, loaded.state, nextEnv);
|
|
1372
|
+
loaded.state.lastError = undefined;
|
|
1373
|
+
await clearPending(loaded.directory, loaded.state);
|
|
1374
|
+
console.log("Recovered the incomplete credential reset. Owner and admin credentials were verified inside the OR3 container.");
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1002
1377
|
if (pending.operation === "restore" || pending.operation === "rollback") {
|
|
1003
1378
|
if (!pending.backupId)
|
|
1004
1379
|
throw new Error(`The incomplete ${pending.operation} has no recorded backup ID.`);
|
|
@@ -1079,7 +1454,7 @@ async function recoverCommand(directory) {
|
|
|
1079
1454
|
throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
|
|
1080
1455
|
}
|
|
1081
1456
|
await stopProject(loaded.directory, loaded.state.mode);
|
|
1082
|
-
await
|
|
1457
|
+
await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
|
|
1083
1458
|
}
|
|
1084
1459
|
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
1085
1460
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
@@ -1090,7 +1465,8 @@ async function recoverCommand(directory) {
|
|
|
1090
1465
|
await commitRecoveredState(loaded.directory, loaded.state, recovered);
|
|
1091
1466
|
console.log(`Recovered the incomplete ${pending.operation} operation. OR3 ${recovered.appVersion} is deeply healthy.`);
|
|
1092
1467
|
} catch (error) {
|
|
1093
|
-
|
|
1468
|
+
const recoverySecrets = pending.operation === "credentials-reset" ? secretValues(pending.credentialReset?.nextEnv ?? loaded.env) : secretValues(loaded.env);
|
|
1469
|
+
loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), recoverySecrets);
|
|
1094
1470
|
if (pending.operation === "adopt" && pending.sourceDirectory) {
|
|
1095
1471
|
try {
|
|
1096
1472
|
await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
|
|
@@ -1115,7 +1491,7 @@ function assertNoPending(state) {
|
|
|
1115
1491
|
throw new Error(`An incomplete ${state.incompleteOperation.operation} is recorded. Run "npx @or3/cloud recover" before starting another operation.`);
|
|
1116
1492
|
}
|
|
1117
1493
|
}
|
|
1118
|
-
async function
|
|
1494
|
+
async function backupCreateCommand(directory) {
|
|
1119
1495
|
await ensureDocker();
|
|
1120
1496
|
const loaded = await loadManaged(directory);
|
|
1121
1497
|
assertNoPending(loaded.state);
|
|
@@ -1137,6 +1513,109 @@ async function backupCommand(directory) {
|
|
|
1137
1513
|
throw error;
|
|
1138
1514
|
}
|
|
1139
1515
|
}
|
|
1516
|
+
async function backupListCommand(directory) {
|
|
1517
|
+
const loaded = await loadManaged(directory);
|
|
1518
|
+
const backups = await enumerateBackups(loaded.directory);
|
|
1519
|
+
if (backups.length === 0) {
|
|
1520
|
+
console.log(`No backups exist yet for ${loaded.directory}. Run "npx @or3/cloud backup" to create one.`);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
console.log(`OR3 Cloud backups for ${loaded.directory} (${backups.length}):`);
|
|
1524
|
+
console.log("backupId createdAt version bytes checksum");
|
|
1525
|
+
for (const backup of backups) {
|
|
1526
|
+
console.log(`${backup.backupId.padEnd(30)} ${backup.createdAt.padEnd(30)} ${backup.appVersion.padEnd(8)} ${String(backup.bytes).padStart(9)} ${backup.dataSha256.slice(0, 12)}`);
|
|
1527
|
+
}
|
|
1528
|
+
console.log(`
|
|
1529
|
+
Backups contain credentials and secrets; keep them owner-only and export off-host.`);
|
|
1530
|
+
}
|
|
1531
|
+
async function backupPruneCommand(directory, flags) {
|
|
1532
|
+
const loaded = await loadManaged(directory);
|
|
1533
|
+
assertNoPending(loaded.state);
|
|
1534
|
+
const keep = parseKeep(flags);
|
|
1535
|
+
const force = boolFlag(flags, "force");
|
|
1536
|
+
if (force && !boolFlag(flags, "yes"))
|
|
1537
|
+
throw new Error("--force may delete the only rollback or recovery backup. Re-run with --force --yes after confirming the exact backups shown by `backup list`.");
|
|
1538
|
+
const deleted = await pruneBackups(loaded.directory, loaded.state, keep, force);
|
|
1539
|
+
console.log(deleted > 0 ? `Pruned ${deleted} backup(s); keeping the newest ${keep}.` : `Nothing to prune: keeping all backups (newest ${keep}).`);
|
|
1540
|
+
}
|
|
1541
|
+
async function backupExportCommand(directory, backupId, destination) {
|
|
1542
|
+
const backupPath = await resolveBackup(directory, backupId);
|
|
1543
|
+
const manifest = await readManifest(backupPath);
|
|
1544
|
+
const dest = resolve(destination);
|
|
1545
|
+
const source = resolve(backupPath);
|
|
1546
|
+
if (source === dest || dest.startsWith(`${source}${sep}`)) {
|
|
1547
|
+
throw new Error("Choose a destination directory different from the backup itself.");
|
|
1548
|
+
}
|
|
1549
|
+
if (await fileExists(dest))
|
|
1550
|
+
throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
|
|
1551
|
+
await mkdir(dirname(dest), { recursive: true, mode: 448 });
|
|
1552
|
+
await mkdir(dest, { mode: 448 });
|
|
1553
|
+
try {
|
|
1554
|
+
await chmod(dest, 448);
|
|
1555
|
+
for (const file of ["data.tgz", "config.env", "manifest.json"]) {
|
|
1556
|
+
await copySecure(join(backupPath, file), join(dest, file));
|
|
1557
|
+
}
|
|
1558
|
+
const dataSha = await sha256File(join(dest, "data.tgz"));
|
|
1559
|
+
if (dataSha !== manifest.dataSha256) {
|
|
1560
|
+
throw new Error(`Exported data.tgz checksum mismatch for ${manifest.backupId}. Expected ${manifest.dataSha256}, got ${dataSha}.`);
|
|
1561
|
+
}
|
|
1562
|
+
if (manifest.configSha256) {
|
|
1563
|
+
const configSha = await sha256File(join(dest, "config.env"));
|
|
1564
|
+
if (configSha !== manifest.configSha256) {
|
|
1565
|
+
throw new Error(`Exported config.env checksum mismatch for ${manifest.backupId}.`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
const destinationDevice = (await stat(dest)).dev;
|
|
1569
|
+
await mkdir(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
|
|
1570
|
+
const receipt = {
|
|
1571
|
+
schemaVersion: 1,
|
|
1572
|
+
backupId: manifest.backupId,
|
|
1573
|
+
exportedAt: now(),
|
|
1574
|
+
destination: dest,
|
|
1575
|
+
destinationDevice,
|
|
1576
|
+
dataSha256: manifest.dataSha256,
|
|
1577
|
+
configSha256: manifest.configSha256
|
|
1578
|
+
};
|
|
1579
|
+
await writeSecure(join(deploymentPaths(directory).exports, `${manifest.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
|
|
1580
|
+
`);
|
|
1581
|
+
const bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size;
|
|
1582
|
+
console.log(`Exported backup ${manifest.backupId} (${bytes} bytes) to ${dest}`);
|
|
1583
|
+
console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
|
|
1584
|
+
if ((await stat(backupPath)).dev === destinationDevice) {
|
|
1585
|
+
console.log("This export is on the same filesystem as the deployment and cannot authorize `remove --purge-data`. Copy it to a mounted backup disk or another host, then export again there.");
|
|
1586
|
+
} else {
|
|
1587
|
+
console.log("Verified export recorded. It can authorize `remove --purge-data --yes` while this backup remains fresh.");
|
|
1588
|
+
}
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
await rm(dest, { recursive: true, force: true });
|
|
1591
|
+
throw error;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
async function backupCommand(directory, positionals, flags) {
|
|
1595
|
+
const subcommand = positionals[0];
|
|
1596
|
+
if (!subcommand)
|
|
1597
|
+
return await backupCreateCommand(directory);
|
|
1598
|
+
if (subcommand === "list") {
|
|
1599
|
+
if (positionals.length > 1)
|
|
1600
|
+
throw new Error("backup list accepts no arguments.");
|
|
1601
|
+
return await backupListCommand(directory);
|
|
1602
|
+
}
|
|
1603
|
+
if (subcommand === "prune") {
|
|
1604
|
+
if (positionals.length > 1)
|
|
1605
|
+
throw new Error("backup prune accepts no arguments.");
|
|
1606
|
+
return await backupPruneCommand(directory, flags);
|
|
1607
|
+
}
|
|
1608
|
+
if (subcommand === "export") {
|
|
1609
|
+
const backupId = positionals[1];
|
|
1610
|
+
const destination = positionals[2];
|
|
1611
|
+
if (!backupId || !destination)
|
|
1612
|
+
throw new Error("backup export requires a backup ID and a destination directory.");
|
|
1613
|
+
if (positionals.length > 3)
|
|
1614
|
+
throw new Error("backup export accepts a backup ID and a destination directory only.");
|
|
1615
|
+
return await backupExportCommand(directory, backupId, destination);
|
|
1616
|
+
}
|
|
1617
|
+
throw new Error(`Unknown backup subcommand "${subcommand}". Use list, prune, export, or no subcommand to create a backup.`);
|
|
1618
|
+
}
|
|
1140
1619
|
async function updateCommand(directory, flags) {
|
|
1141
1620
|
await ensureDocker();
|
|
1142
1621
|
const loaded = await loadManaged(directory);
|
|
@@ -1145,11 +1624,12 @@ async function updateCommand(directory, flags) {
|
|
|
1145
1624
|
await waitForDeepHealth(loaded.directory, state.mode, secretValues(env));
|
|
1146
1625
|
const targetVersion = stringFlag(flags, "to")?.trim() ?? PACKAGE_VERSION;
|
|
1147
1626
|
if (!isVersion(targetVersion))
|
|
1148
|
-
throw new Error(
|
|
1627
|
+
throw new Error(`--to must be a complete semantic version such as ${PACKAGE_VERSION}.`);
|
|
1149
1628
|
if (targetVersion === state.appVersion)
|
|
1150
1629
|
throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
|
|
1151
1630
|
const targetImage = imageFor(targetVersion);
|
|
1152
1631
|
const targetDigest = await pullImage(targetImage);
|
|
1632
|
+
await assertSupportedHostArchitecture(targetImage);
|
|
1153
1633
|
const oldEnv = { ...env };
|
|
1154
1634
|
const pending = {
|
|
1155
1635
|
id: id("update"),
|
|
@@ -1228,6 +1708,8 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
1228
1708
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1229
1709
|
if (manifest.imageDigest)
|
|
1230
1710
|
await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
1711
|
+
await assertSupportedHostArchitecture(manifest.image);
|
|
1712
|
+
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
|
|
1231
1713
|
const pending = {
|
|
1232
1714
|
id: id("restore"),
|
|
1233
1715
|
operation: "restore",
|
|
@@ -1274,6 +1756,8 @@ async function rollbackCommand(directory, flags) {
|
|
|
1274
1756
|
}
|
|
1275
1757
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1276
1758
|
await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
|
|
1759
|
+
await assertSupportedHostArchitecture(point.image);
|
|
1760
|
+
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
|
|
1277
1761
|
const pending = {
|
|
1278
1762
|
id: id("rollback"),
|
|
1279
1763
|
operation: "rollback",
|
|
@@ -1540,6 +2024,7 @@ async function adoptCommand(positionals, flags) {
|
|
|
1540
2024
|
throw new Error(`V1 OR3_IMAGE ${sourceEnv.OR3_IMAGE} does not match the published image for ${release.or3Version}.`);
|
|
1541
2025
|
}
|
|
1542
2026
|
await pullImage(image);
|
|
2027
|
+
await assertSupportedHostArchitecture(image);
|
|
1543
2028
|
const sourceVolume = await readSourceVolume(sourceDirectory);
|
|
1544
2029
|
const email = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
1545
2030
|
const password = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
@@ -1586,9 +2071,12 @@ async function adoptCommand(positionals, flags) {
|
|
|
1586
2071
|
throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
|
|
1587
2072
|
}
|
|
1588
2073
|
await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
|
|
1589
|
-
await writeSecure(join(targetDirectory, ".or3-initial-credentials"),
|
|
1590
|
-
|
|
1591
|
-
|
|
2074
|
+
await writeSecure(join(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
2075
|
+
bootstrapEmail: email,
|
|
2076
|
+
bootstrapPassword: password,
|
|
2077
|
+
adminUsername: email,
|
|
2078
|
+
adminPassword: password
|
|
2079
|
+
}));
|
|
1592
2080
|
const digest = await imageDigest(image);
|
|
1593
2081
|
const state = stateFromEnv(targetDirectory, targetEnv, mode, "adopt", digest);
|
|
1594
2082
|
await markPending(targetDirectory, state, {
|
|
@@ -1638,7 +2126,7 @@ password=${password}
|
|
|
1638
2126
|
await writeSecure(join(sourceBackupDir, "manifest.json"), `${JSON.stringify(sourceManifest, null, 2)}
|
|
1639
2127
|
`);
|
|
1640
2128
|
await updatePending(targetDirectory, state, { backupId: sourceBackupId });
|
|
1641
|
-
await
|
|
2129
|
+
await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
|
|
1642
2130
|
await startProject(targetDirectory, mode, targetEnv);
|
|
1643
2131
|
await clearPending(targetDirectory, state);
|
|
1644
2132
|
console.log(`Adopted ${sourceDirectory} into ${targetDirectory} at OR3 ${release.or3Version}.`);
|
|
@@ -1662,6 +2150,448 @@ password=${password}
|
|
|
1662
2150
|
${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "The original V1 deployment was restarted and deeply healthy. "}Managed files remain at ${targetDirectory}.${sourceBackupDir ? ` Source backup: ${sourceBackupDir}.` : ""}`);
|
|
1663
2151
|
}
|
|
1664
2152
|
}
|
|
2153
|
+
async function resolveResetPasswords(flags) {
|
|
2154
|
+
const ownerPassword = stringFlag(flags, "owner-password");
|
|
2155
|
+
const adminPassword = stringFlag(flags, "admin-password");
|
|
2156
|
+
if (ownerPassword !== undefined || adminPassword !== undefined) {
|
|
2157
|
+
if (ownerPassword === undefined || adminPassword === undefined) {
|
|
2158
|
+
throw new Error("Use both --owner-password and --admin-password together so a reset never applies one credential without the other.");
|
|
2159
|
+
}
|
|
2160
|
+
validatePassword(ownerPassword);
|
|
2161
|
+
validatePassword(adminPassword);
|
|
2162
|
+
return { ownerPassword, adminPassword };
|
|
2163
|
+
}
|
|
2164
|
+
if (!input.isTTY || !output.isTTY) {
|
|
2165
|
+
throw new Error("--owner-password and --admin-password are required in a non-interactive session. Credentials are never generated automatically.");
|
|
2166
|
+
}
|
|
2167
|
+
const prompt = readline.createInterface({ input, output });
|
|
2168
|
+
try {
|
|
2169
|
+
const owner = (await prompt.question("New owner (basic auth) password: ")).trim();
|
|
2170
|
+
validatePassword(owner);
|
|
2171
|
+
const admin = (await prompt.question("New admin password: ")).trim();
|
|
2172
|
+
validatePassword(admin);
|
|
2173
|
+
return { ownerPassword: owner, adminPassword: admin };
|
|
2174
|
+
} finally {
|
|
2175
|
+
prompt.close();
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
function buildCredentialsResetScript(input2) {
|
|
2179
|
+
const authDbPath = input2.authDbPath ?? "/data/auth.sqlite";
|
|
2180
|
+
const adminCredentialsPath = input2.adminCredentialsPath ?? "/data/admin/admin-credentials.json";
|
|
2181
|
+
return `const fs = require('fs');
|
|
2182
|
+
const path = require('path');
|
|
2183
|
+
const candidates = [process.cwd(), '/app'];
|
|
2184
|
+
const resolveModule = (name) => {
|
|
2185
|
+
for (const root of candidates) {
|
|
2186
|
+
const resolved = path.join(root, '.output/server/node_modules', name);
|
|
2187
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
2188
|
+
}
|
|
2189
|
+
return null;
|
|
2190
|
+
};
|
|
2191
|
+
let Database;
|
|
2192
|
+
try {
|
|
2193
|
+
const betterSqlite3Path = resolveModule('better-sqlite3');
|
|
2194
|
+
if (!betterSqlite3Path) throw new Error('better-sqlite3 directory not found');
|
|
2195
|
+
Database = require(betterSqlite3Path);
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
console.error('OR3 credentials reset failed: better-sqlite3 is not available in this image, so the auth database could not be updated. Nothing was changed. Update the OR3 image to a release that bundles it, then retry.');
|
|
2198
|
+
process.exit(1);
|
|
2199
|
+
}
|
|
2200
|
+
let bcrypt;
|
|
2201
|
+
try {
|
|
2202
|
+
// bcryptjs 2.x ships a broken "exports" map (require -> missing umd/index.js),
|
|
2203
|
+
// so it must be loaded by explicit absolute path, like the server bundle does.
|
|
2204
|
+
const bcryptPath = resolveModule('bcryptjs/index.js');
|
|
2205
|
+
if (!bcryptPath) throw new Error('bcryptjs directory not found');
|
|
2206
|
+
bcrypt = require(bcryptPath);
|
|
2207
|
+
} catch (error) {
|
|
2208
|
+
console.error('OR3 credentials reset failed: bcryptjs is not available in this image, so new password hashes could not be computed. Nothing was changed. Update the OR3 image to a release that bundles it, then retry.');
|
|
2209
|
+
process.exit(1);
|
|
2210
|
+
}
|
|
2211
|
+
const ownerEmail = process.env.OR3_RESET_OWNER_EMAIL;
|
|
2212
|
+
const ownerPassword = process.env.OR3_RESET_OWNER_PASSWORD;
|
|
2213
|
+
const adminUsername = process.env.OR3_RESET_ADMIN_USERNAME;
|
|
2214
|
+
const adminPassword = process.env.OR3_RESET_ADMIN_PASSWORD;
|
|
2215
|
+
if (!ownerEmail || !ownerPassword || !adminUsername || !adminPassword) {
|
|
2216
|
+
console.error('OR3 credentials reset failed: required environment values were not supplied to the reset script. Nothing was changed.');
|
|
2217
|
+
process.exit(1);
|
|
2218
|
+
}
|
|
2219
|
+
const ownerHash = bcrypt.hashSync(ownerPassword, 12);
|
|
2220
|
+
const adminHash = bcrypt.hashSync(adminPassword, 12);
|
|
2221
|
+
const now = Date.now();
|
|
2222
|
+
const db = new Database(${JSON.stringify(authDbPath)});
|
|
2223
|
+
db.pragma('busy_timeout = 10000');
|
|
2224
|
+
const account = db.prepare('SELECT id FROM basic_auth_accounts WHERE email = ?').get(ownerEmail);
|
|
2225
|
+
if (!account) {
|
|
2226
|
+
console.error('OR3 credentials reset failed: no Basic Auth account matches OR3_BASIC_AUTH_BOOTSTRAP_EMAIL. Nothing was changed.');
|
|
2227
|
+
process.exit(1);
|
|
2228
|
+
}
|
|
2229
|
+
const adminCredentialsPath = ${JSON.stringify(adminCredentialsPath)};
|
|
2230
|
+
const previousCredentials = (() => {
|
|
2231
|
+
try { return fs.readFileSync(adminCredentialsPath, 'utf8'); } catch { return null; }
|
|
2232
|
+
})();
|
|
2233
|
+
let credentials;
|
|
2234
|
+
try {
|
|
2235
|
+
credentials = previousCredentials ? JSON.parse(previousCredentials) : { created_at: new Date().toISOString() };
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
console.error('OR3 credentials reset failed: the admin credentials file is corrupt. Nothing was changed.');
|
|
2238
|
+
process.exit(1);
|
|
2239
|
+
}
|
|
2240
|
+
credentials.username = adminUsername;
|
|
2241
|
+
credentials.password_hash_bcrypt = adminHash;
|
|
2242
|
+
credentials.updated_at = new Date().toISOString();
|
|
2243
|
+
try {
|
|
2244
|
+
fs.writeFileSync(adminCredentialsPath, JSON.stringify(credentials, null, 2) + '\\n', { mode: 0o600 });
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
console.error('OR3 credentials reset failed: could not update the admin credentials file at ' + adminCredentialsPath + '. Nothing was changed. ' + (error && error.message ? error.message : String(error)));
|
|
2247
|
+
process.exit(1);
|
|
2248
|
+
}
|
|
2249
|
+
try {
|
|
2250
|
+
db.transaction(() => {
|
|
2251
|
+
db.prepare('UPDATE basic_auth_accounts SET password_hash = ?, token_version = token_version + 1, updated_at = ? WHERE id = ?').run(ownerHash, now, account.id);
|
|
2252
|
+
db.prepare('UPDATE basic_auth_sessions SET revoked_at = COALESCE(revoked_at, ?), rotation_grace_until = NULL, rotation_grace_refresh_token = NULL WHERE account_id = ?').run(now, account.id);
|
|
2253
|
+
})();
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
if (previousCredentials !== null) {
|
|
2256
|
+
try { fs.writeFileSync(adminCredentialsPath, previousCredentials, { mode: 0o600 }); } catch {}
|
|
2257
|
+
} else {
|
|
2258
|
+
try { fs.rmSync(adminCredentialsPath, { force: true }); } catch {}
|
|
2259
|
+
}
|
|
2260
|
+
console.error('OR3 credentials reset failed: the authentication database rejected the update. Any partial change was rolled back. ' + (error && error.message ? error.message : String(error)));
|
|
2261
|
+
process.exit(1);
|
|
2262
|
+
}
|
|
2263
|
+
db.close();
|
|
2264
|
+
console.log('credentials-reset: owner hash, sessions, and admin credentials updated.');
|
|
2265
|
+
`;
|
|
2266
|
+
}
|
|
2267
|
+
function credentialResetValues(env) {
|
|
2268
|
+
const ownerEmail = env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
2269
|
+
const ownerPassword = env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
2270
|
+
const adminUsername = env.OR3_ADMIN_USERNAME;
|
|
2271
|
+
const adminPassword = env.OR3_ADMIN_PASSWORD;
|
|
2272
|
+
if (!ownerEmail || !ownerPassword || !adminUsername || !adminPassword) {
|
|
2273
|
+
throw new Error("Credential recovery data is incomplete. Run doctor and restore a backup rather than guessing credentials.");
|
|
2274
|
+
}
|
|
2275
|
+
return { ownerEmail, ownerPassword, adminUsername, adminPassword };
|
|
2276
|
+
}
|
|
2277
|
+
var CREDENTIALS_VERIFY_SCRIPT = `
|
|
2278
|
+
const request = async (path, body) => {
|
|
2279
|
+
const response = await fetch('http://127.0.0.1:3000' + path, {
|
|
2280
|
+
method: 'POST',
|
|
2281
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
2282
|
+
body: JSON.stringify(body),
|
|
2283
|
+
signal: AbortSignal.timeout(30000),
|
|
2284
|
+
});
|
|
2285
|
+
if (!response.ok) throw new Error(path + ' returned HTTP ' + response.status);
|
|
2286
|
+
};
|
|
2287
|
+
Promise.all([
|
|
2288
|
+
request('/api/basic-auth/sign-in', { email: process.env.OR3_RESET_OWNER_EMAIL, password: process.env.OR3_RESET_OWNER_PASSWORD }),
|
|
2289
|
+
request('/api/admin/auth/login', { username: process.env.OR3_RESET_ADMIN_USERNAME, password: process.env.OR3_RESET_ADMIN_PASSWORD }),
|
|
2290
|
+
]).catch((error) => { console.error('Credential verification failed: ' + error.message); process.exit(1); });
|
|
2291
|
+
`;
|
|
2292
|
+
async function runCredentialsResetScript(directory, mode, values) {
|
|
2293
|
+
const script = buildCredentialsResetScript(values);
|
|
2294
|
+
const result = await run("docker", [
|
|
2295
|
+
...composeArgs(directory, mode, [
|
|
2296
|
+
"exec",
|
|
2297
|
+
"-T",
|
|
2298
|
+
"-e",
|
|
2299
|
+
`OR3_RESET_OWNER_EMAIL=${values.ownerEmail}`,
|
|
2300
|
+
"-e",
|
|
2301
|
+
`OR3_RESET_OWNER_PASSWORD=${values.ownerPassword}`,
|
|
2302
|
+
"-e",
|
|
2303
|
+
`OR3_RESET_ADMIN_USERNAME=${values.adminUsername}`,
|
|
2304
|
+
"-e",
|
|
2305
|
+
`OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
|
|
2306
|
+
"or3",
|
|
2307
|
+
CONTAINER_NODE,
|
|
2308
|
+
"-e",
|
|
2309
|
+
script
|
|
2310
|
+
])
|
|
2311
|
+
], directory);
|
|
2312
|
+
if (!result.ok)
|
|
2313
|
+
throw new Error(`${result.command}
|
|
2314
|
+
${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
|
|
2315
|
+
}
|
|
2316
|
+
async function verifyCredentialsInsideContainer(directory, mode, values) {
|
|
2317
|
+
const result = await run("docker", [
|
|
2318
|
+
...composeArgs(directory, mode, [
|
|
2319
|
+
"exec",
|
|
2320
|
+
"-T",
|
|
2321
|
+
"-e",
|
|
2322
|
+
`OR3_RESET_OWNER_EMAIL=${values.ownerEmail}`,
|
|
2323
|
+
"-e",
|
|
2324
|
+
`OR3_RESET_OWNER_PASSWORD=${values.ownerPassword}`,
|
|
2325
|
+
"-e",
|
|
2326
|
+
`OR3_RESET_ADMIN_USERNAME=${values.adminUsername}`,
|
|
2327
|
+
"-e",
|
|
2328
|
+
`OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
|
|
2329
|
+
"or3",
|
|
2330
|
+
CONTAINER_NODE,
|
|
2331
|
+
"-e",
|
|
2332
|
+
CREDENTIALS_VERIFY_SCRIPT
|
|
2333
|
+
])
|
|
2334
|
+
], directory);
|
|
2335
|
+
if (!result.ok)
|
|
2336
|
+
throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
|
|
2337
|
+
}
|
|
2338
|
+
async function applyCredentialReset(directory, state, nextEnv) {
|
|
2339
|
+
const values = credentialResetValues(nextEnv);
|
|
2340
|
+
const running = await run("docker", [...composeArgs(directory, state.mode, ["ps", "-q", "or3"])], directory);
|
|
2341
|
+
if (!running.ok || !running.stdout.trim()) {
|
|
2342
|
+
await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
|
|
2343
|
+
await startProject(directory, state.mode, nextEnv);
|
|
2344
|
+
}
|
|
2345
|
+
await runCredentialsResetScript(directory, state.mode, values);
|
|
2346
|
+
await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
|
|
2347
|
+
const initialCredentials = join(directory, ".or3-initial-credentials");
|
|
2348
|
+
if (await fileExists(initialCredentials)) {
|
|
2349
|
+
await writeSecure(initialCredentials, serializeInitialCredentials({
|
|
2350
|
+
bootstrapEmail: values.ownerEmail,
|
|
2351
|
+
bootstrapPassword: values.ownerPassword,
|
|
2352
|
+
adminUsername: values.adminUsername,
|
|
2353
|
+
adminPassword: values.adminPassword
|
|
2354
|
+
}));
|
|
2355
|
+
}
|
|
2356
|
+
await stopProject(directory, state.mode);
|
|
2357
|
+
await startProject(directory, state.mode, nextEnv);
|
|
2358
|
+
await verifyCredentialsInsideContainer(directory, state.mode, values);
|
|
2359
|
+
}
|
|
2360
|
+
async function credentialsResetCommand(directory, flags) {
|
|
2361
|
+
if (!boolFlag(flags, "yes"))
|
|
2362
|
+
throw new Error("Credentials reset changes the owner password, revokes all app sessions, and changes the admin password. Re-run with --yes after confirming the impact.");
|
|
2363
|
+
await ensureDocker();
|
|
2364
|
+
const loaded = await loadManaged(directory);
|
|
2365
|
+
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2366
|
+
assertNoPending(loaded.state);
|
|
2367
|
+
const ownerEmail = loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
2368
|
+
const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
|
|
2369
|
+
if (!ownerEmail)
|
|
2370
|
+
throw new Error("OR3_BASIC_AUTH_BOOTSTRAP_EMAIL is missing from .env, so the owner account cannot be reset.");
|
|
2371
|
+
if (!adminUsername)
|
|
2372
|
+
throw new Error("OR3_ADMIN_USERNAME is missing from .env, so the admin password cannot be reset.");
|
|
2373
|
+
const { ownerPassword, adminPassword } = await resolveResetPasswords(flags);
|
|
2374
|
+
const nextEnv = {
|
|
2375
|
+
...loaded.env,
|
|
2376
|
+
OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: ownerPassword,
|
|
2377
|
+
OR3_ADMIN_PASSWORD: adminPassword,
|
|
2378
|
+
OR3_ADMIN_JWT_SECRET: randomSecret()
|
|
2379
|
+
};
|
|
2380
|
+
const pending = {
|
|
2381
|
+
id: id("credentials-reset"),
|
|
2382
|
+
operation: "credentials-reset",
|
|
2383
|
+
startedAt: now(),
|
|
2384
|
+
message: "Resetting owner and admin credentials",
|
|
2385
|
+
credentialReset: { nextEnv }
|
|
2386
|
+
};
|
|
2387
|
+
await markPending(loaded.directory, loaded.state, pending);
|
|
2388
|
+
try {
|
|
2389
|
+
await applyCredentialReset(loaded.directory, loaded.state, nextEnv);
|
|
2390
|
+
await clearPending(loaded.directory, loaded.state);
|
|
2391
|
+
console.log("Owner and admin credentials are now separate. The old owner password and all app sessions were revoked.");
|
|
2392
|
+
console.log("Admin session cookies were invalidated by rotating OR3_ADMIN_JWT_SECRET. New passwords were written only to protected files; sign in again with them.");
|
|
2393
|
+
} catch (error) {
|
|
2394
|
+
loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(nextEnv));
|
|
2395
|
+
await writeState(loaded.directory, loaded.state);
|
|
2396
|
+
throw new Error(`${loaded.state.lastError}
|
|
2397
|
+
Credential reset is recoverable: run "npx @or3/cloud recover" to replay the intended protected operation.`);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
async function credentialsCommand(directory, positionals, flags) {
|
|
2401
|
+
const subcommand = positionals[0];
|
|
2402
|
+
if (subcommand !== "reset") {
|
|
2403
|
+
throw new Error(`Unknown credentials subcommand "${subcommand ?? ""}". Use "npx @or3/cloud credentials reset --yes".`);
|
|
2404
|
+
}
|
|
2405
|
+
if (positionals.length > 1)
|
|
2406
|
+
throw new Error("credentials reset accepts no arguments.");
|
|
2407
|
+
return await credentialsResetCommand(directory, flags);
|
|
2408
|
+
}
|
|
2409
|
+
async function statusCommand(directory) {
|
|
2410
|
+
const loaded = await loadManaged(directory);
|
|
2411
|
+
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2412
|
+
const { state } = loaded;
|
|
2413
|
+
console.log(`OR3 Cloud deployment: ${loaded.directory}`);
|
|
2414
|
+
console.log(` mode: ${state.mode}`);
|
|
2415
|
+
console.log(` version: ${state.appVersion}`);
|
|
2416
|
+
if (state.domain)
|
|
2417
|
+
console.log(` domain: ${state.domain}`);
|
|
2418
|
+
console.log(` port: ${state.port}`);
|
|
2419
|
+
console.log(` image digest: ${state.imageDigest}`);
|
|
2420
|
+
console.log(` last successful operation: ${state.lastSuccessfulOperation} (${state.updatedAt})`);
|
|
2421
|
+
if (state.incompleteOperation)
|
|
2422
|
+
console.log(` incomplete operation: ${state.incompleteOperation.operation} (${state.incompleteOperation.id})`);
|
|
2423
|
+
if (state.lastError)
|
|
2424
|
+
console.log(` last error: ${state.lastError}`);
|
|
2425
|
+
console.log();
|
|
2426
|
+
const ps = await run("docker", [...composeArgs(directory, state.mode, ["ps"])], directory);
|
|
2427
|
+
if (ps.ok) {
|
|
2428
|
+
console.log(ps.stdout.trim());
|
|
2429
|
+
} else {
|
|
2430
|
+
console.log(`Could not list containers: ${ps.stderr}`);
|
|
2431
|
+
}
|
|
2432
|
+
const health = await probeDeepHealth(directory, state.mode);
|
|
2433
|
+
if (health === "ok")
|
|
2434
|
+
console.log("Deep health: OK");
|
|
2435
|
+
else if (health === "degraded")
|
|
2436
|
+
console.log("Deep health: DEGRADED (the container is running but /api/health?deep=true is failing).");
|
|
2437
|
+
else
|
|
2438
|
+
console.log("Deep health: unreachable (the or3 container is not running).");
|
|
2439
|
+
await printMaintenanceSummary(directory, state.mode);
|
|
2440
|
+
}
|
|
2441
|
+
async function printMaintenanceSummary(directory, mode) {
|
|
2442
|
+
const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", CONTAINER_NODE, "-e", MAINTENANCE_SCRIPT])], directory);
|
|
2443
|
+
if (!result.ok || !result.stdout.trim())
|
|
2444
|
+
return;
|
|
2445
|
+
try {
|
|
2446
|
+
const maintenance = JSON.parse(result.stdout.trim());
|
|
2447
|
+
if (!maintenance.enabled)
|
|
2448
|
+
return;
|
|
2449
|
+
const state = maintenance.state ?? "idle";
|
|
2450
|
+
const line = `Sync history maintenance: ${state}${maintenance.lastRun ? ` (last run ${maintenance.lastRun})` : ""}${maintenance.backlog !== undefined ? `, backlog ${maintenance.backlog}` : ""}`;
|
|
2451
|
+
if (state === "failed") {
|
|
2452
|
+
console.log(`⚠ ${line}${maintenance.lastError ? `: ${maintenance.lastError}` : ""}`);
|
|
2453
|
+
} else {
|
|
2454
|
+
console.log(` ${line}`);
|
|
2455
|
+
}
|
|
2456
|
+
} catch {}
|
|
2457
|
+
}
|
|
2458
|
+
async function logsCommand(directory, flags, positionals) {
|
|
2459
|
+
const loaded = await loadManaged(directory);
|
|
2460
|
+
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2461
|
+
if (positionals.length > 1)
|
|
2462
|
+
throw new Error("logs accepts at most one service name.");
|
|
2463
|
+
const tailValue = stringFlag(flags, "tail") ?? "200";
|
|
2464
|
+
const tail = Number(tailValue);
|
|
2465
|
+
if (!Number.isInteger(tail) || tail < 1)
|
|
2466
|
+
throw new Error("--tail must be a positive integer.");
|
|
2467
|
+
const service = positionals[0];
|
|
2468
|
+
const args = composeArgs(directory, loaded.state.mode, [
|
|
2469
|
+
"logs",
|
|
2470
|
+
"--no-color",
|
|
2471
|
+
"--tail",
|
|
2472
|
+
String(tail),
|
|
2473
|
+
...service ? [service] : []
|
|
2474
|
+
]);
|
|
2475
|
+
const result = await run("docker", [...args], directory);
|
|
2476
|
+
if (!result.ok)
|
|
2477
|
+
throw new Error(`${result.command}
|
|
2478
|
+
${result.stderr}`);
|
|
2479
|
+
const secrets = secretValues(loaded.env);
|
|
2480
|
+
process.stdout.write(redact(result.stdout, secrets));
|
|
2481
|
+
if (result.stderr)
|
|
2482
|
+
process.stderr.write(redact(result.stderr, secrets));
|
|
2483
|
+
}
|
|
2484
|
+
async function startCommand(directory) {
|
|
2485
|
+
await ensureDocker();
|
|
2486
|
+
const loaded = await loadManaged(directory);
|
|
2487
|
+
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2488
|
+
await startProject(directory, loaded.state.mode, loaded.env);
|
|
2489
|
+
const url = loaded.state.mode === "public" ? `https://${loaded.state.domain}` : `http://127.0.0.1:${loaded.state.port}`;
|
|
2490
|
+
console.log(`OR3 started and is deeply healthy at ${url}.`);
|
|
2491
|
+
}
|
|
2492
|
+
async function stopCommand(directory) {
|
|
2493
|
+
await ensureDocker();
|
|
2494
|
+
const loaded = await loadManaged(directory);
|
|
2495
|
+
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2496
|
+
await stopProject(directory, loaded.state.mode);
|
|
2497
|
+
console.log("OR3 stopped. The data volume, backups, and managed state are retained.");
|
|
2498
|
+
}
|
|
2499
|
+
async function restartCommand(directory) {
|
|
2500
|
+
await ensureDocker();
|
|
2501
|
+
const loaded = await loadManaged(directory);
|
|
2502
|
+
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2503
|
+
await compose(directory, loaded.state.mode, ["restart", "or3"]);
|
|
2504
|
+
await waitForDeepHealth(directory, loaded.state.mode, secretValues(loaded.env));
|
|
2505
|
+
console.log("OR3 restarted and is deeply healthy.");
|
|
2506
|
+
}
|
|
2507
|
+
function purgeVolumesFromState(state) {
|
|
2508
|
+
const volumes = [state.volumeName];
|
|
2509
|
+
if (state.mode === "public") {
|
|
2510
|
+
if (state.caddyDataVolume)
|
|
2511
|
+
volumes.push(state.caddyDataVolume);
|
|
2512
|
+
if (state.caddyConfigVolume)
|
|
2513
|
+
volumes.push(state.caddyConfigVolume);
|
|
2514
|
+
}
|
|
2515
|
+
return volumes;
|
|
2516
|
+
}
|
|
2517
|
+
function assertPurgeBackupFreshness(backups, nowMs) {
|
|
2518
|
+
const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
|
|
2519
|
+
if (!backups.some((backup) => new Date(backup.createdAt).getTime() >= cutoff)) {
|
|
2520
|
+
throw new Error('No backup newer than 24 hours exists for this deployment. Run "npx @or3/cloud backup" and "npx @or3/cloud backup export <backup-id> <destination-dir>" before destroying local data.');
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
async function readBackupExportReceipt(directory, backupId) {
|
|
2524
|
+
try {
|
|
2525
|
+
const receipt = JSON.parse(await readText(join(deploymentPaths(directory).exports, `${backupId}.json`)));
|
|
2526
|
+
if (receipt.schemaVersion !== 1 || receipt.backupId !== backupId || typeof receipt.destination !== "string" || !isAbsolute(receipt.destination) || typeof receipt.destinationDevice !== "number" || !receipt.dataSha256)
|
|
2527
|
+
return;
|
|
2528
|
+
return receipt;
|
|
2529
|
+
} catch {
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
|
|
2534
|
+
assertPurgeBackupFreshness(backups, nowMs);
|
|
2535
|
+
const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
|
|
2536
|
+
for (const backup of backups) {
|
|
2537
|
+
if (new Date(backup.createdAt).getTime() < cutoff)
|
|
2538
|
+
continue;
|
|
2539
|
+
const receipt = await readBackupExportReceipt(directory, backup.backupId);
|
|
2540
|
+
if (!receipt || !await fileExists(receipt.destination))
|
|
2541
|
+
continue;
|
|
2542
|
+
try {
|
|
2543
|
+
const exported = await readManifest(receipt.destination);
|
|
2544
|
+
const sourceDevice = (await stat(backup.path)).dev;
|
|
2545
|
+
const destinationDevice = (await stat(receipt.destination)).dev;
|
|
2546
|
+
if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
|
|
2547
|
+
return receipt;
|
|
2548
|
+
} catch {}
|
|
2549
|
+
}
|
|
2550
|
+
throw new Error("No fresh checksum-verified backup export on another filesystem is available. Run `npx @or3/cloud backup export <backup-id> <new-directory-on-a-mounted-backup-disk>` before purging local data.");
|
|
2551
|
+
}
|
|
2552
|
+
async function removeCommand(directory, flags) {
|
|
2553
|
+
if (boolFlag(flags, "purge-data") && !boolFlag(flags, "yes")) {
|
|
2554
|
+
throw new Error("--purge-data deletes the data volume, every backup, and the managed files. Re-run with --purge-data --yes after confirming the data-loss boundary.");
|
|
2555
|
+
}
|
|
2556
|
+
await ensureDocker();
|
|
2557
|
+
const loaded = await loadManaged(directory);
|
|
2558
|
+
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2559
|
+
assertNoPending(loaded.state);
|
|
2560
|
+
const { state } = loaded;
|
|
2561
|
+
if (!boolFlag(flags, "purge-data")) {
|
|
2562
|
+
await compose(directory, state.mode, ["down"]);
|
|
2563
|
+
console.log(`Removed runtime containers and network. Data volume, backups, configuration, and managed state are retained at ${directory}. Re-run \`npx @or3/cloud start\` to restore service.`);
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
const exportReceipt = await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
|
|
2567
|
+
const volumes = purgeVolumesFromState(state);
|
|
2568
|
+
const managedFiles = [".env", ".or3-cloud", "compose.yaml", ...state.mode === "public" ? ["compose.public.yaml", "Caddyfile"] : [], ".or3-initial-credentials"];
|
|
2569
|
+
console.log("Removing exactly these targets:");
|
|
2570
|
+
for (const volume of volumes)
|
|
2571
|
+
console.log(` docker volume ${volume}`);
|
|
2572
|
+
for (const file of managedFiles)
|
|
2573
|
+
console.log(` ${join(directory, file)}`);
|
|
2574
|
+
await compose(directory, state.mode, ["down"]);
|
|
2575
|
+
for (const volume of volumes) {
|
|
2576
|
+
const result = await run("docker", ["volume", "rm", volume], directory);
|
|
2577
|
+
if (!result.ok)
|
|
2578
|
+
throw new Error(`${result.command}
|
|
2579
|
+
${result.stderr}`);
|
|
2580
|
+
}
|
|
2581
|
+
for (const file of managedFiles) {
|
|
2582
|
+
await rm(join(directory, file), { recursive: true, force: true });
|
|
2583
|
+
}
|
|
2584
|
+
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
|
+
}
|
|
2586
|
+
async function probeDeepHealth(directory, mode) {
|
|
2587
|
+
const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", CONTAINER_NODE, "-e", HEALTH_SCRIPT])], directory);
|
|
2588
|
+
if (result.ok)
|
|
2589
|
+
return "ok";
|
|
2590
|
+
const ps = await run("docker", [...composeArgs(directory, mode, ["ps", "-q", "or3"])], directory);
|
|
2591
|
+
if (ps.ok && ps.stdout.trim())
|
|
2592
|
+
return "degraded";
|
|
2593
|
+
return "unreachable";
|
|
2594
|
+
}
|
|
1665
2595
|
async function main(argv = process.argv.slice(2)) {
|
|
1666
2596
|
const [command = "help", ...rest] = argv;
|
|
1667
2597
|
if (command === "--help" || command === "help")
|
|
@@ -1678,17 +2608,31 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1678
2608
|
if (command === "update")
|
|
1679
2609
|
return await updateCommand(process.cwd(), parsed.flags);
|
|
1680
2610
|
if (command === "backup")
|
|
1681
|
-
return await backupCommand(process.cwd());
|
|
2611
|
+
return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
|
|
1682
2612
|
if (command === "restore")
|
|
1683
2613
|
return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
1684
2614
|
if (command === "rollback")
|
|
1685
2615
|
return await rollbackCommand(process.cwd(), parsed.flags);
|
|
2616
|
+
if (command === "credentials")
|
|
2617
|
+
return await credentialsCommand(process.cwd(), parsed.positionals, parsed.flags);
|
|
1686
2618
|
if (command === "doctor")
|
|
1687
2619
|
return await doctorCommand(process.cwd());
|
|
1688
2620
|
if (command === "recover")
|
|
1689
2621
|
return await recoverCommand(process.cwd());
|
|
1690
2622
|
if (command === "adopt")
|
|
1691
2623
|
return await adoptCommand(parsed.positionals, parsed.flags);
|
|
2624
|
+
if (command === "status")
|
|
2625
|
+
return await statusCommand(process.cwd());
|
|
2626
|
+
if (command === "logs")
|
|
2627
|
+
return await logsCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
2628
|
+
if (command === "start")
|
|
2629
|
+
return await startCommand(process.cwd());
|
|
2630
|
+
if (command === "stop")
|
|
2631
|
+
return await stopCommand(process.cwd());
|
|
2632
|
+
if (command === "restart")
|
|
2633
|
+
return await restartCommand(process.cwd());
|
|
2634
|
+
if (command === "remove")
|
|
2635
|
+
return await removeCommand(process.cwd(), parsed.flags);
|
|
1692
2636
|
throw new Error(`Unknown command "${command}". Run npx @or3/cloud --help.`);
|
|
1693
2637
|
} catch (error) {
|
|
1694
2638
|
console.error(`
|
|
@@ -1701,16 +2645,27 @@ if (invokedAsCli || process.argv[1] && resolve(process.argv[1]) === resolve(file
|
|
|
1701
2645
|
await main();
|
|
1702
2646
|
}
|
|
1703
2647
|
export {
|
|
2648
|
+
validatePassword,
|
|
2649
|
+
supportedImageArchitectures,
|
|
1704
2650
|
stateFromEnv,
|
|
2651
|
+
serializeInitialCredentials,
|
|
1705
2652
|
serializeEnv,
|
|
2653
|
+
selectPruneTargets,
|
|
2654
|
+
requiredArchiveSpace,
|
|
1706
2655
|
redact,
|
|
2656
|
+
purgeVolumesFromState,
|
|
1707
2657
|
parseFlags,
|
|
1708
2658
|
parseEnv,
|
|
1709
2659
|
isVersion,
|
|
1710
2660
|
checkResolvedLoopbackBinding,
|
|
1711
2661
|
buildEnv,
|
|
2662
|
+
buildCredentialsResetScript,
|
|
1712
2663
|
assertSupportedSourceCompose,
|
|
1713
2664
|
assertSupportedSource,
|
|
2665
|
+
assertSupportedArchitecture,
|
|
2666
|
+
assertRemovableArtifactName,
|
|
2667
|
+
assertPurgeBackupFreshness,
|
|
2668
|
+
assertEnoughFreeSpace,
|
|
1714
2669
|
assertCommandFlags,
|
|
1715
2670
|
assertBackupMatchesDeployment,
|
|
1716
2671
|
PACKAGE_VERSION,
|