@or3/cloud 0.1.12 → 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 +1062 -79
- package/package.json +4 -3
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.");
|
|
@@ -132,6 +154,30 @@ function parseFlags(argv) {
|
|
|
132
154
|
}
|
|
133
155
|
return { positionals, flags };
|
|
134
156
|
}
|
|
157
|
+
var COMMAND_FLAGS = {
|
|
158
|
+
init: ["local", "public", "domain", "admin-email", "admin-password", "admin-password-file", "port"],
|
|
159
|
+
update: ["to"],
|
|
160
|
+
backup: ["keep", "force", "yes"],
|
|
161
|
+
restore: ["yes"],
|
|
162
|
+
rollback: ["yes"],
|
|
163
|
+
doctor: [],
|
|
164
|
+
recover: [],
|
|
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"]
|
|
173
|
+
};
|
|
174
|
+
function assertCommandFlags(command, flags) {
|
|
175
|
+
const allowed = new Set(["help", ...COMMAND_FLAGS[command] ?? []]);
|
|
176
|
+
const unknown = Object.keys(flags).filter((flag) => !allowed.has(flag));
|
|
177
|
+
if (unknown.length > 0) {
|
|
178
|
+
throw new Error(`Unknown option${unknown.length === 1 ? "" : "s"} for ${command}: ${unknown.map((flag) => `--${flag}`).join(", ")}. Run npx @or3/cloud ${command} --help.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
135
181
|
function stringFlag(flags, key) {
|
|
136
182
|
const value = flags[key];
|
|
137
183
|
return typeof value === "string" ? value : undefined;
|
|
@@ -197,14 +243,22 @@ async function run(command, args, cwd) {
|
|
|
197
243
|
encoding: "utf8",
|
|
198
244
|
env: process.env
|
|
199
245
|
});
|
|
200
|
-
return {
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
stdout: result.stdout ?? "",
|
|
249
|
+
stderr: result.stderr ?? "",
|
|
250
|
+
command: printable,
|
|
251
|
+
exitCode: 0
|
|
252
|
+
};
|
|
201
253
|
} catch (error) {
|
|
202
254
|
const failure = error;
|
|
255
|
+
const stdout = failure.stdout ?? "";
|
|
203
256
|
return {
|
|
204
257
|
ok: false,
|
|
258
|
+
stdout,
|
|
205
259
|
command: printable,
|
|
206
260
|
exitCode: typeof failure.code === "number" ? failure.code : null,
|
|
207
|
-
stderr: redact(failure.stderr
|
|
261
|
+
stderr: redact(failure.stderr || stdout || String(error))
|
|
208
262
|
};
|
|
209
263
|
}
|
|
210
264
|
}
|
|
@@ -239,6 +293,24 @@ async function fileExists(path) {
|
|
|
239
293
|
async function readText(path) {
|
|
240
294
|
return readFile(path, "utf8");
|
|
241
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
|
+
}
|
|
242
314
|
function parseEnv(text) {
|
|
243
315
|
const values = {};
|
|
244
316
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -264,6 +336,11 @@ function serializeEnvValue(value) {
|
|
|
264
336
|
}
|
|
265
337
|
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
266
338
|
}
|
|
339
|
+
function serializeCredentialValue(value) {
|
|
340
|
+
if (/^[A-Za-z0-9._:@%+=/-]+$/.test(value))
|
|
341
|
+
return value;
|
|
342
|
+
return serializeEnvValue(value);
|
|
343
|
+
}
|
|
267
344
|
function serializeEnv(values) {
|
|
268
345
|
return `${Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${serializeEnvValue(value)}`).join(`
|
|
269
346
|
`)}
|
|
@@ -275,7 +352,8 @@ function deploymentPaths(directory) {
|
|
|
275
352
|
env: join(directory, ".env"),
|
|
276
353
|
state: join(cloud, "state.json"),
|
|
277
354
|
operations: join(cloud, "operations"),
|
|
278
|
-
backups: join(cloud, "backups")
|
|
355
|
+
backups: join(cloud, "backups"),
|
|
356
|
+
exports: join(cloud, "exports")
|
|
279
357
|
};
|
|
280
358
|
}
|
|
281
359
|
async function readState(directory) {
|
|
@@ -320,11 +398,38 @@ function diagnostics(directory, mode) {
|
|
|
320
398
|
const files = `-f ${quote(join(directory, "compose.yaml"))}${mode === "public" ? ` -f ${quote(join(directory, "compose.public.yaml"))}` : ""}`;
|
|
321
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`;
|
|
322
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
|
+
}
|
|
323
425
|
async function compose(directory, mode, command, secrets = []) {
|
|
324
426
|
const result = await run("docker", composeArgs(directory, mode, command), directory);
|
|
325
427
|
if (!result.ok) {
|
|
326
|
-
|
|
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)}
|
|
327
430
|
${redact(result.stderr, secrets)}
|
|
431
|
+
Captured Docker diagnostics:
|
|
432
|
+
${captured}
|
|
328
433
|
Diagnostics: ${diagnostics(directory, mode)}`);
|
|
329
434
|
}
|
|
330
435
|
return result.stdout;
|
|
@@ -385,11 +490,13 @@ async function assertSafeComposeBinding(directory, mode, env) {
|
|
|
385
490
|
}
|
|
386
491
|
}
|
|
387
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";
|
|
388
495
|
async function waitForDeepHealthWithArgs(composeCommand, directory, secrets = []) {
|
|
389
496
|
const deadline = Date.now() + DEEP_HEALTH_TIMEOUT_MS;
|
|
390
497
|
let lastError = "health check did not complete";
|
|
391
498
|
while (Date.now() < deadline) {
|
|
392
|
-
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);
|
|
393
500
|
if (result.ok)
|
|
394
501
|
return;
|
|
395
502
|
lastError = redact(result.stderr, secrets);
|
|
@@ -413,16 +520,26 @@ async function waitForDeepHealth(directory, mode, secrets = []) {
|
|
|
413
520
|
try {
|
|
414
521
|
await waitForDeepHealthWithArgs(composeArgs(directory, mode), directory, secrets);
|
|
415
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));
|
|
416
524
|
throw new Error(`${error instanceof Error ? error.message : String(error)}
|
|
525
|
+
Captured Docker diagnostics:
|
|
526
|
+
${captured}
|
|
417
527
|
Diagnostics: ${diagnostics(directory, mode)}`);
|
|
418
528
|
}
|
|
419
529
|
}
|
|
420
530
|
async function pullImage(image) {
|
|
421
|
-
|
|
531
|
+
const local = await run("docker", ["image", "inspect", image]);
|
|
532
|
+
if (!local.ok && process.env.OR3_CLOUD_SKIP_PULL !== "true") {
|
|
422
533
|
const result = await run("docker", ["pull", image]);
|
|
423
|
-
if (!result.ok)
|
|
424
|
-
|
|
425
|
-
|
|
534
|
+
if (!result.ok) {
|
|
535
|
+
const detail = result.stderr.trim();
|
|
536
|
+
if (/(not found|manifest unknown|pull access denied)/i.test(detail)) {
|
|
537
|
+
throw new Error(`The matching OR3 container image is not published yet: ${image}. This is a release issue, not a problem with your computer. Try again after the image release completes. ${detail}`);
|
|
538
|
+
}
|
|
539
|
+
throw new Error(`Could not download ${image}. Check your internet connection and Docker registry access, then retry. ${detail}`);
|
|
540
|
+
}
|
|
541
|
+
} else if (!local.ok) {
|
|
542
|
+
throw new Error(`${image} is not available locally and OR3_CLOUD_SKIP_PULL=true prevents downloading it.`);
|
|
426
543
|
}
|
|
427
544
|
return imageDigest(image);
|
|
428
545
|
}
|
|
@@ -463,6 +580,50 @@ ${result.stderr}`);
|
|
|
463
580
|
throw new Error(`Could not resolve a digest for ${image}.`);
|
|
464
581
|
return idResult.stdout.trim();
|
|
465
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
|
+
}
|
|
466
627
|
function composeProjectNames(directory) {
|
|
467
628
|
const base = sanitizeName(basename(directory));
|
|
468
629
|
return {
|
|
@@ -487,7 +648,11 @@ function buildEnv(input2) {
|
|
|
487
648
|
SSR_AUTH_ENABLED: "true",
|
|
488
649
|
AUTH_PROVIDER: "basic-auth",
|
|
489
650
|
OR3_AUTH_PROVIDER: "basic-auth",
|
|
651
|
+
OR3_AUTH_REGISTRATION_MODE: "invite_only",
|
|
652
|
+
OR3_AUTH_AUTO_PROVISION: "false",
|
|
490
653
|
OR3_GUEST_ACCESS_ENABLED: "false",
|
|
654
|
+
OR3_PLUGIN_ZIP_INSTALL_ENABLED: "false",
|
|
655
|
+
OR3_ADMIN_ALLOW_REBUILD: "false",
|
|
491
656
|
OR3_BASIC_AUTH_JWT_SECRET: secrets.OR3_BASIC_AUTH_JWT_SECRET ?? randomSecret(),
|
|
492
657
|
OR3_BASIC_AUTH_REFRESH_SECRET: secrets.OR3_BASIC_AUTH_REFRESH_SECRET ?? randomSecret(),
|
|
493
658
|
OR3_BASIC_AUTH_ACCESS_TTL_SECONDS: "900",
|
|
@@ -495,6 +660,7 @@ function buildEnv(input2) {
|
|
|
495
660
|
OR3_BASIC_AUTH_DB_PATH: "/data/auth.sqlite",
|
|
496
661
|
OR3_BASIC_AUTH_BOOTSTRAP_EMAIL: input2.email,
|
|
497
662
|
OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: input2.password,
|
|
663
|
+
OR3_AUTH_INVITE_TOKEN_SECRET: secrets.OR3_AUTH_INVITE_TOKEN_SECRET ?? randomSecret(),
|
|
498
664
|
OR3_SYNC_ENABLED: "true",
|
|
499
665
|
OR3_CLOUD_SYNC_ENABLED: "true",
|
|
500
666
|
OR3_SYNC_PROVIDER: "sqlite",
|
|
@@ -614,6 +780,111 @@ async function startProject(directory, mode, env) {
|
|
|
614
780
|
function backupDirectory(directory, backupId) {
|
|
615
781
|
return join(deploymentPaths(directory).backups, backupId);
|
|
616
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
|
+
}
|
|
617
888
|
function assertDeploymentIdentity(state, env) {
|
|
618
889
|
const expected = {
|
|
619
890
|
OR3_COMPOSE_PROJECT: state.composeProject,
|
|
@@ -631,6 +902,26 @@ function assertDeploymentIdentity(state, env) {
|
|
|
631
902
|
}
|
|
632
903
|
}
|
|
633
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
|
+
}
|
|
634
925
|
function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
|
|
635
926
|
if (manifest.mode !== state.mode || manifest.appVersion === undefined || manifest.image === undefined) {
|
|
636
927
|
throw new Error(`Backup ${manifest.backupId} does not match the managed deployment mode.`);
|
|
@@ -672,69 +963,128 @@ function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
|
|
|
672
963
|
}
|
|
673
964
|
async function sha256File(path) {
|
|
674
965
|
const digest = createHash("sha256");
|
|
675
|
-
|
|
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
|
+
});
|
|
676
972
|
return digest.digest("hex");
|
|
677
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
|
+
}
|
|
678
1049
|
async function volumeArchive(directory, mode, env, backupDir) {
|
|
679
|
-
|
|
680
|
-
await compose(directory, mode, [
|
|
1050
|
+
await streamCommandToFile("docker", composeArgs(directory, mode, [
|
|
681
1051
|
"run",
|
|
682
1052
|
"--rm",
|
|
683
1053
|
"--no-deps",
|
|
684
|
-
"--user",
|
|
685
|
-
"0:0",
|
|
686
|
-
"-v",
|
|
687
|
-
mount,
|
|
688
1054
|
"--entrypoint",
|
|
689
1055
|
"sh",
|
|
690
1056
|
"or3",
|
|
691
1057
|
"-c",
|
|
692
|
-
"
|
|
693
|
-
], secretValues(env));
|
|
1058
|
+
"tar czf - -C /data ."
|
|
1059
|
+
]), join(backupDir, "data.tgz"), directory, secretValues(env));
|
|
694
1060
|
}
|
|
695
1061
|
async function archiveExternalVolume(image, volume, backupDir) {
|
|
696
|
-
|
|
697
|
-
"run",
|
|
698
|
-
"--rm",
|
|
699
|
-
"--user",
|
|
700
|
-
"0:0",
|
|
701
|
-
"-v",
|
|
702
|
-
`${volume}:/source:ro`,
|
|
703
|
-
"-v",
|
|
704
|
-
`${backupDir}:/backup`,
|
|
705
|
-
image,
|
|
706
|
-
"sh",
|
|
707
|
-
"-c",
|
|
708
|
-
"umask 077 && tar czf /backup/data.tgz -C /source . && tar tzf /backup/data.tgz >/dev/null"
|
|
709
|
-
]);
|
|
710
|
-
if (!result.ok)
|
|
711
|
-
throw new Error(`${result.command}
|
|
712
|
-
${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"));
|
|
713
1063
|
}
|
|
714
|
-
async function
|
|
715
|
-
await
|
|
1064
|
+
async function restoreVolumeArchive(directory, mode, env, backupPath) {
|
|
1065
|
+
await streamFileToCommand("docker", composeArgs(directory, mode, [
|
|
716
1066
|
"run",
|
|
717
1067
|
"--rm",
|
|
1068
|
+
"-T",
|
|
718
1069
|
"--no-deps",
|
|
719
|
-
"--user",
|
|
720
|
-
"0:0",
|
|
721
|
-
"-v",
|
|
722
|
-
`${backupPath}:/backup:ro`,
|
|
723
1070
|
"--entrypoint",
|
|
724
1071
|
"sh",
|
|
725
1072
|
"or3",
|
|
726
1073
|
"-c",
|
|
727
|
-
"find /data -mindepth 1 -delete && tar xzf
|
|
728
|
-
], secretValues(env));
|
|
1074
|
+
"find /data -mindepth 1 -delete && tar xzf - -C /data"
|
|
1075
|
+
]), join(backupPath, "data.tgz"), directory, secretValues(env));
|
|
729
1076
|
}
|
|
730
1077
|
async function createBackup(directory, state, env) {
|
|
731
1078
|
await requireImageDigest(state.image, state.imageDigest, "Current deployment");
|
|
732
1079
|
const backupId = id("backup");
|
|
733
1080
|
const backupDir = backupDirectory(directory, backupId);
|
|
734
|
-
|
|
735
|
-
await
|
|
1081
|
+
const volumeSize = await dataVolumeSize(directory, state.mode, env);
|
|
1082
|
+
await assertFreeSpaceForArchive(backupDir, volumeSize, "Backup");
|
|
1083
|
+
let manifestWritten = false;
|
|
736
1084
|
let stopAttempted = false;
|
|
737
1085
|
try {
|
|
1086
|
+
await mkdir(backupDir, { recursive: true, mode: 448 });
|
|
1087
|
+
await chmod(backupDir, 448);
|
|
738
1088
|
stopAttempted = true;
|
|
739
1089
|
await stopProject(directory, state.mode);
|
|
740
1090
|
await volumeArchive(directory, state.mode, env, backupDir);
|
|
@@ -747,6 +1097,7 @@ async function createBackup(directory, state, env) {
|
|
|
747
1097
|
image: state.image,
|
|
748
1098
|
imageDigest: state.imageDigest,
|
|
749
1099
|
dataSha256: await sha256File(join(backupDir, "data.tgz")),
|
|
1100
|
+
dataBytes: volumeSize,
|
|
750
1101
|
configSha256: await sha256File(join(backupDir, "config.env")),
|
|
751
1102
|
mode: state.mode,
|
|
752
1103
|
domain: state.domain,
|
|
@@ -758,7 +1109,21 @@ async function createBackup(directory, state, env) {
|
|
|
758
1109
|
};
|
|
759
1110
|
await writeSecure(join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
760
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
|
+
}
|
|
761
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;
|
|
762
1127
|
} finally {
|
|
763
1128
|
if (stopAttempted) {
|
|
764
1129
|
try {
|
|
@@ -771,7 +1136,7 @@ async function createBackup(directory, state, env) {
|
|
|
771
1136
|
}
|
|
772
1137
|
async function readManifest(backupPath) {
|
|
773
1138
|
const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
|
|
774
|
-
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)) {
|
|
775
1140
|
throw new Error(`Invalid backup manifest at ${backupPath}.`);
|
|
776
1141
|
}
|
|
777
1142
|
const actual = await sha256File(join(backupPath, "data.tgz"));
|
|
@@ -784,6 +1149,28 @@ async function readManifest(backupPath) {
|
|
|
784
1149
|
}
|
|
785
1150
|
return manifest;
|
|
786
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
|
+
}
|
|
787
1174
|
async function restoreBackupData(directory, state, env, backupPath) {
|
|
788
1175
|
const manifest = await readManifest(backupPath);
|
|
789
1176
|
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
@@ -793,26 +1180,14 @@ async function restoreBackupData(directory, state, env, backupPath) {
|
|
|
793
1180
|
assertBackupMatchesDeployment(manifest, backupEnv, state, env);
|
|
794
1181
|
if (manifest.imageDigest)
|
|
795
1182
|
await requireImageDigest(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
1183
|
+
await assertRestoreFreeSpace(directory, state, env, manifest, backupPath);
|
|
796
1184
|
let started = false;
|
|
797
1185
|
let stopAttempted = false;
|
|
798
1186
|
try {
|
|
799
1187
|
stopAttempted = true;
|
|
800
1188
|
await stopProject(directory, state.mode);
|
|
801
1189
|
await copySecure(join(backupPath, "config.env"), deploymentPaths(directory).env);
|
|
802
|
-
await
|
|
803
|
-
"run",
|
|
804
|
-
"--rm",
|
|
805
|
-
"--no-deps",
|
|
806
|
-
"--user",
|
|
807
|
-
"0:0",
|
|
808
|
-
"-v",
|
|
809
|
-
`${backupPath}:/backup:ro`,
|
|
810
|
-
"--entrypoint",
|
|
811
|
-
"sh",
|
|
812
|
-
"or3",
|
|
813
|
-
"-c",
|
|
814
|
-
"find /data -mindepth 1 -delete && tar xzf /backup/data.tgz -C /data"
|
|
815
|
-
], secretValues(backupEnv));
|
|
1190
|
+
await restoreVolumeArchive(directory, state.mode, backupEnv, backupPath);
|
|
816
1191
|
const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
817
1192
|
await startProject(directory, state.mode, restoredEnv);
|
|
818
1193
|
started = true;
|
|
@@ -854,19 +1229,29 @@ Usage:
|
|
|
854
1229
|
npx @or3/cloud init [directory] --local
|
|
855
1230
|
npx @or3/cloud init [directory] --public --domain <hostname>
|
|
856
1231
|
npx @or3/cloud update [--to <exact-version>]
|
|
857
|
-
npx @or3/cloud backup
|
|
1232
|
+
npx @or3/cloud backup [list|prune [--keep <n>]|export <backup-id> <destination-dir>]
|
|
858
1233
|
npx @or3/cloud restore <backup-id-or-path> --yes
|
|
859
1234
|
npx @or3/cloud rollback --yes
|
|
1235
|
+
npx @or3/cloud credentials reset --yes [--owner-password <p> --admin-password <p>]
|
|
860
1236
|
npx @or3/cloud doctor
|
|
861
1237
|
npx @or3/cloud recover
|
|
862
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]
|
|
863
1243
|
|
|
864
1244
|
Options:
|
|
865
1245
|
--admin-email <email> Administrator email for first login
|
|
866
|
-
--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
|
|
867
1247
|
--admin-password-file <path> Read the bootstrap password without shell history
|
|
1248
|
+
--owner-password <password> New owner (basic auth) password for credentials reset
|
|
868
1249
|
--port <port> Local OR3 port (default: 3000)
|
|
869
|
-
--
|
|
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
|
|
870
1255
|
--help Show this help
|
|
871
1256
|
--version Show the Cloud package version
|
|
872
1257
|
|
|
@@ -914,6 +1299,7 @@ async function initCommand(positionals, flags) {
|
|
|
914
1299
|
const version = PACKAGE_VERSION;
|
|
915
1300
|
const image = imageFor(version);
|
|
916
1301
|
await pullImage(image);
|
|
1302
|
+
await assertSupportedHostArchitecture(image);
|
|
917
1303
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
918
1304
|
await chmod(directory, 448);
|
|
919
1305
|
await mkdir(deploymentPaths(directory).operations, { recursive: true, mode: 448 });
|
|
@@ -921,9 +1307,12 @@ async function initCommand(positionals, flags) {
|
|
|
921
1307
|
await copyAssets(directory, mode);
|
|
922
1308
|
const env = buildEnv({ mode, version, directory, email, password, domain, port });
|
|
923
1309
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
|
|
924
|
-
await writeSecure(join(directory, ".or3-initial-credentials"),
|
|
925
|
-
|
|
926
|
-
|
|
1310
|
+
await writeSecure(join(directory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
1311
|
+
bootstrapEmail: email,
|
|
1312
|
+
bootstrapPassword: password,
|
|
1313
|
+
adminUsername: email,
|
|
1314
|
+
adminPassword: password
|
|
1315
|
+
}));
|
|
927
1316
|
const digest = await imageDigest(image);
|
|
928
1317
|
const state = stateFromEnv(directory, env, mode, "init", digest);
|
|
929
1318
|
await markPending(directory, state, {
|
|
@@ -975,6 +1364,16 @@ async function recoverCommand(directory) {
|
|
|
975
1364
|
return;
|
|
976
1365
|
}
|
|
977
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
|
+
}
|
|
978
1377
|
if (pending.operation === "restore" || pending.operation === "rollback") {
|
|
979
1378
|
if (!pending.backupId)
|
|
980
1379
|
throw new Error(`The incomplete ${pending.operation} has no recorded backup ID.`);
|
|
@@ -1055,7 +1454,7 @@ async function recoverCommand(directory) {
|
|
|
1055
1454
|
throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
|
|
1056
1455
|
}
|
|
1057
1456
|
await stopProject(loaded.directory, loaded.state.mode);
|
|
1058
|
-
await
|
|
1457
|
+
await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
|
|
1059
1458
|
}
|
|
1060
1459
|
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
1061
1460
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
@@ -1066,7 +1465,8 @@ async function recoverCommand(directory) {
|
|
|
1066
1465
|
await commitRecoveredState(loaded.directory, loaded.state, recovered);
|
|
1067
1466
|
console.log(`Recovered the incomplete ${pending.operation} operation. OR3 ${recovered.appVersion} is deeply healthy.`);
|
|
1068
1467
|
} catch (error) {
|
|
1069
|
-
|
|
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);
|
|
1070
1470
|
if (pending.operation === "adopt" && pending.sourceDirectory) {
|
|
1071
1471
|
try {
|
|
1072
1472
|
await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
|
|
@@ -1091,7 +1491,7 @@ function assertNoPending(state) {
|
|
|
1091
1491
|
throw new Error(`An incomplete ${state.incompleteOperation.operation} is recorded. Run "npx @or3/cloud recover" before starting another operation.`);
|
|
1092
1492
|
}
|
|
1093
1493
|
}
|
|
1094
|
-
async function
|
|
1494
|
+
async function backupCreateCommand(directory) {
|
|
1095
1495
|
await ensureDocker();
|
|
1096
1496
|
const loaded = await loadManaged(directory);
|
|
1097
1497
|
assertNoPending(loaded.state);
|
|
@@ -1113,6 +1513,109 @@ async function backupCommand(directory) {
|
|
|
1113
1513
|
throw error;
|
|
1114
1514
|
}
|
|
1115
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
|
+
}
|
|
1116
1619
|
async function updateCommand(directory, flags) {
|
|
1117
1620
|
await ensureDocker();
|
|
1118
1621
|
const loaded = await loadManaged(directory);
|
|
@@ -1121,11 +1624,12 @@ async function updateCommand(directory, flags) {
|
|
|
1121
1624
|
await waitForDeepHealth(loaded.directory, state.mode, secretValues(env));
|
|
1122
1625
|
const targetVersion = stringFlag(flags, "to")?.trim() ?? PACKAGE_VERSION;
|
|
1123
1626
|
if (!isVersion(targetVersion))
|
|
1124
|
-
throw new Error(
|
|
1627
|
+
throw new Error(`--to must be a complete semantic version such as ${PACKAGE_VERSION}.`);
|
|
1125
1628
|
if (targetVersion === state.appVersion)
|
|
1126
1629
|
throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
|
|
1127
1630
|
const targetImage = imageFor(targetVersion);
|
|
1128
1631
|
const targetDigest = await pullImage(targetImage);
|
|
1632
|
+
await assertSupportedHostArchitecture(targetImage);
|
|
1129
1633
|
const oldEnv = { ...env };
|
|
1130
1634
|
const pending = {
|
|
1131
1635
|
id: id("update"),
|
|
@@ -1204,6 +1708,8 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
1204
1708
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1205
1709
|
if (manifest.imageDigest)
|
|
1206
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);
|
|
1207
1713
|
const pending = {
|
|
1208
1714
|
id: id("restore"),
|
|
1209
1715
|
operation: "restore",
|
|
@@ -1250,6 +1756,8 @@ async function rollbackCommand(directory, flags) {
|
|
|
1250
1756
|
}
|
|
1251
1757
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1252
1758
|
await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
|
|
1759
|
+
await assertSupportedHostArchitecture(point.image);
|
|
1760
|
+
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
|
|
1253
1761
|
const pending = {
|
|
1254
1762
|
id: id("rollback"),
|
|
1255
1763
|
operation: "rollback",
|
|
@@ -1516,6 +2024,7 @@ async function adoptCommand(positionals, flags) {
|
|
|
1516
2024
|
throw new Error(`V1 OR3_IMAGE ${sourceEnv.OR3_IMAGE} does not match the published image for ${release.or3Version}.`);
|
|
1517
2025
|
}
|
|
1518
2026
|
await pullImage(image);
|
|
2027
|
+
await assertSupportedHostArchitecture(image);
|
|
1519
2028
|
const sourceVolume = await readSourceVolume(sourceDirectory);
|
|
1520
2029
|
const email = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
1521
2030
|
const password = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
@@ -1562,9 +2071,12 @@ async function adoptCommand(positionals, flags) {
|
|
|
1562
2071
|
throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
|
|
1563
2072
|
}
|
|
1564
2073
|
await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
|
|
1565
|
-
await writeSecure(join(targetDirectory, ".or3-initial-credentials"),
|
|
1566
|
-
|
|
1567
|
-
|
|
2074
|
+
await writeSecure(join(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
2075
|
+
bootstrapEmail: email,
|
|
2076
|
+
bootstrapPassword: password,
|
|
2077
|
+
adminUsername: email,
|
|
2078
|
+
adminPassword: password
|
|
2079
|
+
}));
|
|
1568
2080
|
const digest = await imageDigest(image);
|
|
1569
2081
|
const state = stateFromEnv(targetDirectory, targetEnv, mode, "adopt", digest);
|
|
1570
2082
|
await markPending(targetDirectory, state, {
|
|
@@ -1614,7 +2126,7 @@ password=${password}
|
|
|
1614
2126
|
await writeSecure(join(sourceBackupDir, "manifest.json"), `${JSON.stringify(sourceManifest, null, 2)}
|
|
1615
2127
|
`);
|
|
1616
2128
|
await updatePending(targetDirectory, state, { backupId: sourceBackupId });
|
|
1617
|
-
await
|
|
2129
|
+
await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
|
|
1618
2130
|
await startProject(targetDirectory, mode, targetEnv);
|
|
1619
2131
|
await clearPending(targetDirectory, state);
|
|
1620
2132
|
console.log(`Adopted ${sourceDirectory} into ${targetDirectory} at OR3 ${release.or3Version}.`);
|
|
@@ -1638,6 +2150,448 @@ password=${password}
|
|
|
1638
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}.` : ""}`);
|
|
1639
2151
|
}
|
|
1640
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
|
+
}
|
|
1641
2595
|
async function main(argv = process.argv.slice(2)) {
|
|
1642
2596
|
const [command = "help", ...rest] = argv;
|
|
1643
2597
|
if (command === "--help" || command === "help")
|
|
@@ -1646,22 +2600,39 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1646
2600
|
return console.log(PACKAGE_VERSION);
|
|
1647
2601
|
const parsed = parseFlags(rest);
|
|
1648
2602
|
try {
|
|
2603
|
+
if (parsed.flags.help)
|
|
2604
|
+
return help();
|
|
2605
|
+
assertCommandFlags(command, parsed.flags);
|
|
1649
2606
|
if (command === "init")
|
|
1650
2607
|
return await initCommand(parsed.positionals, parsed.flags);
|
|
1651
2608
|
if (command === "update")
|
|
1652
2609
|
return await updateCommand(process.cwd(), parsed.flags);
|
|
1653
2610
|
if (command === "backup")
|
|
1654
|
-
return await backupCommand(process.cwd());
|
|
2611
|
+
return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
|
|
1655
2612
|
if (command === "restore")
|
|
1656
2613
|
return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
1657
2614
|
if (command === "rollback")
|
|
1658
2615
|
return await rollbackCommand(process.cwd(), parsed.flags);
|
|
2616
|
+
if (command === "credentials")
|
|
2617
|
+
return await credentialsCommand(process.cwd(), parsed.positionals, parsed.flags);
|
|
1659
2618
|
if (command === "doctor")
|
|
1660
2619
|
return await doctorCommand(process.cwd());
|
|
1661
2620
|
if (command === "recover")
|
|
1662
2621
|
return await recoverCommand(process.cwd());
|
|
1663
2622
|
if (command === "adopt")
|
|
1664
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);
|
|
1665
2636
|
throw new Error(`Unknown command "${command}". Run npx @or3/cloud --help.`);
|
|
1666
2637
|
} catch (error) {
|
|
1667
2638
|
console.error(`
|
|
@@ -1674,16 +2645,28 @@ if (invokedAsCli || process.argv[1] && resolve(process.argv[1]) === resolve(file
|
|
|
1674
2645
|
await main();
|
|
1675
2646
|
}
|
|
1676
2647
|
export {
|
|
2648
|
+
validatePassword,
|
|
2649
|
+
supportedImageArchitectures,
|
|
1677
2650
|
stateFromEnv,
|
|
2651
|
+
serializeInitialCredentials,
|
|
1678
2652
|
serializeEnv,
|
|
2653
|
+
selectPruneTargets,
|
|
2654
|
+
requiredArchiveSpace,
|
|
1679
2655
|
redact,
|
|
2656
|
+
purgeVolumesFromState,
|
|
1680
2657
|
parseFlags,
|
|
1681
2658
|
parseEnv,
|
|
1682
2659
|
isVersion,
|
|
1683
2660
|
checkResolvedLoopbackBinding,
|
|
1684
2661
|
buildEnv,
|
|
2662
|
+
buildCredentialsResetScript,
|
|
1685
2663
|
assertSupportedSourceCompose,
|
|
1686
2664
|
assertSupportedSource,
|
|
2665
|
+
assertSupportedArchitecture,
|
|
2666
|
+
assertRemovableArtifactName,
|
|
2667
|
+
assertPurgeBackupFreshness,
|
|
2668
|
+
assertEnoughFreeSpace,
|
|
2669
|
+
assertCommandFlags,
|
|
1687
2670
|
assertBackupMatchesDeployment,
|
|
1688
2671
|
PACKAGE_VERSION,
|
|
1689
2672
|
IMAGE_REPOSITORY
|