@or3/cloud 0.1.37 → 0.1.49
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/LICENSE +17 -0
- package/README.md +104 -2
- package/assets/Caddyfile +4 -1
- package/assets/compose.operator.yaml +61 -0
- package/assets/compose.public.yaml +6 -0
- package/assets/compose.yaml +4 -0
- package/assets/dashboard-operator.mjs +874 -0
- package/dist/cli.mjs +2178 -331
- package/package.json +8 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
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 { createReadStream, createWriteStream, existsSync } from "node:fs";
|
|
10
|
+
import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import {
|
|
12
12
|
chmod,
|
|
13
13
|
copyFile,
|
|
14
14
|
mkdir,
|
|
15
|
+
open,
|
|
15
16
|
readFile,
|
|
16
17
|
readdir,
|
|
18
|
+
realpath,
|
|
17
19
|
rename,
|
|
18
20
|
rm,
|
|
19
21
|
stat,
|
|
@@ -27,16 +29,26 @@ import { dirname, basename, isAbsolute, join, resolve, sep } from "node:path";
|
|
|
27
29
|
import { fileURLToPath } from "node:url";
|
|
28
30
|
import { createGunzip } from "node:zlib";
|
|
29
31
|
var execFile = promisify(execFileCallback);
|
|
30
|
-
var
|
|
32
|
+
var PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url)));
|
|
33
|
+
var PACKAGE_VERSION = "0.1.49";
|
|
31
34
|
var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
|
|
32
35
|
var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
|
|
33
36
|
var STATE_SCHEMA_VERSION = 1;
|
|
34
37
|
var DEFAULT_PORT = 3000;
|
|
35
38
|
var DEEP_HEALTH_TIMEOUT_MS = 180000;
|
|
39
|
+
var COMMAND_TIMEOUT_MS = 180000;
|
|
40
|
+
var STREAM_COMMAND_TIMEOUT_MS = 15 * 60000;
|
|
36
41
|
var BACKUP_RETENTION_KEEP = 5;
|
|
37
42
|
var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
|
|
38
43
|
var FREE_SPACE_HEADROOM_BYTES = 64 * 1024 * 1024;
|
|
39
44
|
var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
|
|
45
|
+
var MANAGED_ASSET_INVENTORY_VERSION = 3;
|
|
46
|
+
var DASHBOARD_LEASE_STALE_MS = 30000;
|
|
47
|
+
var PROVISIONING_CREDENTIAL_KEYS = [
|
|
48
|
+
"OR3_BASIC_AUTH_BOOTSTRAP_EMAIL",
|
|
49
|
+
"OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
|
|
50
|
+
"OR3_ADMIN_PASSWORD"
|
|
51
|
+
];
|
|
40
52
|
var SECRET_KEYS = [
|
|
41
53
|
"OR3_BASIC_AUTH_JWT_SECRET",
|
|
42
54
|
"OR3_BASIC_AUTH_REFRESH_SECRET",
|
|
@@ -54,6 +66,32 @@ var DEPLOYMENT_ENV_KEYS = [
|
|
|
54
66
|
"OR3_PORT",
|
|
55
67
|
"OR3_PUBLIC_DOMAIN"
|
|
56
68
|
];
|
|
69
|
+
var DEPLOYMENT_ID_ENV_KEY = "OR3_DEPLOYMENT_ID";
|
|
70
|
+
var PROCESS_ENV_PASSTHROUGH = [
|
|
71
|
+
"PATH",
|
|
72
|
+
"HOME",
|
|
73
|
+
"USER",
|
|
74
|
+
"LOGNAME",
|
|
75
|
+
"SHELL",
|
|
76
|
+
"TMPDIR",
|
|
77
|
+
"TMP",
|
|
78
|
+
"TEMP",
|
|
79
|
+
"XDG_CONFIG_HOME",
|
|
80
|
+
"XDG_RUNTIME_DIR",
|
|
81
|
+
"SSL_CERT_FILE",
|
|
82
|
+
"SSL_CERT_DIR",
|
|
83
|
+
"HTTP_PROXY",
|
|
84
|
+
"HTTPS_PROXY",
|
|
85
|
+
"NO_PROXY",
|
|
86
|
+
"http_proxy",
|
|
87
|
+
"https_proxy",
|
|
88
|
+
"no_proxy",
|
|
89
|
+
"DOCKER_HOST",
|
|
90
|
+
"DOCKER_CONTEXT",
|
|
91
|
+
"DOCKER_TLS_VERIFY",
|
|
92
|
+
"DOCKER_CERT_PATH",
|
|
93
|
+
"DOCKER_CONFIG"
|
|
94
|
+
];
|
|
57
95
|
var ALLOWED_ENV_KEYS = new Set([
|
|
58
96
|
"SSR_AUTH_ENABLED",
|
|
59
97
|
"AUTH_PROVIDER",
|
|
@@ -68,6 +106,7 @@ var ALLOWED_ENV_KEYS = new Set([
|
|
|
68
106
|
"OR3_BASIC_AUTH_DB_PATH",
|
|
69
107
|
"OR3_BASIC_AUTH_BOOTSTRAP_EMAIL",
|
|
70
108
|
"OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
|
|
109
|
+
"OR3_MANAGED_OWNER_EMAIL",
|
|
71
110
|
"OR3_SYNC_ENABLED",
|
|
72
111
|
"OR3_CLOUD_SYNC_ENABLED",
|
|
73
112
|
"OR3_SYNC_PROVIDER",
|
|
@@ -116,6 +155,9 @@ function serializeInitialCredentials(input2) {
|
|
|
116
155
|
`);
|
|
117
156
|
}
|
|
118
157
|
function validatePassword(password) {
|
|
158
|
+
if (password.includes("\x00") || /\r|\n/.test(password)) {
|
|
159
|
+
throw new Error("The administrator password may not contain NUL or newline characters.");
|
|
160
|
+
}
|
|
119
161
|
if (password.length < 12 || !/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
|
|
120
162
|
throw new Error("The administrator password must be at least 12 characters and contain uppercase, lowercase, and numeric characters.");
|
|
121
163
|
}
|
|
@@ -127,7 +169,7 @@ function redact(text, secrets = []) {
|
|
|
127
169
|
let output2 = text;
|
|
128
170
|
for (const secret of secrets.filter(Boolean))
|
|
129
171
|
output2 = output2.replaceAll(secret, "[REDACTED]");
|
|
130
|
-
output2 = output2.replace(/((?:PASSWORD|SECRET|TOKEN|JWT)\s*[=:]\s*)([^\
|
|
172
|
+
output2 = output2.replace(/((?:PASSWORD|SECRET|TOKEN|JWT)\s*[=:]\s*)(?!\[REDACTED\])([^\r\n]+)/gi, "$1[REDACTED]");
|
|
131
173
|
return output2;
|
|
132
174
|
}
|
|
133
175
|
function parseFlags(argv) {
|
|
@@ -161,9 +203,10 @@ var COMMAND_FLAGS = {
|
|
|
161
203
|
restore: ["yes"],
|
|
162
204
|
rollback: ["yes"],
|
|
163
205
|
doctor: [],
|
|
206
|
+
verify: ["public", "verification-email", "verification-password-file"],
|
|
164
207
|
recover: [],
|
|
165
208
|
adopt: ["from"],
|
|
166
|
-
credentials: ["yes", "owner-password", "admin-password"],
|
|
209
|
+
credentials: ["yes", "owner-password", "owner-password-file", "admin-password", "admin-password-file"],
|
|
167
210
|
status: [],
|
|
168
211
|
logs: ["tail"],
|
|
169
212
|
start: [],
|
|
@@ -178,6 +221,28 @@ function assertCommandFlags(command, flags) {
|
|
|
178
221
|
throw new Error(`Unknown option${unknown.length === 1 ? "" : "s"} for ${command}: ${unknown.map((flag) => `--${flag}`).join(", ")}. Run npx @or3/cloud ${command} --help.`);
|
|
179
222
|
}
|
|
180
223
|
}
|
|
224
|
+
function assertCommandPositionals(command, positionals) {
|
|
225
|
+
if (command === "backup" || command === "credentials")
|
|
226
|
+
return;
|
|
227
|
+
if (command === "init" || command === "adopt") {
|
|
228
|
+
if (positionals.length > 1)
|
|
229
|
+
throw new Error(`${command} accepts at most one target directory.`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (command === "restore") {
|
|
233
|
+
if (positionals.length !== 1)
|
|
234
|
+
throw new Error("restore requires exactly one backup ID or absolute backup path.");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (command === "logs") {
|
|
238
|
+
if (positionals.length > 1)
|
|
239
|
+
throw new Error("logs accepts at most one service name.");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (positionals.length > 0) {
|
|
243
|
+
throw new Error(`${command} accepts no positional arguments. Run npx @or3/cloud ${command} --help.`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
181
246
|
function stringFlag(flags, key) {
|
|
182
247
|
const value = flags[key];
|
|
183
248
|
return typeof value === "string" ? value : undefined;
|
|
@@ -202,6 +267,9 @@ function imageFor(version) {
|
|
|
202
267
|
return testImage;
|
|
203
268
|
return `${IMAGE_REPOSITORY}:${version}`;
|
|
204
269
|
}
|
|
270
|
+
function operatorImageFor(version) {
|
|
271
|
+
return process.env.OR3_CLOUD_TEST_OPERATOR_IMAGE?.trim() || `${IMAGE_REPOSITORY}:${version}-operator`;
|
|
272
|
+
}
|
|
205
273
|
function sanitizeName(value, fallback = "or3-cloud") {
|
|
206
274
|
const name = value.toLowerCase().replaceAll(/[^a-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
|
|
207
275
|
return name || fallback;
|
|
@@ -234,14 +302,38 @@ function validateDomain(domain) {
|
|
|
234
302
|
throw new Error(`"${domain}" is not a hostname. Use a name such as cloud.example.com.`);
|
|
235
303
|
}
|
|
236
304
|
}
|
|
237
|
-
|
|
305
|
+
function composeProcessEnv(directory) {
|
|
306
|
+
const safe = {};
|
|
307
|
+
for (const key of PROCESS_ENV_PASSTHROUGH) {
|
|
308
|
+
if (process.env[key] !== undefined)
|
|
309
|
+
safe[key] = process.env[key];
|
|
310
|
+
}
|
|
311
|
+
const envPath = join(directory, ".env");
|
|
312
|
+
try {
|
|
313
|
+
for (const [key, value] of Object.entries(parseEnv(readFileSync(envPath, "utf8")))) {
|
|
314
|
+
if (key.startsWith("OR3_"))
|
|
315
|
+
safe[key] = value;
|
|
316
|
+
}
|
|
317
|
+
return safe;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw new Error(`Could not read managed Compose environment ${envPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function commandEnvironment(command, args, cwd, supplied) {
|
|
323
|
+
if (supplied)
|
|
324
|
+
return supplied;
|
|
325
|
+
return command === "docker" && args[0] === "compose" && cwd ? composeProcessEnv(cwd) : process.env;
|
|
326
|
+
}
|
|
327
|
+
async function run(command, args, cwd, environment) {
|
|
238
328
|
const printable = `${command} ${args.map(quote).join(" ")}`;
|
|
239
329
|
try {
|
|
240
330
|
const result = await execFile(command, args, {
|
|
241
331
|
cwd,
|
|
242
332
|
maxBuffer: 4 * 1024 * 1024,
|
|
243
333
|
encoding: "utf8",
|
|
244
|
-
|
|
334
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
335
|
+
killSignal: "SIGKILL",
|
|
336
|
+
env: commandEnvironment(command, args, cwd, environment)
|
|
245
337
|
});
|
|
246
338
|
return {
|
|
247
339
|
ok: true,
|
|
@@ -274,13 +366,35 @@ async function writeSecure(path, content) {
|
|
|
274
366
|
const temporary = `${path}.tmp-${randomBytes(4).toString("hex")}`;
|
|
275
367
|
await writeFile(temporary, content, { mode: 384 });
|
|
276
368
|
await chmod(temporary, 384);
|
|
277
|
-
await
|
|
278
|
-
|
|
369
|
+
await durableRename(temporary, path);
|
|
370
|
+
}
|
|
371
|
+
async function syncFile(path) {
|
|
372
|
+
const handle = await open(path, "r");
|
|
373
|
+
try {
|
|
374
|
+
await handle.sync();
|
|
375
|
+
} finally {
|
|
376
|
+
await handle.close();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async function syncDirectory(path) {
|
|
380
|
+
const handle = await open(path, "r");
|
|
381
|
+
try {
|
|
382
|
+
await handle.sync();
|
|
383
|
+
} finally {
|
|
384
|
+
await handle.close();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function durableRename(source, destination) {
|
|
388
|
+
await syncFile(source);
|
|
389
|
+
await rename(source, destination);
|
|
390
|
+
await syncDirectory(dirname(destination));
|
|
279
391
|
}
|
|
280
392
|
async function copySecure(source, destination) {
|
|
281
393
|
await mkdir(dirname(destination), { recursive: true, mode: 448 });
|
|
282
394
|
await copyFile(source, destination);
|
|
283
395
|
await chmod(destination, 384);
|
|
396
|
+
await syncFile(destination);
|
|
397
|
+
await syncDirectory(dirname(destination));
|
|
284
398
|
}
|
|
285
399
|
async function fileExists(path) {
|
|
286
400
|
try {
|
|
@@ -293,6 +407,13 @@ async function fileExists(path) {
|
|
|
293
407
|
async function readText(path) {
|
|
294
408
|
return readFile(path, "utf8");
|
|
295
409
|
}
|
|
410
|
+
async function readOwnerOnlyText(path, label) {
|
|
411
|
+
const info = await stat(path);
|
|
412
|
+
if (!info.isFile() || (info.mode & 63) !== 0) {
|
|
413
|
+
throw new Error(`${label} must be a regular owner-only file with no group/world permissions.`);
|
|
414
|
+
}
|
|
415
|
+
return readText(path);
|
|
416
|
+
}
|
|
296
417
|
function requiredArchiveSpace(requiredBytes) {
|
|
297
418
|
return requiredBytes + Math.max(Math.ceil(requiredBytes / 2), FREE_SPACE_HEADROOM_BYTES);
|
|
298
419
|
}
|
|
@@ -353,9 +474,56 @@ function deploymentPaths(directory) {
|
|
|
353
474
|
state: join(cloud, "state.json"),
|
|
354
475
|
operations: join(cloud, "operations"),
|
|
355
476
|
backups: join(cloud, "backups"),
|
|
356
|
-
exports: join(cloud, "exports")
|
|
477
|
+
exports: join(cloud, "exports"),
|
|
478
|
+
lease: join(cloud, "operation-lease"),
|
|
479
|
+
backupAuthKey: join(cloud, "backup-auth.key"),
|
|
480
|
+
operatorIpc: join(cloud, "operator-ipc")
|
|
357
481
|
};
|
|
358
482
|
}
|
|
483
|
+
async function backupAuthenticationKey(directory, create = false) {
|
|
484
|
+
const keyPath = deploymentPaths(directory).backupAuthKey;
|
|
485
|
+
try {
|
|
486
|
+
const key = (await readText(keyPath)).trim();
|
|
487
|
+
if (!/^[0-9a-f]{64}$/i.test(key))
|
|
488
|
+
throw new Error("invalid format");
|
|
489
|
+
return key;
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (error.code !== "ENOENT") {
|
|
492
|
+
throw new Error(`Backup authentication key at ${keyPath} is invalid or unreadable. Restore is refused until it is repaired from the deployment owner’s secure key material.`);
|
|
493
|
+
}
|
|
494
|
+
if (!create) {
|
|
495
|
+
throw new Error(`This deployment has no backup authentication key at ${keyPath}. Backups created by older CLI versions are intentionally not trusted for restore. Create a new verified backup before attempting a destructive restore.`);
|
|
496
|
+
}
|
|
497
|
+
const key = randomBytes(32).toString("hex");
|
|
498
|
+
await writeSecure(keyPath, `${key}
|
|
499
|
+
`);
|
|
500
|
+
return key;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function backupAuthenticationTag(key, manifestContents) {
|
|
504
|
+
return createHmac("sha256", Buffer.from(key, "hex")).update(manifestContents).digest("hex");
|
|
505
|
+
}
|
|
506
|
+
async function writeBackupAuthentication(directory, backupPath, manifestContents) {
|
|
507
|
+
const key = await backupAuthenticationKey(directory, true);
|
|
508
|
+
await writeSecure(join(backupPath, "manifest.auth"), `${backupAuthenticationTag(key, manifestContents)}
|
|
509
|
+
`);
|
|
510
|
+
}
|
|
511
|
+
async function assertBackupAuthentication(directory, backupPath, manifestContents) {
|
|
512
|
+
const key = await backupAuthenticationKey(directory);
|
|
513
|
+
let provided;
|
|
514
|
+
try {
|
|
515
|
+
const value = (await readText(join(backupPath, "manifest.auth"))).trim();
|
|
516
|
+
if (!/^[0-9a-f]{64}$/i.test(value))
|
|
517
|
+
throw new Error("invalid authentication tag");
|
|
518
|
+
provided = Buffer.from(value, "hex");
|
|
519
|
+
} catch {
|
|
520
|
+
throw new Error(`Backup at ${backupPath} has no valid deployment authentication tag. Refusing to restore or export an unauthenticated backup.`);
|
|
521
|
+
}
|
|
522
|
+
const expected = Buffer.from(backupAuthenticationTag(key, manifestContents), "hex");
|
|
523
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
524
|
+
throw new Error(`Backup authentication failed for ${backupPath}. Refusing to trust data, configuration, or managed assets from another deployment.`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
359
527
|
async function readState(directory) {
|
|
360
528
|
const paths = deploymentPaths(directory);
|
|
361
529
|
const parsed = JSON.parse(await readText(paths.state));
|
|
@@ -371,8 +539,15 @@ async function writeState(directory, state) {
|
|
|
371
539
|
async function readDirectoryEmpty(directory) {
|
|
372
540
|
try {
|
|
373
541
|
const entries = await readdir(directory);
|
|
374
|
-
|
|
542
|
+
const nonLeaseEntries = entries.filter((entry) => entry !== ".or3-cloud");
|
|
543
|
+
if (nonLeaseEntries.length)
|
|
375
544
|
throw new Error(`Refusing to use non-empty directory ${directory}. Choose a new directory or run adopt explicitly.`);
|
|
545
|
+
if (entries.includes(".or3-cloud")) {
|
|
546
|
+
const cloudEntries = await readdir(join(directory, ".or3-cloud"));
|
|
547
|
+
if (cloudEntries.some((entry) => entry !== "operation-lease")) {
|
|
548
|
+
throw new Error(`Refusing to use non-empty directory ${directory}. Choose a new directory or run adopt explicitly.`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
376
551
|
return false;
|
|
377
552
|
} catch (error) {
|
|
378
553
|
if (error.code === "ENOENT")
|
|
@@ -380,12 +555,124 @@ async function readDirectoryEmpty(directory) {
|
|
|
380
555
|
throw error;
|
|
381
556
|
}
|
|
382
557
|
}
|
|
558
|
+
async function readLeaseOwner(leasePath) {
|
|
559
|
+
try {
|
|
560
|
+
const owner = JSON.parse(await readText(join(leasePath, "owner.json")));
|
|
561
|
+
if (owner.schemaVersion !== 1 || typeof owner.nonce !== "string" || typeof owner.command !== "string" || owner.origin !== "cli" && owner.origin !== "dashboard" || !Number.isSafeInteger(owner.pid) || typeof owner.acquiredAt !== "string" || typeof owner.heartbeatAt !== "string")
|
|
562
|
+
return;
|
|
563
|
+
return owner;
|
|
564
|
+
} catch {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function cliLeaseOwnerIsGone(owner) {
|
|
569
|
+
if (owner.origin !== "cli")
|
|
570
|
+
return false;
|
|
571
|
+
try {
|
|
572
|
+
process.kill(owner.pid, 0);
|
|
573
|
+
return false;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return error.code === "ESRCH";
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function dashboardLeaseOwnerIsStale(owner, jobId) {
|
|
579
|
+
if (owner.origin !== "dashboard" || jobId && owner.jobId !== jobId)
|
|
580
|
+
return false;
|
|
581
|
+
const heartbeat = Date.parse(owner.heartbeatAt);
|
|
582
|
+
return Number.isFinite(heartbeat) && Date.now() - heartbeat > DASHBOARD_LEASE_STALE_MS;
|
|
583
|
+
}
|
|
584
|
+
async function acquireDeploymentLease(directory, command) {
|
|
585
|
+
const leasePath = deploymentPaths(directory).lease;
|
|
586
|
+
await mkdir(dirname(leasePath), { recursive: true, mode: 448 });
|
|
587
|
+
const nonce = randomBytes(16).toString("hex");
|
|
588
|
+
const owner = {
|
|
589
|
+
schemaVersion: 1,
|
|
590
|
+
nonce,
|
|
591
|
+
command,
|
|
592
|
+
origin: process.env.OR3_DASHBOARD_UPDATE_JOB_ID ? "dashboard" : "cli",
|
|
593
|
+
jobId: process.env.OR3_DASHBOARD_UPDATE_JOB_ID?.trim() || undefined,
|
|
594
|
+
pid: process.pid,
|
|
595
|
+
acquiredAt: now(),
|
|
596
|
+
heartbeatAt: now()
|
|
597
|
+
};
|
|
598
|
+
try {
|
|
599
|
+
await mkdir(leasePath, { recursive: false, mode: 448 });
|
|
600
|
+
} catch (error) {
|
|
601
|
+
if (error.code !== "EEXIST")
|
|
602
|
+
throw error;
|
|
603
|
+
const active = await readLeaseOwner(leasePath);
|
|
604
|
+
if (active && (cliLeaseOwnerIsGone(active) || dashboardLeaseOwnerIsStale(active, owner.jobId))) {
|
|
605
|
+
const stalePath = `${leasePath}.stale-${randomBytes(8).toString("hex")}`;
|
|
606
|
+
try {
|
|
607
|
+
await rename(leasePath, stalePath);
|
|
608
|
+
await rm(stalePath, { recursive: true, force: true });
|
|
609
|
+
await mkdir(leasePath, { recursive: false, mode: 448 });
|
|
610
|
+
} catch (reclaimError) {
|
|
611
|
+
throw new Error(`Another OR3 Cloud operation owns ${directory}. Refusing to race its lifecycle lock. ${reclaimError instanceof Error ? reclaimError.message : String(reclaimError)}`);
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
const detail = active ? `${active.command} (${active.origin}) acquired at ${active.acquiredAt}` : "an unreadable owner record";
|
|
615
|
+
throw new Error(`Another OR3 Cloud operation owns ${directory}: ${detail}. Refusing to run concurrently.`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
await writeSecure(join(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
|
|
620
|
+
`);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
await rm(leasePath, { recursive: true, force: true }).catch(() => {
|
|
623
|
+
return;
|
|
624
|
+
});
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
const heartbeat = setInterval(() => {
|
|
628
|
+
owner.heartbeatAt = now();
|
|
629
|
+
writeSecure(join(leasePath, "owner.json"), `${JSON.stringify(owner, null, 2)}
|
|
630
|
+
`).catch(() => {
|
|
631
|
+
return;
|
|
632
|
+
});
|
|
633
|
+
}, 5000);
|
|
634
|
+
heartbeat.unref();
|
|
635
|
+
return async () => {
|
|
636
|
+
clearInterval(heartbeat);
|
|
637
|
+
const current = await readLeaseOwner(leasePath);
|
|
638
|
+
if (current?.nonce === nonce)
|
|
639
|
+
await rm(leasePath, { recursive: true, force: true });
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
async function withDeploymentLease(directory, command, action) {
|
|
643
|
+
const release = await acquireDeploymentLease(directory, command);
|
|
644
|
+
try {
|
|
645
|
+
return await action();
|
|
646
|
+
} finally {
|
|
647
|
+
await release();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function dashboardUpdatesEnabled(directory) {
|
|
651
|
+
try {
|
|
652
|
+
return parseEnv(readFileSync(join(directory, ".env"), "utf8")).OR3_DASHBOARD_UPDATES_ENABLED === "true";
|
|
653
|
+
} catch {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
383
657
|
function composeArgs(directory, mode, command = []) {
|
|
658
|
+
const env = parseEnv(readFileSync(join(directory, ".env"), "utf8"));
|
|
659
|
+
const project = env.OR3_COMPOSE_PROJECT;
|
|
660
|
+
if (!project)
|
|
661
|
+
throw new Error(`Managed Compose environment at ${join(directory, ".env")} has no OR3_COMPOSE_PROJECT.`);
|
|
384
662
|
const files = ["-f", join(directory, "compose.yaml")];
|
|
385
663
|
if (mode === "public")
|
|
386
664
|
files.push("-f", join(directory, "compose.public.yaml"));
|
|
665
|
+
if (dashboardUpdatesEnabled(directory)) {
|
|
666
|
+
const operatorOverlay = join(directory, "compose.operator.yaml");
|
|
667
|
+
if (!existsSync(operatorOverlay)) {
|
|
668
|
+
throw new Error("Dashboard updates are enabled but compose.operator.yaml is missing. Run `npx @or3/cloud recover` before operating on this deployment.");
|
|
669
|
+
}
|
|
670
|
+
files.push("-f", operatorOverlay);
|
|
671
|
+
}
|
|
387
672
|
return [
|
|
388
673
|
"compose",
|
|
674
|
+
"--project-name",
|
|
675
|
+
project,
|
|
389
676
|
"--project-directory",
|
|
390
677
|
directory,
|
|
391
678
|
"--env-file",
|
|
@@ -395,7 +682,7 @@ function composeArgs(directory, mode, command = []) {
|
|
|
395
682
|
];
|
|
396
683
|
}
|
|
397
684
|
function diagnostics(directory, mode) {
|
|
398
|
-
const files = `-f ${quote(join(directory, "compose.yaml"))}${mode === "public" ? ` -f ${quote(join(directory, "compose.public.yaml"))}` : ""}`;
|
|
685
|
+
const files = `-f ${quote(join(directory, "compose.yaml"))}${mode === "public" ? ` -f ${quote(join(directory, "compose.public.yaml"))}` : ""}${dashboardUpdatesEnabled(directory) ? ` -f ${quote(join(directory, "compose.operator.yaml"))}` : ""}`;
|
|
399
686
|
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`;
|
|
400
687
|
}
|
|
401
688
|
async function captureComposeDiagnostics(directory, mode, secrets) {
|
|
@@ -453,6 +740,15 @@ function composeEnvironmentValue(service, key) {
|
|
|
453
740
|
const value = environment?.[key];
|
|
454
741
|
return typeof value === "string" ? value : undefined;
|
|
455
742
|
}
|
|
743
|
+
function composeLabelValue(service, key) {
|
|
744
|
+
const labels = service.labels;
|
|
745
|
+
if (Array.isArray(labels)) {
|
|
746
|
+
const line = labels.find((value2) => value2.startsWith(`${key}=`));
|
|
747
|
+
return line?.slice(key.length + 1);
|
|
748
|
+
}
|
|
749
|
+
const value = labels?.[key];
|
|
750
|
+
return typeof value === "string" ? value : undefined;
|
|
751
|
+
}
|
|
456
752
|
function composePublishedPort(port) {
|
|
457
753
|
const target = Number(port.target);
|
|
458
754
|
const published = Number(port.published);
|
|
@@ -465,6 +761,8 @@ function checkResolvedLoopbackBinding(config, expectedPort) {
|
|
|
465
761
|
const service = parsed.services?.or3;
|
|
466
762
|
if (!service)
|
|
467
763
|
return false;
|
|
764
|
+
if (service.network_mode)
|
|
765
|
+
return false;
|
|
468
766
|
const ports = (service.ports ?? []).map(composePublishedPort);
|
|
469
767
|
const appPorts = ports.filter(({ target }) => target === 3000);
|
|
470
768
|
if (!appPorts.length)
|
|
@@ -485,12 +783,94 @@ ${result.stderr}`);
|
|
|
485
783
|
}
|
|
486
784
|
async function assertSafeComposeBinding(directory, mode, env) {
|
|
487
785
|
const config = await resolvedComposeConfig(directory, mode);
|
|
786
|
+
const parsed = parseComposeConfig(config);
|
|
787
|
+
const service = parsed.services?.or3;
|
|
788
|
+
if (parsed.name !== undefined && parsed.name !== env.OR3_COMPOSE_PROJECT) {
|
|
789
|
+
throw new Error("Resolved Compose configuration does not use the managed project name. Refusing to operate on another deployment.");
|
|
790
|
+
}
|
|
488
791
|
if (!checkResolvedLoopbackBinding(config, Number(env.OR3_PORT))) {
|
|
489
792
|
throw new Error("Resolved Compose configuration must publish OR3 port 3000 only on 127.0.0.1. Refusing to expose the application directly.");
|
|
490
793
|
}
|
|
794
|
+
if (!service || service.image !== env.OR3_IMAGE) {
|
|
795
|
+
throw new Error("Resolved Compose configuration does not use the managed immutable OR3 image reference. Refusing to start an unexpected image.");
|
|
796
|
+
}
|
|
797
|
+
if (env.OR3_DEPLOYMENT_ID && composeLabelValue(service, "io.or3.cloud.deployment-id") !== env.OR3_DEPLOYMENT_ID) {
|
|
798
|
+
throw new Error("Resolved Compose configuration does not carry the managed deployment identity label. Refusing to start an unbound project.");
|
|
799
|
+
}
|
|
800
|
+
const dataMount = (service.volumes ?? []).find((mount) => mount.target === "/data");
|
|
801
|
+
const source = typeof dataMount?.source === "string" ? dataMount.source : "";
|
|
802
|
+
const volumeName = source ? parsed.volumes?.[source]?.name : undefined;
|
|
803
|
+
if (dataMount?.type !== "volume" || volumeName !== env.OR3_VOLUME_NAME) {
|
|
804
|
+
throw new Error("Resolved Compose configuration does not bind the managed OR3 data volume. Refusing to operate on an unexpected volume.");
|
|
805
|
+
}
|
|
806
|
+
const volumeLabels = source ? parsed.volumes?.[source]?.labels : undefined;
|
|
807
|
+
const deploymentLabel = Array.isArray(volumeLabels) ? volumeLabels.find((value) => value.startsWith("io.or3.cloud.deployment-id="))?.slice("io.or3.cloud.deployment-id=".length) : volumeLabels?.["io.or3.cloud.deployment-id"];
|
|
808
|
+
if (env.OR3_DEPLOYMENT_ID && deploymentLabel !== env.OR3_DEPLOYMENT_ID) {
|
|
809
|
+
throw new Error("Resolved managed data volume lacks the expected deployment identity label.");
|
|
810
|
+
}
|
|
811
|
+
if (env.OR3_DASHBOARD_UPDATES_ENABLED === "true") {
|
|
812
|
+
const operator = parsed.services?.["or3-operator"];
|
|
813
|
+
if (!operator || operator.network_mode || operator.image !== env.OR3_OPERATOR_IMAGE || env.OR3_DEPLOYMENT_ID && composeLabelValue(operator, "io.or3.cloud.deployment-id") !== env.OR3_DEPLOYMENT_ID) {
|
|
814
|
+
throw new Error("Resolved dashboard operator does not match the managed digest-qualified deployment bridge. Refusing to start it.");
|
|
815
|
+
}
|
|
816
|
+
if (process.env.OR3_CLOUD_SKIP_PULL !== "true" && !/@sha256:[0-9a-f]{64}$/i.test(env.OR3_OPERATOR_IMAGE ?? "")) {
|
|
817
|
+
throw new Error("The dashboard operator image is not digest-qualified. Refusing to start a mutable privileged runtime.");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
async function assertRunningAppImage(directory, mode, expectedImage) {
|
|
822
|
+
const container = await run("docker", composeArgs(directory, mode, ["ps", "-q", "or3"]), directory);
|
|
823
|
+
const containerId = container.stdout.trim();
|
|
824
|
+
if (!container.ok || !containerId)
|
|
825
|
+
throw new Error("OR3 started without a running application container.");
|
|
826
|
+
const image = await run("docker", ["inspect", "--format", "{{.Image}}", containerId], directory);
|
|
827
|
+
if (!image.ok || !image.stdout.trim())
|
|
828
|
+
throw new Error("Could not inspect the image of the running OR3 container.");
|
|
829
|
+
const expectedDigest = expectedImage.match(/@((?:sha256:)[0-9a-f]{64})$/i)?.[1];
|
|
830
|
+
if (!expectedDigest && process.env.OR3_CLOUD_SKIP_PULL === "true") {
|
|
831
|
+
const expectedId = await run("docker", ["image", "inspect", "--format", "{{.Id}}", expectedImage], directory);
|
|
832
|
+
if (!expectedId.ok || expectedId.stdout.trim() !== image.stdout.trim()) {
|
|
833
|
+
throw new Error("The local qualification fixture started a different OR3 image than the one selected by the managed environment.");
|
|
834
|
+
}
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (!expectedDigest)
|
|
838
|
+
throw new Error(`Managed OR3 image ${expectedImage} is not digest-qualified.`);
|
|
839
|
+
const repoDigests = await run("docker", ["image", "inspect", "--format", "{{json .RepoDigests}}", image.stdout.trim()], directory);
|
|
840
|
+
if (!repoDigests.ok)
|
|
841
|
+
throw new Error("Could not inspect repository digests for the running OR3 container.");
|
|
842
|
+
let values;
|
|
843
|
+
try {
|
|
844
|
+
values = JSON.parse(repoDigests.stdout.trim());
|
|
845
|
+
} catch {
|
|
846
|
+
throw new Error("The running OR3 container has no readable repository digests.");
|
|
847
|
+
}
|
|
848
|
+
const expected = `${imageRepository(expectedImage)}@${expectedDigest}`;
|
|
849
|
+
if (!values.includes(expected)) {
|
|
850
|
+
throw new Error(`The running OR3 container image does not match the managed digest ${expectedDigest}. Refusing to commit startup.`);
|
|
851
|
+
}
|
|
491
852
|
}
|
|
492
853
|
var HEALTH_SCRIPT = "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);for(const path of [process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH].filter(Boolean)){const fd=fs.openSync(path,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
|
|
493
854
|
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(()=>{})";
|
|
855
|
+
var VERIFY_DATABASES_SCRIPT = `
|
|
856
|
+
const fs = require('node:fs');
|
|
857
|
+
const Database = require('/app/.output/server/node_modules/better-sqlite3');
|
|
858
|
+
const results = [];
|
|
859
|
+
for (const path of [process.env.OR3_BASIC_AUTH_DB_PATH, process.env.OR3_SQLITE_DB_PATH].filter(Boolean)) {
|
|
860
|
+
const info = fs.statSync(path);
|
|
861
|
+
if (info.uid !== 65532 || info.gid !== 65532) throw new Error(path + ' must be owned by 65532:65532');
|
|
862
|
+
const db = new Database(path, { readonly: true, fileMustExist: true });
|
|
863
|
+
const quickCheck = db.pragma('quick_check', { simple: true });
|
|
864
|
+
const tables = db.prepare("select count(*) as count from sqlite_master where type = 'table'").get().count;
|
|
865
|
+
db.close();
|
|
866
|
+
if (quickCheck !== 'ok') throw new Error(path + ' quick_check failed: ' + quickCheck);
|
|
867
|
+
results.push({ path, quickCheck, tables });
|
|
868
|
+
}
|
|
869
|
+
if (process.env.OR3_FORCE_HTTPS === 'true' && process.env.NUXT_SECURITY_PROXY_TRUST_PROXY !== 'true') {
|
|
870
|
+
throw new Error('NUXT_SECURITY_PROXY_TRUST_PROXY must be true for a managed public deployment');
|
|
871
|
+
}
|
|
872
|
+
console.log(JSON.stringify(results));
|
|
873
|
+
`;
|
|
494
874
|
var CONTAINER_NODE = "/nodejs/bin/node";
|
|
495
875
|
var LEGACY_CONTAINER_NODE = "/usr/local/bin/node";
|
|
496
876
|
var CONTAINER_NODE_SHELL = `if [ -x ${CONTAINER_NODE} ]; then exec ${CONTAINER_NODE} -e "$1"; elif [ -x ${LEGACY_CONTAINER_NODE} ]; then exec ${LEGACY_CONTAINER_NODE} -e "$1"; else exit 127; fi`;
|
|
@@ -580,6 +960,81 @@ async function setManagedVolumeRootOwnership(image, volume, ownership) {
|
|
|
580
960
|
throw new Error(`Managed volume ${volume} root ownership verification failed.`);
|
|
581
961
|
}
|
|
582
962
|
}
|
|
963
|
+
function updateRequiresVolumeRecreation(state, env) {
|
|
964
|
+
return !state.deploymentId && !env.OR3_DEPLOYMENT_ID;
|
|
965
|
+
}
|
|
966
|
+
function restoreRequiresVolumeRecreation(currentEnv, targetEnv) {
|
|
967
|
+
return currentEnv.OR3_DEPLOYMENT_ID !== targetEnv.OR3_DEPLOYMENT_ID;
|
|
968
|
+
}
|
|
969
|
+
async function removeManagedDataVolumeForRecreation(directory, state) {
|
|
970
|
+
const containers = await run("docker", [
|
|
971
|
+
"ps",
|
|
972
|
+
"-aq",
|
|
973
|
+
"--filter",
|
|
974
|
+
`label=com.docker.compose.project=${state.composeProject}`,
|
|
975
|
+
"--filter",
|
|
976
|
+
"label=com.docker.compose.service=or3"
|
|
977
|
+
], directory);
|
|
978
|
+
if (!containers.ok)
|
|
979
|
+
throw new Error(`Could not resolve the managed OR3 container. ${containers.stderr.trim()}`);
|
|
980
|
+
const containerIds = containers.stdout.trim().split(/\s+/).filter(Boolean);
|
|
981
|
+
if (containerIds.some((value) => !/^[0-9a-f]{12,64}$/i.test(value))) {
|
|
982
|
+
throw new Error("Docker returned an invalid managed OR3 container ID. Refusing to recreate the data volume.");
|
|
983
|
+
}
|
|
984
|
+
if (containerIds.length) {
|
|
985
|
+
const removedContainers = await run("docker", ["rm", "--force", ...containerIds], directory);
|
|
986
|
+
if (!removedContainers.ok)
|
|
987
|
+
throw new Error(`Could not remove the stopped managed OR3 container. ${removedContainers.stderr.trim()}`);
|
|
988
|
+
}
|
|
989
|
+
const inspected = await run("docker", ["volume", "inspect", state.volumeName, "--format", "{{json .}}"], directory);
|
|
990
|
+
if (!inspected.ok)
|
|
991
|
+
return;
|
|
992
|
+
let volume;
|
|
993
|
+
try {
|
|
994
|
+
volume = JSON.parse(inspected.stdout.trim());
|
|
995
|
+
} catch {
|
|
996
|
+
throw new Error(`Docker returned unreadable metadata for managed volume ${state.volumeName}.`);
|
|
997
|
+
}
|
|
998
|
+
if (volume.Name !== state.volumeName || volume.Labels?.["com.docker.compose.project"] !== state.composeProject || volume.Labels?.["com.docker.compose.volume"] !== "or3-data") {
|
|
999
|
+
throw new Error(`Volume ${state.volumeName} is not bound to this managed Compose deployment. Refusing to recreate it.`);
|
|
1000
|
+
}
|
|
1001
|
+
const removedVolume = await run("docker", ["volume", "rm", state.volumeName], directory);
|
|
1002
|
+
if (!removedVolume.ok)
|
|
1003
|
+
throw new Error(`Could not remove the verified legacy data volume for recreation. ${removedVolume.stderr.trim()}`);
|
|
1004
|
+
}
|
|
1005
|
+
async function ensureManagedDataVolume(directory, mode, state, env) {
|
|
1006
|
+
const existing = await run("docker", ["volume", "inspect", state.volumeName, "--format", "{{.Name}}"], directory);
|
|
1007
|
+
if (existing.ok && existing.stdout.trim() === state.volumeName)
|
|
1008
|
+
return;
|
|
1009
|
+
const created = await run("docker", composeArgs(directory, mode, [
|
|
1010
|
+
"run",
|
|
1011
|
+
"--rm",
|
|
1012
|
+
"-T",
|
|
1013
|
+
"--no-deps",
|
|
1014
|
+
"--entrypoint",
|
|
1015
|
+
"sh",
|
|
1016
|
+
"or3",
|
|
1017
|
+
"-c",
|
|
1018
|
+
"true"
|
|
1019
|
+
]), directory);
|
|
1020
|
+
if (!created.ok)
|
|
1021
|
+
throw new Error(`Could not create the managed data volume. ${redact(created.stderr, secretValues(env))}`);
|
|
1022
|
+
const inspected = await run("docker", ["volume", "inspect", state.volumeName, "--format", "{{json .Labels}}"], directory);
|
|
1023
|
+
if (!inspected.ok)
|
|
1024
|
+
throw new Error(`Could not verify the recreated managed data volume ${state.volumeName}.`);
|
|
1025
|
+
let labels;
|
|
1026
|
+
try {
|
|
1027
|
+
labels = JSON.parse(inspected.stdout.trim());
|
|
1028
|
+
} catch {
|
|
1029
|
+
throw new Error(`Docker returned unreadable labels for recreated managed volume ${state.volumeName}.`);
|
|
1030
|
+
}
|
|
1031
|
+
if (!labels || typeof labels !== "object") {
|
|
1032
|
+
throw new Error(`Recreated volume ${state.volumeName} has no managed deployment labels.`);
|
|
1033
|
+
}
|
|
1034
|
+
if (labels["com.docker.compose.project"] !== state.composeProject || labels["com.docker.compose.volume"] !== "or3-data" || env.OR3_DEPLOYMENT_ID && labels["io.or3.cloud.deployment-id"] !== env.OR3_DEPLOYMENT_ID) {
|
|
1035
|
+
throw new Error(`Recreated volume ${state.volumeName} does not carry the expected managed deployment labels.`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
583
1038
|
async function portAvailable(port) {
|
|
584
1039
|
return new Promise((resolvePromise) => {
|
|
585
1040
|
const server = createServer();
|
|
@@ -588,6 +1043,16 @@ async function portAvailable(port) {
|
|
|
588
1043
|
server.listen(port, "127.0.0.1");
|
|
589
1044
|
});
|
|
590
1045
|
}
|
|
1046
|
+
async function dockerDaemonIsLocal() {
|
|
1047
|
+
const configured = process.env.DOCKER_HOST?.trim();
|
|
1048
|
+
if (configured)
|
|
1049
|
+
return configured.startsWith("unix://") || configured.startsWith("npipe://");
|
|
1050
|
+
const context = await run("docker", ["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]);
|
|
1051
|
+
if (!context.ok)
|
|
1052
|
+
throw new Error(`Could not resolve the active Docker context endpoint. ${context.stderr.trim()}`);
|
|
1053
|
+
const endpoint = context.stdout.trim();
|
|
1054
|
+
return endpoint.startsWith("unix://") || endpoint.startsWith("npipe://");
|
|
1055
|
+
}
|
|
591
1056
|
async function waitForDeepHealth(directory, mode, secrets = []) {
|
|
592
1057
|
try {
|
|
593
1058
|
await waitForDeepHealthWithArgs(composeArgs(directory, mode), directory, secrets);
|
|
@@ -599,9 +1064,84 @@ ${captured}
|
|
|
599
1064
|
Diagnostics: ${diagnostics(directory, mode)}`);
|
|
600
1065
|
}
|
|
601
1066
|
}
|
|
602
|
-
|
|
1067
|
+
function packagedImageDigest(version) {
|
|
1068
|
+
let manifest;
|
|
1069
|
+
try {
|
|
1070
|
+
manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
1071
|
+
} catch {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (manifest.version !== version || manifest.or3Cloud?.imageDigest === undefined)
|
|
1075
|
+
return;
|
|
1076
|
+
const digest = manifest.or3Cloud.imageDigest;
|
|
1077
|
+
if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
|
1078
|
+
throw new Error("This @or3/cloud package contains an invalid release image digest. Refusing to pull an unverified image.");
|
|
1079
|
+
}
|
|
1080
|
+
return digest;
|
|
1081
|
+
}
|
|
1082
|
+
function packagedOperatorImageDigest(version) {
|
|
1083
|
+
let manifest;
|
|
1084
|
+
try {
|
|
1085
|
+
manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
1086
|
+
} catch {
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (manifest.version !== version || manifest.or3Cloud?.operatorImageDigest === undefined)
|
|
1090
|
+
return;
|
|
1091
|
+
const digest = manifest.or3Cloud.operatorImageDigest;
|
|
1092
|
+
if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
|
1093
|
+
throw new Error("This @or3/cloud package contains an invalid dashboard operator image digest. Refusing to enable a mutable privileged runtime.");
|
|
1094
|
+
}
|
|
1095
|
+
return digest;
|
|
1096
|
+
}
|
|
1097
|
+
function packagedSourceRevision(version) {
|
|
1098
|
+
let manifest;
|
|
1099
|
+
try {
|
|
1100
|
+
manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
1101
|
+
} catch {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (manifest.version !== version || manifest.or3Cloud?.sourceRevision === undefined)
|
|
1105
|
+
return;
|
|
1106
|
+
const revision = manifest.or3Cloud.sourceRevision;
|
|
1107
|
+
if (typeof revision !== "string" || !/^[0-9a-f]{40}$/.test(revision)) {
|
|
1108
|
+
throw new Error("This @or3/cloud package contains an invalid source revision. Refusing to run an unbound release image.");
|
|
1109
|
+
}
|
|
1110
|
+
return revision;
|
|
1111
|
+
}
|
|
1112
|
+
function expectedImageDigest(version) {
|
|
1113
|
+
const packaged = packagedImageDigest(version);
|
|
1114
|
+
const supplied = process.env.OR3_EXPECTED_IMAGE_DIGEST?.trim();
|
|
1115
|
+
if (supplied && !/^sha256:[0-9a-f]{64}$/.test(supplied)) {
|
|
1116
|
+
throw new Error("OR3_EXPECTED_IMAGE_DIGEST must be a complete sha256 digest.");
|
|
1117
|
+
}
|
|
1118
|
+
if (packaged && supplied && packaged !== supplied) {
|
|
1119
|
+
throw new Error("The requested image digest does not match the authenticated @or3/cloud package.");
|
|
1120
|
+
}
|
|
1121
|
+
return packaged ?? supplied;
|
|
1122
|
+
}
|
|
1123
|
+
function expectedOperatorImageDigest(version) {
|
|
1124
|
+
const packaged = packagedOperatorImageDigest(version);
|
|
1125
|
+
const supplied = process.env.OR3_EXPECTED_OPERATOR_IMAGE_DIGEST?.trim();
|
|
1126
|
+
if (supplied && !/^sha256:[0-9a-f]{64}$/.test(supplied)) {
|
|
1127
|
+
throw new Error("OR3_EXPECTED_OPERATOR_IMAGE_DIGEST must be a complete sha256 digest.");
|
|
1128
|
+
}
|
|
1129
|
+
if (packaged && supplied && packaged !== supplied) {
|
|
1130
|
+
throw new Error("The requested dashboard operator digest does not match the authenticated @or3/cloud package.");
|
|
1131
|
+
}
|
|
1132
|
+
return packaged ?? supplied;
|
|
1133
|
+
}
|
|
1134
|
+
async function pullImage(image, expectedDigest) {
|
|
603
1135
|
const local = await run("docker", ["image", "inspect", image]);
|
|
604
|
-
if (
|
|
1136
|
+
if (local.ok) {
|
|
1137
|
+
const localDigest = await imageDigest(image);
|
|
1138
|
+
if (!expectedDigest || localDigest === expectedDigest)
|
|
1139
|
+
return localDigest;
|
|
1140
|
+
if (process.env.OR3_CLOUD_SKIP_PULL === "true") {
|
|
1141
|
+
throw new Error(`Local image digest mismatch for ${image}. Expected ${expectedDigest}, found ${localDigest}, and OR3_CLOUD_SKIP_PULL=true prevents downloading the authenticated image.`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (process.env.OR3_CLOUD_SKIP_PULL !== "true") {
|
|
605
1145
|
const result = await run("docker", ["pull", image]);
|
|
606
1146
|
if (!result.ok) {
|
|
607
1147
|
const detail = result.stderr.trim();
|
|
@@ -610,10 +1150,14 @@ async function pullImage(image) {
|
|
|
610
1150
|
}
|
|
611
1151
|
throw new Error(`Could not download ${image}. Check your internet connection and Docker registry access, then retry. ${detail}`);
|
|
612
1152
|
}
|
|
613
|
-
} else
|
|
1153
|
+
} else {
|
|
614
1154
|
throw new Error(`${image} is not available locally and OR3_CLOUD_SKIP_PULL=true prevents downloading it.`);
|
|
615
1155
|
}
|
|
616
|
-
|
|
1156
|
+
const actual = await imageDigest(image);
|
|
1157
|
+
if (expectedDigest && actual !== expectedDigest) {
|
|
1158
|
+
throw new Error(`Published image digest mismatch for ${image}. Expected the package-authenticated ${expectedDigest}, found ${actual}. The image tag may have been replaced; refusing to continue.`);
|
|
1159
|
+
}
|
|
1160
|
+
return actual;
|
|
617
1161
|
}
|
|
618
1162
|
function imageRepository(image) {
|
|
619
1163
|
const reference = image.split("@", 1)[0];
|
|
@@ -621,6 +1165,19 @@ function imageRepository(image) {
|
|
|
621
1165
|
const colon = reference.lastIndexOf(":");
|
|
622
1166
|
return colon > slash ? reference.slice(0, colon) : reference;
|
|
623
1167
|
}
|
|
1168
|
+
function imageAtDigest(image, digest) {
|
|
1169
|
+
if (!/^sha256:[0-9a-f]{64}$/i.test(digest)) {
|
|
1170
|
+
throw new Error(`Could not bind ${image} to a complete immutable image digest.`);
|
|
1171
|
+
}
|
|
1172
|
+
if (image.includes("@")) {
|
|
1173
|
+
if (!image.endsWith(`@${digest}`))
|
|
1174
|
+
throw new Error(`Image reference ${image} does not match expected digest ${digest}.`);
|
|
1175
|
+
return image;
|
|
1176
|
+
}
|
|
1177
|
+
if (process.env.OR3_CLOUD_SKIP_PULL === "true")
|
|
1178
|
+
return image;
|
|
1179
|
+
return `${imageRepository(image)}@${digest}`;
|
|
1180
|
+
}
|
|
624
1181
|
async function requireImageDigest(image, expected, label) {
|
|
625
1182
|
const actual = await imageDigest(image);
|
|
626
1183
|
if (actual !== expected) {
|
|
@@ -629,12 +1186,30 @@ async function requireImageDigest(image, expected, label) {
|
|
|
629
1186
|
return actual;
|
|
630
1187
|
}
|
|
631
1188
|
async function pullAndRequireImage(image, expected, label) {
|
|
632
|
-
const actual = await pullImage(image);
|
|
1189
|
+
const actual = await pullImage(image, expected);
|
|
633
1190
|
if (actual !== expected) {
|
|
634
1191
|
throw new Error(`${label} image digest mismatch for ${image}. Expected ${expected}, found ${actual}. The registry tag may have moved; refusing to mutate the deployment.`);
|
|
635
1192
|
}
|
|
636
1193
|
return actual;
|
|
637
1194
|
}
|
|
1195
|
+
function assertImageReleaseLabels(image, labels, version, expectedRevision) {
|
|
1196
|
+
const revision = labels["org.opencontainers.image.revision"];
|
|
1197
|
+
if (labels["org.opencontainers.image.source"] !== "https://github.com/Saluana/or3-chat" || labels["org.opencontainers.image.version"] !== version || typeof revision !== "string" || !/^[0-9a-f]{40}$/i.test(revision) || expectedRevision !== undefined && revision.toLowerCase() !== expectedRevision) {
|
|
1198
|
+
throw new Error(`OR3 image ${image} does not carry the expected source/version release labels for ${version}.`);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
async function assertImageReleaseIdentity(image, version) {
|
|
1202
|
+
const result = await run("docker", ["image", "inspect", "--format", "{{json .Config.Labels}}", image]);
|
|
1203
|
+
if (!result.ok)
|
|
1204
|
+
throw new Error(`Could not inspect release labels for ${image}. ${result.stderr.trim()}`);
|
|
1205
|
+
let labels;
|
|
1206
|
+
try {
|
|
1207
|
+
labels = JSON.parse(result.stdout.trim());
|
|
1208
|
+
} catch {
|
|
1209
|
+
throw new Error(`OR3 image ${image} has no readable release labels.`);
|
|
1210
|
+
}
|
|
1211
|
+
assertImageReleaseLabels(image, labels, version, packagedSourceRevision(version));
|
|
1212
|
+
}
|
|
638
1213
|
async function imageDigest(image) {
|
|
639
1214
|
const result = await run("docker", ["image", "inspect", "--format", "{{json .RepoDigests}}", image]);
|
|
640
1215
|
if (!result.ok)
|
|
@@ -668,15 +1243,19 @@ function assertSupportedArchitecture(manifest, hostArch) {
|
|
|
668
1243
|
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.`);
|
|
669
1244
|
}
|
|
670
1245
|
}
|
|
671
|
-
function
|
|
672
|
-
|
|
1246
|
+
async function dockerDaemonArchitecture() {
|
|
1247
|
+
const result = await run("docker", ["info", "--format", "{{.Architecture}}"]);
|
|
1248
|
+
if (!result.ok)
|
|
1249
|
+
throw new Error(`Could not determine the selected Docker daemon architecture. ${result.stderr.trim()}`);
|
|
1250
|
+
const architecture = result.stdout.trim().toLowerCase();
|
|
1251
|
+
if (architecture === "arm64" || architecture === "aarch64")
|
|
673
1252
|
return "arm64";
|
|
674
|
-
if (
|
|
1253
|
+
if (architecture === "amd64" || architecture === "x86_64")
|
|
675
1254
|
return "amd64";
|
|
676
|
-
throw new Error(`OR3 supports only linux/amd64 and linux/arm64
|
|
1255
|
+
throw new Error(`OR3 supports only linux/amd64 and linux/arm64 Docker daemons; the selected daemon reports ${architecture || "no architecture"}.`);
|
|
677
1256
|
}
|
|
678
1257
|
async function assertSupportedHostArchitecture(image) {
|
|
679
|
-
const hostArch =
|
|
1258
|
+
const hostArch = await dockerDaemonArchitecture();
|
|
680
1259
|
if (process.env.OR3_CLOUD_SKIP_PULL === "true") {
|
|
681
1260
|
const local = await run("docker", ["image", "inspect", "--format", "{{.Architecture}}", image]);
|
|
682
1261
|
if (local.ok && local.stdout.trim()) {
|
|
@@ -705,13 +1284,149 @@ function composeProjectNames(directory) {
|
|
|
705
1284
|
caddyConfig: `${base}-caddy-config`
|
|
706
1285
|
};
|
|
707
1286
|
}
|
|
1287
|
+
async function assertDockerProjectAbsent(project) {
|
|
1288
|
+
for (const [resource, args] of [
|
|
1289
|
+
["container", ["ps", "-aq", "--filter", `label=com.docker.compose.project=${project}`]],
|
|
1290
|
+
["network", ["network", "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`]]
|
|
1291
|
+
]) {
|
|
1292
|
+
const result = await run("docker", args);
|
|
1293
|
+
if (!result.ok) {
|
|
1294
|
+
throw new Error(`Could not inspect Docker ${resource}s for Compose project ${project}. Refusing to continue while the daemon state is unknown. ${result.stderr.trim()}`);
|
|
1295
|
+
}
|
|
1296
|
+
if (result.stdout.trim()) {
|
|
1297
|
+
throw new Error(`Docker ${resource}s already exist for Compose project ${project}. Choose a different target directory or inspect the existing project before continuing.`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
async function dashboardOperatorEnv(directory, version) {
|
|
1302
|
+
if (process.platform !== "linux" || !process.getuid || !process.getgid)
|
|
1303
|
+
return;
|
|
1304
|
+
const expectedDigest = expectedOperatorImageDigest(version);
|
|
1305
|
+
if (!expectedDigest)
|
|
1306
|
+
return;
|
|
1307
|
+
const configured = process.env.DOCKER_HOST?.trim();
|
|
1308
|
+
if (configured && !configured.startsWith("unix://"))
|
|
1309
|
+
return;
|
|
1310
|
+
const socket = configured ? configured.slice("unix://".length) : "/var/run/docker.sock";
|
|
1311
|
+
if (!isAbsolute(socket))
|
|
1312
|
+
return;
|
|
1313
|
+
let socketStat;
|
|
1314
|
+
let uid;
|
|
1315
|
+
let gid;
|
|
1316
|
+
try {
|
|
1317
|
+
socketStat = await stat(socket);
|
|
1318
|
+
if (!socketStat.isSocket())
|
|
1319
|
+
return;
|
|
1320
|
+
uid = process.getuid();
|
|
1321
|
+
gid = process.getgid();
|
|
1322
|
+
if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0)
|
|
1323
|
+
return;
|
|
1324
|
+
} catch {
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const operatorTag = operatorImageFor(version);
|
|
1328
|
+
let digest;
|
|
1329
|
+
try {
|
|
1330
|
+
digest = await pullImage(operatorTag, expectedDigest);
|
|
1331
|
+
await assertSupportedHostArchitecture(operatorTag);
|
|
1332
|
+
await assertImageReleaseIdentity(operatorTag, version);
|
|
1333
|
+
} catch {
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
return {
|
|
1337
|
+
OR3_DASHBOARD_UPDATES_ENABLED: "true",
|
|
1338
|
+
OR3_OPERATOR_IMAGE: imageAtDigest(operatorTag, digest),
|
|
1339
|
+
OR3_DEPLOYMENT_DIR: resolve(directory),
|
|
1340
|
+
OR3_OPERATOR_UID: String(uid),
|
|
1341
|
+
OR3_OPERATOR_GID: String(gid),
|
|
1342
|
+
OR3_DOCKER_SOCKET: socket,
|
|
1343
|
+
OR3_DOCKER_GID: String(socketStat.gid)
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
async function prepareDashboardOperatorIpc(directory, enabled) {
|
|
1347
|
+
if (!enabled)
|
|
1348
|
+
return;
|
|
1349
|
+
const ipc = deploymentPaths(directory).operatorIpc;
|
|
1350
|
+
await mkdir(ipc, { recursive: true, mode: 456 });
|
|
1351
|
+
await chmod(ipc, 456);
|
|
1352
|
+
}
|
|
1353
|
+
async function verifyDashboardOperatorBridge(directory, operator) {
|
|
1354
|
+
if (!operator)
|
|
1355
|
+
return false;
|
|
1356
|
+
const probe = `.operator-probe-${randomBytes(8).toString("hex")}`;
|
|
1357
|
+
const result = await run("docker", [
|
|
1358
|
+
"run",
|
|
1359
|
+
"--rm",
|
|
1360
|
+
"--network",
|
|
1361
|
+
"none",
|
|
1362
|
+
"--read-only",
|
|
1363
|
+
"--user",
|
|
1364
|
+
`${operator.OR3_OPERATOR_UID}:${operator.OR3_OPERATOR_GID}`,
|
|
1365
|
+
"--group-add",
|
|
1366
|
+
operator.OR3_DOCKER_GID,
|
|
1367
|
+
"--mount",
|
|
1368
|
+
`type=bind,src=${operator.OR3_DOCKER_SOCKET},dst=/var/run/docker.sock`,
|
|
1369
|
+
"--mount",
|
|
1370
|
+
`type=bind,src=${operator.OR3_DEPLOYMENT_DIR},dst=/deployment`,
|
|
1371
|
+
"--workdir",
|
|
1372
|
+
"/deployment",
|
|
1373
|
+
"--tmpfs",
|
|
1374
|
+
"/tmp:rw,noexec,nosuid,size=16m",
|
|
1375
|
+
"--entrypoint",
|
|
1376
|
+
"sh",
|
|
1377
|
+
operator.OR3_OPERATOR_IMAGE,
|
|
1378
|
+
"-c",
|
|
1379
|
+
`docker version --format '{{.Server.Version}}' >/dev/null && umask 077 && : > .or3-cloud/operator-ipc/${probe} && test -O .or3-cloud/operator-ipc/${probe} && rm .or3-cloud/operator-ipc/${probe}`
|
|
1380
|
+
], directory);
|
|
1381
|
+
if (!result.ok) {
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
async function prepareVerifiedDashboardOperator(directory, version) {
|
|
1387
|
+
const candidate = await dashboardOperatorEnv(directory, version);
|
|
1388
|
+
if (!candidate)
|
|
1389
|
+
return;
|
|
1390
|
+
try {
|
|
1391
|
+
await prepareDashboardOperatorIpc(directory, true);
|
|
1392
|
+
if (await verifyDashboardOperatorBridge(directory, candidate))
|
|
1393
|
+
return candidate;
|
|
1394
|
+
} catch {}
|
|
1395
|
+
await rm(deploymentPaths(directory).operatorIpc, { recursive: true, force: true }).catch(() => {
|
|
1396
|
+
return;
|
|
1397
|
+
});
|
|
1398
|
+
console.warn("Dashboard updates are unavailable on this Docker setup; the deployment will remain host-CLI managed.");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
var DASHBOARD_OPERATOR_ENV_KEYS = [
|
|
1402
|
+
"OR3_DASHBOARD_UPDATES_ENABLED",
|
|
1403
|
+
"OR3_OPERATOR_IMAGE",
|
|
1404
|
+
"OR3_DEPLOYMENT_DIR",
|
|
1405
|
+
"OR3_OPERATOR_UID",
|
|
1406
|
+
"OR3_OPERATOR_GID",
|
|
1407
|
+
"OR3_DOCKER_SOCKET",
|
|
1408
|
+
"OR3_DOCKER_GID"
|
|
1409
|
+
];
|
|
1410
|
+
function withoutDashboardOperator(env) {
|
|
1411
|
+
const result = { ...env };
|
|
1412
|
+
for (const key of DASHBOARD_OPERATOR_ENV_KEYS)
|
|
1413
|
+
delete result[key];
|
|
1414
|
+
return result;
|
|
1415
|
+
}
|
|
1416
|
+
function withoutProvisioningCredentials(env) {
|
|
1417
|
+
const result = { ...env };
|
|
1418
|
+
for (const key of PROVISIONING_CREDENTIAL_KEYS)
|
|
1419
|
+
delete result[key];
|
|
1420
|
+
return result;
|
|
1421
|
+
}
|
|
708
1422
|
function buildEnv(input2) {
|
|
709
1423
|
const names = composeProjectNames(input2.directory);
|
|
710
1424
|
const secrets = input2.secrets ?? {};
|
|
711
1425
|
const publicOrigin = input2.mode === "public" ? `https://${input2.domain}` : `http://127.0.0.1:${input2.port}`;
|
|
712
1426
|
const values = {
|
|
713
1427
|
OR3_VERSION: input2.version,
|
|
714
|
-
OR3_IMAGE: imageFor(input2.version),
|
|
1428
|
+
OR3_IMAGE: input2.image ?? imageFor(input2.version),
|
|
1429
|
+
OR3_DEPLOYMENT_ID: input2.deploymentId ?? id("deployment"),
|
|
715
1430
|
OR3_COMPOSE_PROJECT: names.project,
|
|
716
1431
|
OR3_VOLUME_NAME: names.volume,
|
|
717
1432
|
OR3_CADDY_DATA_VOLUME: names.caddyData,
|
|
@@ -732,6 +1447,7 @@ function buildEnv(input2) {
|
|
|
732
1447
|
OR3_BASIC_AUTH_DB_PATH: "/data/auth.sqlite",
|
|
733
1448
|
OR3_BASIC_AUTH_BOOTSTRAP_EMAIL: input2.email,
|
|
734
1449
|
OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: input2.password,
|
|
1450
|
+
OR3_MANAGED_OWNER_EMAIL: input2.email,
|
|
735
1451
|
OR3_AUTH_INVITE_TOKEN_SECRET: secrets.OR3_AUTH_INVITE_TOKEN_SECRET ?? randomSecret(),
|
|
736
1452
|
OR3_SYNC_ENABLED: "true",
|
|
737
1453
|
OR3_CLOUD_SYNC_ENABLED: "true",
|
|
@@ -757,6 +1473,8 @@ function buildEnv(input2) {
|
|
|
757
1473
|
OR3_TRUST_PROXY: input2.mode === "public" ? "true" : "false",
|
|
758
1474
|
OR3_FORWARDED_FOR_HEADER: "x-forwarded-for"
|
|
759
1475
|
};
|
|
1476
|
+
if (input2.dashboardOperator)
|
|
1477
|
+
Object.assign(values, input2.dashboardOperator);
|
|
760
1478
|
return values;
|
|
761
1479
|
}
|
|
762
1480
|
function secretValues(env) {
|
|
@@ -794,14 +1512,142 @@ async function readPassword(flags) {
|
|
|
794
1512
|
validatePassword(password);
|
|
795
1513
|
return password;
|
|
796
1514
|
}
|
|
1515
|
+
function managedAssetNames(mode) {
|
|
1516
|
+
return ["compose.yaml", "compose.operator.yaml", "dashboard-operator.mjs", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
|
|
1517
|
+
}
|
|
1518
|
+
function managedAssetNamesForInventory(mode, inventoryVersion) {
|
|
1519
|
+
if (inventoryVersion === MANAGED_ASSET_INVENTORY_VERSION)
|
|
1520
|
+
return managedAssetNames(mode);
|
|
1521
|
+
if (inventoryVersion === 2) {
|
|
1522
|
+
return ["compose.yaml", "dashboard-operator.mjs", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
|
|
1523
|
+
}
|
|
1524
|
+
return ["compose.yaml", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
|
|
1525
|
+
}
|
|
1526
|
+
async function installManagedAssets(directory, assets) {
|
|
1527
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
1528
|
+
const token = randomBytes(4).toString("hex");
|
|
1529
|
+
const staged = [];
|
|
1530
|
+
let retainRollbackCopies = false;
|
|
1531
|
+
try {
|
|
1532
|
+
for (const [name, contents] of assets) {
|
|
1533
|
+
const destination = join(directory, name);
|
|
1534
|
+
const replacement = `${destination}.next-${token}`;
|
|
1535
|
+
const entry = {
|
|
1536
|
+
destination,
|
|
1537
|
+
replacement
|
|
1538
|
+
};
|
|
1539
|
+
staged.push(entry);
|
|
1540
|
+
await writeFile(replacement, contents, { mode: 420 });
|
|
1541
|
+
await chmod(replacement, 420);
|
|
1542
|
+
if (await fileExists(destination)) {
|
|
1543
|
+
entry.rollback = `${destination}.previous-${token}`;
|
|
1544
|
+
await copyFile(destination, entry.rollback);
|
|
1545
|
+
await chmod(entry.rollback, 420);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
const applied = [];
|
|
1549
|
+
try {
|
|
1550
|
+
for (const entry of staged) {
|
|
1551
|
+
applied.push(entry);
|
|
1552
|
+
await durableRename(entry.replacement, entry.destination);
|
|
1553
|
+
}
|
|
1554
|
+
for (const [name, contents] of assets) {
|
|
1555
|
+
if (!(await readFile(join(directory, name))).equals(contents)) {
|
|
1556
|
+
throw new Error(`Managed asset ${name} did not match its staged replacement after commit.`);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
} catch (error) {
|
|
1560
|
+
const rollbackErrors = [];
|
|
1561
|
+
for (const entry of applied.reverse()) {
|
|
1562
|
+
try {
|
|
1563
|
+
if (entry.rollback)
|
|
1564
|
+
await durableRename(entry.rollback, entry.destination);
|
|
1565
|
+
else
|
|
1566
|
+
await rm(entry.destination, { force: true });
|
|
1567
|
+
} catch (rollbackError) {
|
|
1568
|
+
rollbackErrors.push(rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)));
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (rollbackErrors.length > 0) {
|
|
1572
|
+
retainRollbackCopies = true;
|
|
1573
|
+
throw new AggregateError([error, ...rollbackErrors], "Managed asset installation failed and one or more rollback copies could not be restored. Recovery copies were retained.");
|
|
1574
|
+
}
|
|
1575
|
+
throw error;
|
|
1576
|
+
}
|
|
1577
|
+
} finally {
|
|
1578
|
+
for (const entry of staged) {
|
|
1579
|
+
await rm(entry.replacement, { force: true }).catch(() => {
|
|
1580
|
+
return;
|
|
1581
|
+
});
|
|
1582
|
+
if (entry.rollback && !retainRollbackCopies)
|
|
1583
|
+
await rm(entry.rollback, { force: true }).catch(() => {
|
|
1584
|
+
return;
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
797
1589
|
async function copyAssets(directory, mode) {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1590
|
+
const assets = new Map;
|
|
1591
|
+
for (const name of managedAssetNames(mode)) {
|
|
1592
|
+
assets.set(name, await readFile(join(ASSET_ROOT, name)));
|
|
1593
|
+
}
|
|
1594
|
+
await installManagedAssets(directory, assets);
|
|
1595
|
+
}
|
|
1596
|
+
async function snapshotManagedAssets(directory, mode, backupDir) {
|
|
1597
|
+
const assetDir = join(backupDir, "managed-assets");
|
|
1598
|
+
await mkdir(assetDir, { recursive: true, mode: 448 });
|
|
1599
|
+
await chmod(assetDir, 448);
|
|
1600
|
+
const checksums = {};
|
|
1601
|
+
for (const name of managedAssetNames(mode)) {
|
|
1602
|
+
if ((name === "dashboard-operator.mjs" || name === "compose.operator.yaml") && !await fileExists(join(directory, name)))
|
|
1603
|
+
continue;
|
|
1604
|
+
const destination = join(assetDir, name);
|
|
1605
|
+
await copySecure(join(directory, name), destination);
|
|
1606
|
+
checksums[name] = await sha256File(destination);
|
|
1607
|
+
}
|
|
1608
|
+
return checksums;
|
|
1609
|
+
}
|
|
1610
|
+
async function verifiedManagedAssetContents(backupPath, manifest) {
|
|
1611
|
+
const checksums = manifest.managedAssetSha256;
|
|
1612
|
+
if (!checksums)
|
|
1613
|
+
return;
|
|
1614
|
+
const expected = managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion).sort();
|
|
1615
|
+
const actual = Object.keys(checksums).sort();
|
|
1616
|
+
if (manifest.managedAssetInventoryVersion !== undefined && manifest.managedAssetInventoryVersion !== 2 && manifest.managedAssetInventoryVersion !== MANAGED_ASSET_INVENTORY_VERSION || actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
|
|
1617
|
+
throw new Error(`Backup ${manifest.backupId} has an invalid managed asset inventory.`);
|
|
1618
|
+
}
|
|
1619
|
+
const assets = new Map;
|
|
1620
|
+
for (const name of expected) {
|
|
1621
|
+
const expectedSha = checksums[name];
|
|
1622
|
+
if (!expectedSha || !/^[0-9a-f]{64}$/i.test(expectedSha)) {
|
|
1623
|
+
throw new Error(`Backup ${manifest.backupId} has an invalid checksum for managed asset ${name}.`);
|
|
1624
|
+
}
|
|
1625
|
+
const source = join(backupPath, "managed-assets", name);
|
|
1626
|
+
const actualSha = await sha256File(source);
|
|
1627
|
+
if (actualSha !== expectedSha) {
|
|
1628
|
+
throw new Error(`Backup managed asset checksum mismatch for ${name}. Expected ${expectedSha}, got ${actualSha}.`);
|
|
1629
|
+
}
|
|
1630
|
+
assets.set(name, await readFile(source));
|
|
1631
|
+
}
|
|
1632
|
+
return assets;
|
|
1633
|
+
}
|
|
1634
|
+
async function restoreManagedAssets(directory, backupPath, manifest) {
|
|
1635
|
+
const assets = await verifiedManagedAssetContents(backupPath, manifest);
|
|
1636
|
+
if (!assets) {
|
|
1637
|
+
throw new Error(`Backup ${manifest.backupId} predates authenticated managed-asset snapshots. Refusing to run its image under today's Compose/Caddy assets; restore with the exact historical @or3/cloud release after separately authenticating its assets.`);
|
|
1638
|
+
}
|
|
1639
|
+
await installManagedAssets(directory, assets);
|
|
1640
|
+
if (manifest.managedAssetInventoryVersion !== 2 && manifest.managedAssetInventoryVersion !== MANAGED_ASSET_INVENTORY_VERSION) {
|
|
1641
|
+
await rm(join(directory, "dashboard-operator.mjs"), { force: true });
|
|
1642
|
+
}
|
|
1643
|
+
if (manifest.managedAssetInventoryVersion !== MANAGED_ASSET_INVENTORY_VERSION) {
|
|
1644
|
+
await rm(join(directory, "compose.operator.yaml"), { force: true });
|
|
1645
|
+
}
|
|
1646
|
+
return true;
|
|
1647
|
+
}
|
|
1648
|
+
function assertRestorableManagedAssets(manifest) {
|
|
1649
|
+
if (!manifest.managedAssetSha256) {
|
|
1650
|
+
throw new Error(`Backup ${manifest.backupId} predates authenticated managed-asset snapshots. Refusing to run its image under today's Compose/Caddy assets; restore with the exact historical @or3/cloud release after separately authenticating its assets.`);
|
|
805
1651
|
}
|
|
806
1652
|
}
|
|
807
1653
|
async function checkPublicPrerequisites(domain) {
|
|
@@ -815,6 +1661,10 @@ async function checkPublicPrerequisites(domain) {
|
|
|
815
1661
|
return address;
|
|
816
1662
|
}
|
|
817
1663
|
async function markPending(directory, state, operation) {
|
|
1664
|
+
const dashboardJobId = process.env.OR3_DASHBOARD_UPDATE_JOB_ID?.trim();
|
|
1665
|
+
operation.origin = dashboardJobId ? "dashboard" : "cli";
|
|
1666
|
+
if (dashboardJobId)
|
|
1667
|
+
operation.dashboardJobId = dashboardJobId;
|
|
818
1668
|
state.incompleteOperation = operation;
|
|
819
1669
|
await writeState(directory, state);
|
|
820
1670
|
await writeSecure(join(deploymentPaths(directory).operations, `${operation.id}.json`), `${JSON.stringify(operation, null, 2)}
|
|
@@ -844,10 +1694,29 @@ async function clearPending(directory, state) {
|
|
|
844
1694
|
async function stopProject(directory, mode) {
|
|
845
1695
|
await compose(directory, mode, ["stop", "or3"]);
|
|
846
1696
|
}
|
|
1697
|
+
async function projectServiceRunning(directory, mode) {
|
|
1698
|
+
const result = await run("docker", composeArgs(directory, mode, ["ps", "--status", "running", "-q", "or3"]), directory);
|
|
1699
|
+
if (!result.ok)
|
|
1700
|
+
throw new Error(`Could not determine whether OR3 is running. ${result.stderr.trim()}`);
|
|
1701
|
+
return Boolean(result.stdout.trim());
|
|
1702
|
+
}
|
|
1703
|
+
async function sourceServiceRunning(directory, composeFiles) {
|
|
1704
|
+
const result = await run("docker", [...sourceComposeArgs(directory, composeFiles), "ps", "--status", "running", "-q", "or3"], directory);
|
|
1705
|
+
if (!result.ok)
|
|
1706
|
+
throw new Error(`Could not determine whether the V1 OR3 service is running. ${result.stderr.trim()}`);
|
|
1707
|
+
return Boolean(result.stdout.trim());
|
|
1708
|
+
}
|
|
1709
|
+
async function removeDashboardOperator(directory, mode) {
|
|
1710
|
+
if (!dashboardUpdatesEnabled(directory))
|
|
1711
|
+
return;
|
|
1712
|
+
await compose(directory, mode, ["rm", "--stop", "--force", "or3-operator"]);
|
|
1713
|
+
}
|
|
847
1714
|
async function startProject(directory, mode, env) {
|
|
848
1715
|
await assertSafeComposeBinding(directory, mode, env);
|
|
849
|
-
|
|
1716
|
+
const services = process.env.OR3_DASHBOARD_UPDATE_JOB_ID ? ["or3"] : [];
|
|
1717
|
+
await compose(directory, mode, ["up", "-d", "--wait", "--wait-timeout", "180", ...services], secretValues(env));
|
|
850
1718
|
await waitForDeepHealth(directory, mode, secretValues(env));
|
|
1719
|
+
await assertRunningAppImage(directory, mode, env.OR3_IMAGE);
|
|
851
1720
|
}
|
|
852
1721
|
function backupDirectory(directory, backupId) {
|
|
853
1722
|
return join(deploymentPaths(directory).backups, backupId);
|
|
@@ -862,7 +1731,22 @@ async function dataVolumeSize(directory, mode, env) {
|
|
|
862
1731
|
const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", "du -sb /data 2>/dev/null"])], directory);
|
|
863
1732
|
if (exec.ok)
|
|
864
1733
|
return parseDuOutput(exec.stdout);
|
|
865
|
-
const fallback = await run("docker", [
|
|
1734
|
+
const fallback = await run("docker", [
|
|
1735
|
+
"run",
|
|
1736
|
+
"--rm",
|
|
1737
|
+
"--network",
|
|
1738
|
+
"none",
|
|
1739
|
+
"--read-only",
|
|
1740
|
+
"--cap-drop",
|
|
1741
|
+
"ALL",
|
|
1742
|
+
"-v",
|
|
1743
|
+
`${env.OR3_VOLUME_NAME}:/data:ro`,
|
|
1744
|
+
"--entrypoint",
|
|
1745
|
+
"sh",
|
|
1746
|
+
env.OR3_IMAGE,
|
|
1747
|
+
"-c",
|
|
1748
|
+
"du -sb /data 2>/dev/null"
|
|
1749
|
+
], directory);
|
|
866
1750
|
if (fallback.ok)
|
|
867
1751
|
return parseDuOutput(fallback.stdout);
|
|
868
1752
|
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}`)}`);
|
|
@@ -882,37 +1766,41 @@ async function removeNamedBackupArtifact(directory, backupId) {
|
|
|
882
1766
|
await rm(target, { recursive: true, force: true });
|
|
883
1767
|
await rm(join(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
|
|
884
1768
|
}
|
|
885
|
-
async function
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
return manifest;
|
|
891
|
-
} catch {
|
|
892
|
-
return null;
|
|
1769
|
+
async function removeEnumeratedBackupArtifact(directory, backup) {
|
|
1770
|
+
const backupsRoot = resolve(deploymentPaths(directory).backups);
|
|
1771
|
+
const target = resolve(backup.path);
|
|
1772
|
+
if (dirname(target) !== backupsRoot || basename(target) !== backup.backupId || !BACKUP_ID_PATTERN.test(backup.backupId)) {
|
|
1773
|
+
throw new Error(`Refusing to prune an untrusted backup path ${backup.path}.`);
|
|
893
1774
|
}
|
|
1775
|
+
await rm(target, { recursive: true, force: true });
|
|
1776
|
+
await rm(join(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
|
|
894
1777
|
}
|
|
895
1778
|
async function enumerateBackups(directory) {
|
|
896
1779
|
const backupsRoot = deploymentPaths(directory).backups;
|
|
897
1780
|
let entries = [];
|
|
898
1781
|
try {
|
|
899
1782
|
entries = await readdir(backupsRoot);
|
|
900
|
-
} catch {
|
|
901
|
-
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
if (error.code === "ENOENT")
|
|
1785
|
+
return [];
|
|
1786
|
+
throw new Error(`Could not enumerate managed backups at ${backupsRoot}: ${error instanceof Error ? error.message : String(error)}`);
|
|
902
1787
|
}
|
|
903
1788
|
const result = [];
|
|
904
1789
|
for (const entry of entries) {
|
|
1790
|
+
if (!BACKUP_ID_PATTERN.test(entry)) {
|
|
1791
|
+
throw new Error(`Backup store contains an unexpected artifact ${join(backupsRoot, entry)}. Refusing to use it for retention; inspect or remove it explicitly.`);
|
|
1792
|
+
}
|
|
905
1793
|
const path = join(backupsRoot, entry);
|
|
906
|
-
const manifest = await
|
|
907
|
-
if (
|
|
908
|
-
|
|
1794
|
+
const manifest = await readManifest(path, directory);
|
|
1795
|
+
if (manifest.backupId !== entry) {
|
|
1796
|
+
throw new Error(`Backup directory ${path} does not match manifest ID ${manifest.backupId}. Refusing to use it for retention.`);
|
|
1797
|
+
}
|
|
1798
|
+
if (!Number.isFinite(Date.parse(manifest.createdAt))) {
|
|
1799
|
+
throw new Error(`Backup ${manifest.backupId} has an invalid creation time.`);
|
|
1800
|
+
}
|
|
909
1801
|
let bytes = 0;
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
bytes += (await stat(join(path, file))).size;
|
|
913
|
-
}
|
|
914
|
-
} catch {
|
|
915
|
-
bytes = 0;
|
|
1802
|
+
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
|
|
1803
|
+
bytes += (await stat(join(path, file))).size;
|
|
916
1804
|
}
|
|
917
1805
|
result.push({
|
|
918
1806
|
backupId: manifest.backupId,
|
|
@@ -952,8 +1840,11 @@ async function pruneBackups(directory, state, keep, force) {
|
|
|
952
1840
|
console.log(`--force --yes will permanently delete: ${targets.join(", ")}`);
|
|
953
1841
|
}
|
|
954
1842
|
for (const backupId of targets) {
|
|
955
|
-
|
|
956
|
-
|
|
1843
|
+
const backup = backups.find((entry) => entry.backupId === backupId);
|
|
1844
|
+
if (!backup)
|
|
1845
|
+
throw new Error(`Retention selected backup ${backupId}, but its verified path disappeared before deletion.`);
|
|
1846
|
+
await removeEnumeratedBackupArtifact(directory, backup);
|
|
1847
|
+
console.log(`Deleted backup ${backupId} at ${backup.path}`);
|
|
957
1848
|
}
|
|
958
1849
|
return targets.length;
|
|
959
1850
|
}
|
|
@@ -973,9 +1864,31 @@ function assertDeploymentIdentity(state, env) {
|
|
|
973
1864
|
throw new Error(`Managed state does not match ${key} in .env. Refusing to operate on an unexpected deployment identity.`);
|
|
974
1865
|
}
|
|
975
1866
|
}
|
|
1867
|
+
const pending = state.incompleteOperation;
|
|
1868
|
+
let pendingTargetImage;
|
|
1869
|
+
if (pending?.targetImage) {
|
|
1870
|
+
try {
|
|
1871
|
+
pendingTargetImage = pending.targetImageDigest ? imageAtDigest(pending.targetImage, pending.targetImageDigest) : pending.targetImage;
|
|
1872
|
+
} catch {
|
|
1873
|
+
pendingTargetImage = undefined;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
const journaledTarget = Boolean(pending && (pending.operation === "update" || pending.operation === "restore" || pending.operation === "rollback") && pending.targetVersion && pendingTargetImage && env.OR3_VERSION === pending.targetVersion && env.OR3_IMAGE === pendingTargetImage);
|
|
1877
|
+
const journaledIdentityMigration = Boolean(journaledTarget && !state.deploymentId && pending?.targetDeploymentId && env[DEPLOYMENT_ID_ENV_KEY] === pending.targetDeploymentId);
|
|
1878
|
+
if (state.deploymentId || env[DEPLOYMENT_ID_ENV_KEY]) {
|
|
1879
|
+
if ((!state.deploymentId || env[DEPLOYMENT_ID_ENV_KEY] !== state.deploymentId) && !journaledIdentityMigration) {
|
|
1880
|
+
throw new Error("Managed state does not match OR3_DEPLOYMENT_ID in .env. Refusing to operate on an unexpected deployment identity.");
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
if ((env.OR3_VERSION !== state.appVersion || env.OR3_IMAGE !== state.image) && !journaledTarget) {
|
|
1884
|
+
throw new Error("Managed state does not match OR3_VERSION or OR3_IMAGE in .env. Refusing to create a backup or mutate an unverified release configuration.");
|
|
1885
|
+
}
|
|
976
1886
|
}
|
|
977
1887
|
function assertDeploymentDirectoryIdentity(directory, state) {
|
|
978
1888
|
const resolved = resolve(directory);
|
|
1889
|
+
if (state.deploymentRoot && resolve(state.deploymentRoot) !== resolved) {
|
|
1890
|
+
throw new Error(`Managed deployment root is ${state.deploymentRoot}, not ${resolved}. Refusing to operate on a copied or relocated deployment; perform an explicit relocation before mutation.`);
|
|
1891
|
+
}
|
|
979
1892
|
const names = composeProjectNames(resolved);
|
|
980
1893
|
const mismatches = [];
|
|
981
1894
|
if (state.composeProject !== names.project) {
|
|
@@ -1029,6 +1942,12 @@ function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
|
|
|
1029
1942
|
if (manifest.caddyConfigVolume !== undefined && manifest.caddyConfigVolume !== state.caddyConfigVolume) {
|
|
1030
1943
|
throw new Error(`Backup ${manifest.backupId} belongs to a different Caddy config volume.`);
|
|
1031
1944
|
}
|
|
1945
|
+
if (state.deploymentId && (manifest.deploymentId !== state.deploymentId || backupEnv.OR3_DEPLOYMENT_ID !== state.deploymentId)) {
|
|
1946
|
+
const legacySnapshot = manifest.deploymentId === undefined && backupEnv.OR3_DEPLOYMENT_ID === undefined;
|
|
1947
|
+
if (!legacySnapshot) {
|
|
1948
|
+
throw new Error(`Backup ${manifest.backupId} belongs to a different immutable deployment identity.`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1032
1951
|
if (manifest.port !== undefined && manifest.port !== state.port) {
|
|
1033
1952
|
throw new Error(`Backup ${manifest.backupId} belongs to port ${manifest.port}, not ${state.port}.`);
|
|
1034
1953
|
}
|
|
@@ -1043,14 +1962,35 @@ async function sha256File(path) {
|
|
|
1043
1962
|
});
|
|
1044
1963
|
return digest.digest("hex");
|
|
1045
1964
|
}
|
|
1965
|
+
function terminateChildProcess(child) {
|
|
1966
|
+
if (child.killed)
|
|
1967
|
+
return;
|
|
1968
|
+
if (process.platform !== "win32" && child.pid) {
|
|
1969
|
+
try {
|
|
1970
|
+
process.kill(-child.pid, "SIGKILL");
|
|
1971
|
+
return;
|
|
1972
|
+
} catch {}
|
|
1973
|
+
}
|
|
1974
|
+
child.kill("SIGKILL");
|
|
1975
|
+
}
|
|
1046
1976
|
async function streamCommandToFile(command, args, destination, cwd, secrets = []) {
|
|
1047
1977
|
if (await fileExists(destination)) {
|
|
1048
1978
|
throw new Error(`Refusing to overwrite existing archive ${destination}.`);
|
|
1049
1979
|
}
|
|
1050
1980
|
await mkdir(dirname(destination), { recursive: true, mode: 448 });
|
|
1051
1981
|
const temporary = `${destination}.${randomBytes(8).toString("hex")}.partial`;
|
|
1052
|
-
const child = spawn(command, args, {
|
|
1982
|
+
const child = spawn(command, args, {
|
|
1983
|
+
cwd,
|
|
1984
|
+
env: commandEnvironment(command, args, cwd),
|
|
1985
|
+
detached: process.platform !== "win32",
|
|
1986
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1987
|
+
});
|
|
1053
1988
|
let stderr = "";
|
|
1989
|
+
let timedOut = false;
|
|
1990
|
+
const timeout = setTimeout(() => {
|
|
1991
|
+
timedOut = true;
|
|
1992
|
+
terminateChildProcess(child);
|
|
1993
|
+
}, STREAM_COMMAND_TIMEOUT_MS);
|
|
1054
1994
|
child.stderr.setEncoding("utf8");
|
|
1055
1995
|
child.stderr.on("data", (chunk) => {
|
|
1056
1996
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
@@ -1064,25 +2004,38 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
1064
2004
|
exit,
|
|
1065
2005
|
pipeline(child.stdout, createWriteStream(temporary, { flags: "wx", mode: 384 }))
|
|
1066
2006
|
]);
|
|
2007
|
+
if (timedOut)
|
|
2008
|
+
throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
|
|
1067
2009
|
if (exitCode !== 0) {
|
|
1068
2010
|
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
1069
2011
|
}
|
|
1070
2012
|
await chmod(temporary, 384);
|
|
1071
|
-
await
|
|
2013
|
+
await durableRename(temporary, destination);
|
|
1072
2014
|
} catch (error) {
|
|
1073
|
-
|
|
1074
|
-
child.kill("SIGTERM");
|
|
2015
|
+
terminateChildProcess(child);
|
|
1075
2016
|
await rm(temporary, { force: true }).catch(() => {
|
|
1076
2017
|
return;
|
|
1077
2018
|
});
|
|
1078
2019
|
const detail = error instanceof Error ? error.message : String(error);
|
|
1079
2020
|
throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
|
|
1080
2021
|
${redact(stderr, secrets)}` : ""}`);
|
|
2022
|
+
} finally {
|
|
2023
|
+
clearTimeout(timeout);
|
|
1081
2024
|
}
|
|
1082
2025
|
}
|
|
1083
2026
|
async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
1084
|
-
const child = spawn(command, args, {
|
|
2027
|
+
const child = spawn(command, args, {
|
|
2028
|
+
cwd,
|
|
2029
|
+
env: commandEnvironment(command, args, cwd),
|
|
2030
|
+
detached: process.platform !== "win32",
|
|
2031
|
+
stdio: ["pipe", "ignore", "pipe"]
|
|
2032
|
+
});
|
|
1085
2033
|
let stderr = "";
|
|
2034
|
+
let timedOut = false;
|
|
2035
|
+
const timeout = setTimeout(() => {
|
|
2036
|
+
timedOut = true;
|
|
2037
|
+
terminateChildProcess(child);
|
|
2038
|
+
}, STREAM_COMMAND_TIMEOUT_MS);
|
|
1086
2039
|
child.stderr.setEncoding("utf8");
|
|
1087
2040
|
child.stderr.on("data", (chunk) => {
|
|
1088
2041
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
@@ -1096,15 +2049,18 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
|
1096
2049
|
exit,
|
|
1097
2050
|
pipeline(createReadStream(source), child.stdin)
|
|
1098
2051
|
]);
|
|
2052
|
+
if (timedOut)
|
|
2053
|
+
throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
|
|
1099
2054
|
if (exitCode !== 0) {
|
|
1100
2055
|
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
1101
2056
|
}
|
|
1102
2057
|
} catch (error) {
|
|
1103
|
-
|
|
1104
|
-
child.kill("SIGTERM");
|
|
2058
|
+
terminateChildProcess(child);
|
|
1105
2059
|
const detail = error instanceof Error ? error.message : String(error);
|
|
1106
2060
|
throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
|
|
1107
2061
|
${redact(stderr, secrets)}` : ""}`);
|
|
2062
|
+
} finally {
|
|
2063
|
+
clearTimeout(timeout);
|
|
1108
2064
|
}
|
|
1109
2065
|
}
|
|
1110
2066
|
async function gzipUncompressedBytes(path) {
|
|
@@ -1122,38 +2078,121 @@ async function volumeArchive(directory, mode, env, backupDir) {
|
|
|
1122
2078
|
await streamCommandToFile("docker", composeArgs(directory, mode, [
|
|
1123
2079
|
"run",
|
|
1124
2080
|
"--rm",
|
|
1125
|
-
"--no-deps",
|
|
2081
|
+
"--no-deps",
|
|
2082
|
+
"--entrypoint",
|
|
2083
|
+
"sh",
|
|
2084
|
+
"or3",
|
|
2085
|
+
"-c",
|
|
2086
|
+
"tar czf - -C /data ."
|
|
2087
|
+
]), join(backupDir, "data.tgz"), directory, secretValues(env));
|
|
2088
|
+
}
|
|
2089
|
+
async function archiveExternalVolume(image, volume, backupDir) {
|
|
2090
|
+
await streamCommandToFile("docker", [
|
|
2091
|
+
"run",
|
|
2092
|
+
"--rm",
|
|
2093
|
+
"--network",
|
|
2094
|
+
"none",
|
|
2095
|
+
"--read-only",
|
|
2096
|
+
"--user",
|
|
2097
|
+
"0:0",
|
|
2098
|
+
"--security-opt",
|
|
2099
|
+
"no-new-privileges:true",
|
|
2100
|
+
"--cap-drop",
|
|
2101
|
+
"ALL",
|
|
2102
|
+
"--cap-add",
|
|
2103
|
+
"DAC_READ_SEARCH",
|
|
2104
|
+
"--tmpfs",
|
|
2105
|
+
"/tmp:rw,noexec,nosuid,size=16m",
|
|
2106
|
+
"-v",
|
|
2107
|
+
`${volume}:/source:ro`,
|
|
2108
|
+
image,
|
|
2109
|
+
"sh",
|
|
2110
|
+
"-c",
|
|
2111
|
+
"tar czf - -C /source ."
|
|
2112
|
+
], join(backupDir, "data.tgz"));
|
|
2113
|
+
}
|
|
2114
|
+
async function restoreVolumeArchive(directory, mode, env, backupPath) {
|
|
2115
|
+
const state = await readState(directory);
|
|
2116
|
+
await ensureManagedDataVolume(directory, mode, state, env);
|
|
2117
|
+
const clear = await run("docker", [
|
|
2118
|
+
"run",
|
|
2119
|
+
"--rm",
|
|
2120
|
+
"--network",
|
|
2121
|
+
"none",
|
|
2122
|
+
"--user",
|
|
2123
|
+
"0:0",
|
|
2124
|
+
"--read-only",
|
|
2125
|
+
"--cap-drop",
|
|
2126
|
+
"ALL",
|
|
2127
|
+
"--cap-add",
|
|
2128
|
+
"DAC_OVERRIDE",
|
|
2129
|
+
"--cap-add",
|
|
2130
|
+
"FOWNER",
|
|
2131
|
+
"--security-opt",
|
|
2132
|
+
"no-new-privileges:true",
|
|
2133
|
+
"-v",
|
|
2134
|
+
`${state.volumeName}:/data`,
|
|
2135
|
+
"--entrypoint",
|
|
2136
|
+
"sh",
|
|
2137
|
+
env.OR3_IMAGE,
|
|
2138
|
+
"-c",
|
|
2139
|
+
"find /data -mindepth 1 -delete"
|
|
2140
|
+
], directory);
|
|
2141
|
+
if (!clear.ok) {
|
|
2142
|
+
throw new Error(`Could not safely clear the managed data volume before restore. ${redact(clear.stderr, secretValues(env))}`);
|
|
2143
|
+
}
|
|
2144
|
+
await streamFileToCommand("docker", [
|
|
2145
|
+
"run",
|
|
2146
|
+
"--rm",
|
|
2147
|
+
"-i",
|
|
2148
|
+
"--network",
|
|
2149
|
+
"none",
|
|
2150
|
+
"--read-only",
|
|
2151
|
+
"--security-opt",
|
|
2152
|
+
"no-new-privileges:true",
|
|
2153
|
+
"--cap-drop",
|
|
2154
|
+
"ALL",
|
|
2155
|
+
"-v",
|
|
2156
|
+
`${state.volumeName}:/data`,
|
|
1126
2157
|
"--entrypoint",
|
|
1127
2158
|
"sh",
|
|
1128
|
-
|
|
2159
|
+
env.OR3_IMAGE,
|
|
1129
2160
|
"-c",
|
|
1130
|
-
"tar
|
|
1131
|
-
]
|
|
1132
|
-
}
|
|
1133
|
-
async function archiveExternalVolume(image, volume, backupDir) {
|
|
1134
|
-
await streamCommandToFile("docker", ["run", "--rm", "--user", "0:0", "-v", `${volume}:/source:ro`, image, "sh", "-c", "tar czf - -C /source ."], join(backupDir, "data.tgz"));
|
|
2161
|
+
"tar xzf - -C /data"
|
|
2162
|
+
], join(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1135
2163
|
}
|
|
1136
|
-
async function
|
|
1137
|
-
await streamFileToCommand("docker",
|
|
2164
|
+
async function validateVolumeArchive(directory, _mode, env, backupPath) {
|
|
2165
|
+
await streamFileToCommand("docker", [
|
|
1138
2166
|
"run",
|
|
1139
2167
|
"--rm",
|
|
1140
|
-
"-
|
|
1141
|
-
"--
|
|
2168
|
+
"-i",
|
|
2169
|
+
"--network",
|
|
2170
|
+
"none",
|
|
2171
|
+
"--read-only",
|
|
2172
|
+
"--security-opt",
|
|
2173
|
+
"no-new-privileges:true",
|
|
2174
|
+
"--cap-drop",
|
|
2175
|
+
"ALL",
|
|
1142
2176
|
"--entrypoint",
|
|
1143
2177
|
"sh",
|
|
1144
|
-
|
|
2178
|
+
env.OR3_IMAGE,
|
|
1145
2179
|
"-c",
|
|
1146
|
-
"
|
|
1147
|
-
]
|
|
2180
|
+
"tar tzf - >/dev/null"
|
|
2181
|
+
], join(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1148
2182
|
}
|
|
1149
|
-
async function createBackup(directory, state, env) {
|
|
2183
|
+
async function createBackup(directory, state, env, options = {}) {
|
|
2184
|
+
const restartAfter = options.restartAfter ?? true;
|
|
1150
2185
|
await requireImageDigest(state.image, state.imageDigest, "Current deployment");
|
|
1151
|
-
const backupId = id("backup");
|
|
2186
|
+
const backupId = options.backupId ?? id("backup");
|
|
2187
|
+
if (!BACKUP_ID_PATTERN.test(backupId))
|
|
2188
|
+
throw new Error(`Backup ID ${backupId} is invalid.`);
|
|
1152
2189
|
const backupDir = backupDirectory(directory, backupId);
|
|
2190
|
+
const initiallyRunning = options.initiallyRunning ?? await projectServiceRunning(directory, state.mode);
|
|
1153
2191
|
const volumeSize = await dataVolumeSize(directory, state.mode, env);
|
|
1154
2192
|
await assertFreeSpaceForArchive(backupDir, volumeSize, "Backup");
|
|
1155
2193
|
let manifestWritten = false;
|
|
1156
2194
|
let stopAttempted = false;
|
|
2195
|
+
let backupFailure;
|
|
1157
2196
|
try {
|
|
1158
2197
|
await mkdir(backupDir, { recursive: true, mode: 448 });
|
|
1159
2198
|
await chmod(backupDir, 448);
|
|
@@ -1161,6 +2200,7 @@ async function createBackup(directory, state, env) {
|
|
|
1161
2200
|
await stopProject(directory, state.mode);
|
|
1162
2201
|
await volumeArchive(directory, state.mode, env, backupDir);
|
|
1163
2202
|
await copySecure(deploymentPaths(directory).env, join(backupDir, "config.env"));
|
|
2203
|
+
const managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
|
|
1164
2204
|
const manifest = {
|
|
1165
2205
|
schemaVersion: 1,
|
|
1166
2206
|
backupId,
|
|
@@ -1171,44 +2211,54 @@ async function createBackup(directory, state, env) {
|
|
|
1171
2211
|
dataSha256: await sha256File(join(backupDir, "data.tgz")),
|
|
1172
2212
|
dataBytes: volumeSize,
|
|
1173
2213
|
configSha256: await sha256File(join(backupDir, "config.env")),
|
|
2214
|
+
managedAssetSha256,
|
|
2215
|
+
managedAssetInventoryVersion: managedAssetSha256["compose.operator.yaml"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256["dashboard-operator.mjs"] ? 2 : undefined,
|
|
1174
2216
|
mode: state.mode,
|
|
1175
2217
|
domain: state.domain,
|
|
1176
2218
|
composeProject: state.composeProject,
|
|
1177
2219
|
volumeName: state.volumeName,
|
|
1178
2220
|
caddyDataVolume: state.caddyDataVolume,
|
|
1179
2221
|
caddyConfigVolume: state.caddyConfigVolume,
|
|
2222
|
+
deploymentId: state.deploymentId,
|
|
1180
2223
|
port: state.port
|
|
1181
2224
|
};
|
|
1182
|
-
|
|
1183
|
-
|
|
2225
|
+
const manifestContents = `${JSON.stringify(manifest, null, 2)}
|
|
2226
|
+
`;
|
|
2227
|
+
await writeSecure(join(backupDir, "manifest.json"), manifestContents);
|
|
2228
|
+
await writeBackupAuthentication(directory, backupDir, manifestContents);
|
|
2229
|
+
const verifiedManifest = await readManifest(backupDir, directory);
|
|
1184
2230
|
manifestWritten = true;
|
|
1185
|
-
|
|
1186
|
-
await pruneBackups(directory, state, BACKUP_RETENTION_KEEP, false);
|
|
1187
|
-
} catch (error) {
|
|
1188
|
-
console.log(`Warning: automatic backup retention could not run: ${error instanceof Error ? error.message : String(error)}`);
|
|
1189
|
-
}
|
|
1190
|
-
return { backupId, backupDir, manifest };
|
|
2231
|
+
return { backupId, backupDir, manifest: verifiedManifest };
|
|
1191
2232
|
} catch (error) {
|
|
1192
2233
|
if (!manifestWritten) {
|
|
1193
2234
|
await removeNamedBackupArtifact(directory, backupId).catch(() => {
|
|
1194
2235
|
return;
|
|
1195
2236
|
});
|
|
1196
|
-
|
|
2237
|
+
backupFailure = new Error(`${error instanceof Error ? error.message : String(error)} The partial backup artifact at ${backupDir} was removed.`);
|
|
2238
|
+
throw backupFailure;
|
|
1197
2239
|
}
|
|
2240
|
+
backupFailure = error instanceof Error ? error : new Error(String(error));
|
|
1198
2241
|
throw error;
|
|
1199
2242
|
} finally {
|
|
1200
|
-
if (stopAttempted) {
|
|
2243
|
+
if (stopAttempted && restartAfter && initiallyRunning) {
|
|
1201
2244
|
try {
|
|
1202
2245
|
await startProject(directory, state.mode, env);
|
|
1203
2246
|
} catch (error) {
|
|
2247
|
+
const restartFailure = error instanceof Error ? error : new Error(String(error));
|
|
2248
|
+
if (backupFailure) {
|
|
2249
|
+
throw new AggregateError([backupFailure, restartFailure], `Backup failed and OR3 could not restart. Primary failure: ${backupFailure.message}. Restart failure: ${restartFailure.message}`);
|
|
2250
|
+
}
|
|
1204
2251
|
throw new Error(`Backup was created but OR3 could not restart: ${error instanceof Error ? error.message : String(error)}`);
|
|
1205
2252
|
}
|
|
1206
2253
|
}
|
|
1207
2254
|
}
|
|
1208
2255
|
}
|
|
1209
|
-
async function readManifest(backupPath) {
|
|
1210
|
-
const
|
|
1211
|
-
if (
|
|
2256
|
+
async function readManifest(backupPath, authenticatedForDirectory) {
|
|
2257
|
+
const manifestContents = await readText(join(backupPath, "manifest.json"));
|
|
2258
|
+
if (authenticatedForDirectory)
|
|
2259
|
+
await assertBackupAuthentication(authenticatedForDirectory, backupPath, manifestContents);
|
|
2260
|
+
const manifest = JSON.parse(manifestContents);
|
|
2261
|
+
if (manifest.schemaVersion !== 1 || !BACKUP_ID_PATTERN.test(manifest.backupId) || !Number.isFinite(Date.parse(manifest.createdAt)) || !isVersion(manifest.appVersion) || !manifest.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest.imageDigest) || !/^[0-9a-f]{64}$/i.test(manifest.dataSha256) || !/^[0-9a-f]{64}$/i.test(manifest.configSha256 ?? "") || manifest.dataBytes !== undefined && (!Number.isSafeInteger(manifest.dataBytes) || manifest.dataBytes < 0)) {
|
|
1212
2262
|
throw new Error(`Invalid backup manifest at ${backupPath}.`);
|
|
1213
2263
|
}
|
|
1214
2264
|
const actual = await sha256File(join(backupPath, "data.tgz"));
|
|
@@ -1219,8 +2269,21 @@ async function readManifest(backupPath) {
|
|
|
1219
2269
|
if (configActual !== manifest.configSha256)
|
|
1220
2270
|
throw new Error(`Backup configuration checksum mismatch for ${manifest.backupId}.`);
|
|
1221
2271
|
}
|
|
2272
|
+
await verifiedManagedAssetContents(backupPath, manifest);
|
|
1222
2273
|
return manifest;
|
|
1223
2274
|
}
|
|
2275
|
+
async function cleanupJournaledPartialBackup(directory, backupId, backupPath) {
|
|
2276
|
+
if (!backupId || !backupPath || !BACKUP_ID_PATTERN.test(backupId))
|
|
2277
|
+
return;
|
|
2278
|
+
const expectedPath = resolve(backupDirectory(directory, backupId));
|
|
2279
|
+
if (resolve(backupPath) !== expectedPath || !await fileExists(expectedPath))
|
|
2280
|
+
return;
|
|
2281
|
+
try {
|
|
2282
|
+
await readManifest(expectedPath, directory);
|
|
2283
|
+
} catch {
|
|
2284
|
+
await removeNamedBackupArtifact(directory, backupId);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
1224
2287
|
async function dataVolumeFreeBytes(directory, mode, env) {
|
|
1225
2288
|
const parse = (stdout) => {
|
|
1226
2289
|
const blocks = Number(stdout.trim());
|
|
@@ -1232,7 +2295,22 @@ async function dataVolumeFreeBytes(directory, mode, env) {
|
|
|
1232
2295
|
const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", script])], directory);
|
|
1233
2296
|
if (exec.ok)
|
|
1234
2297
|
return parse(exec.stdout);
|
|
1235
|
-
const fallback = await run("docker", [
|
|
2298
|
+
const fallback = await run("docker", [
|
|
2299
|
+
"run",
|
|
2300
|
+
"--rm",
|
|
2301
|
+
"--network",
|
|
2302
|
+
"none",
|
|
2303
|
+
"--read-only",
|
|
2304
|
+
"--cap-drop",
|
|
2305
|
+
"ALL",
|
|
2306
|
+
"-v",
|
|
2307
|
+
`${env.OR3_VOLUME_NAME}:/data:ro`,
|
|
2308
|
+
"--entrypoint",
|
|
2309
|
+
"sh",
|
|
2310
|
+
env.OR3_IMAGE,
|
|
2311
|
+
"-c",
|
|
2312
|
+
script
|
|
2313
|
+
], directory);
|
|
1236
2314
|
if (fallback.ok)
|
|
1237
2315
|
return parse(fallback.stdout);
|
|
1238
2316
|
throw new Error(`Could not measure free space in the Docker data volume. ${redact(`${exec.stderr} ${fallback.stderr}`, secretValues(env))}`);
|
|
@@ -1241,10 +2319,12 @@ async function assertRestoreFreeSpace(directory, state, env, manifest, backupPat
|
|
|
1241
2319
|
const archiveBytes = await gzipUncompressedBytes(join(backupPath, "data.tgz"));
|
|
1242
2320
|
const requiredBytes = Math.max(manifest.dataBytes ?? 0, archiveBytes);
|
|
1243
2321
|
const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
|
|
1244
|
-
|
|
2322
|
+
const reclaimableBytes = await dataVolumeSize(directory, state.mode, env);
|
|
2323
|
+
assertEnoughFreeSpace(freeBytes + reclaimableBytes, requiredBytes, "Restore");
|
|
1245
2324
|
}
|
|
1246
|
-
async function restoreBackupData(directory, state, env, backupPath) {
|
|
1247
|
-
const manifest = await readManifest(backupPath);
|
|
2325
|
+
async function restoreBackupData(directory, state, env, backupPath, options = {}) {
|
|
2326
|
+
const manifest = await readManifest(backupPath, directory);
|
|
2327
|
+
assertRestorableManagedAssets(manifest);
|
|
1248
2328
|
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
1249
2329
|
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1250
2330
|
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
@@ -1252,29 +2332,28 @@ async function restoreBackupData(directory, state, env, backupPath) {
|
|
|
1252
2332
|
assertBackupMatchesDeployment(manifest, backupEnv, state, env);
|
|
1253
2333
|
if (manifest.imageDigest)
|
|
1254
2334
|
await requireImageDigest(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
2335
|
+
await validateVolumeArchive(directory, state.mode, env, backupPath);
|
|
2336
|
+
await ensureManagedDataVolume(directory, state.mode, state, env);
|
|
1255
2337
|
await assertRestoreFreeSpace(directory, state, env, manifest, backupPath);
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
2338
|
+
const restoresOperatorOverlay = manifest.managedAssetInventoryVersion === MANAGED_ASSET_INVENTORY_VERSION && Boolean(manifest.managedAssetSha256?.["compose.operator.yaml"]);
|
|
2339
|
+
const restoredEnv = restoresOperatorOverlay ? {
|
|
2340
|
+
...backupEnv,
|
|
2341
|
+
OR3_IMAGE: imageAtDigest(manifest.image, manifest.imageDigest)
|
|
2342
|
+
} : withoutDashboardOperator({
|
|
2343
|
+
...backupEnv,
|
|
2344
|
+
OR3_IMAGE: imageAtDigest(manifest.image, manifest.imageDigest)
|
|
2345
|
+
});
|
|
2346
|
+
if (options.recreateDataVolume)
|
|
2347
|
+
await removeManagedDataVolumeForRecreation(directory, state);
|
|
2348
|
+
else
|
|
1260
2349
|
await stopProject(directory, state.mode);
|
|
1261
|
-
|
|
1262
|
-
await
|
|
1263
|
-
const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
1264
|
-
await startProject(directory, state.mode, restoredEnv);
|
|
1265
|
-
started = true;
|
|
1266
|
-
} catch (error) {
|
|
1267
|
-
let restartError = "";
|
|
1268
|
-
if (stopAttempted && !started) {
|
|
1269
|
-
try {
|
|
1270
|
-
const recoveryEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
1271
|
-
await startProject(directory, state.mode, recoveryEnv);
|
|
1272
|
-
} catch (recovery) {
|
|
1273
|
-
restartError = ` OR3 could not be restarted: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
throw new Error(`Restore failed for ${manifest.backupId}: ${error instanceof Error ? error.message : String(error)}${restartError}`);
|
|
2350
|
+
if (dashboardUpdatesEnabled(directory) && restoredEnv.OR3_DASHBOARD_UPDATES_ENABLED !== "true" && !process.env.OR3_DASHBOARD_UPDATE_JOB_ID) {
|
|
2351
|
+
await removeDashboardOperator(directory, state.mode);
|
|
1277
2352
|
}
|
|
2353
|
+
await writeSecure(deploymentPaths(directory).env, serializeEnv(restoredEnv));
|
|
2354
|
+
await restoreManagedAssets(directory, backupPath, manifest);
|
|
2355
|
+
await restoreVolumeArchive(directory, state.mode, restoredEnv, backupPath);
|
|
2356
|
+
await startProject(directory, state.mode, restoredEnv);
|
|
1278
2357
|
return manifest;
|
|
1279
2358
|
}
|
|
1280
2359
|
function stateFromEnv(directory, env, mode, operation, digest) {
|
|
@@ -1285,6 +2364,8 @@ function stateFromEnv(directory, env, mode, operation, digest) {
|
|
|
1285
2364
|
volumeName: env.OR3_VOLUME_NAME,
|
|
1286
2365
|
caddyDataVolume: mode === "public" ? env.OR3_CADDY_DATA_VOLUME : undefined,
|
|
1287
2366
|
caddyConfigVolume: mode === "public" ? env.OR3_CADDY_CONFIG_VOLUME : undefined,
|
|
2367
|
+
deploymentId: env.OR3_DEPLOYMENT_ID,
|
|
2368
|
+
deploymentRoot: resolve(directory),
|
|
1288
2369
|
appVersion: env.OR3_VERSION,
|
|
1289
2370
|
image: env.OR3_IMAGE,
|
|
1290
2371
|
imageDigest: digest,
|
|
@@ -1304,8 +2385,9 @@ Usage:
|
|
|
1304
2385
|
npx @or3/cloud backup [list|prune [--keep <n>]|export <backup-id> <destination-dir>]
|
|
1305
2386
|
npx @or3/cloud restore <backup-id-or-path> --yes
|
|
1306
2387
|
npx @or3/cloud rollback --yes
|
|
1307
|
-
npx @or3/cloud credentials reset --yes [--owner-password <
|
|
2388
|
+
npx @or3/cloud credentials reset --yes [--owner-password-file <path> --admin-password-file <path>]
|
|
1308
2389
|
npx @or3/cloud doctor
|
|
2390
|
+
npx @or3/cloud verify [--public] [--verification-email <email> --verification-password-file <path>]
|
|
1309
2391
|
npx @or3/cloud recover
|
|
1310
2392
|
npx @or3/cloud adopt --from <v1-directory> [directory]
|
|
1311
2393
|
npx @or3/cloud status
|
|
@@ -1316,12 +2398,16 @@ Usage:
|
|
|
1316
2398
|
Options:
|
|
1317
2399
|
--admin-email <email> Administrator email for first login
|
|
1318
2400
|
--admin-password <password> Explicit password (prefer --admin-password-file); for credentials reset, the new admin password
|
|
1319
|
-
--admin-password-file <path> Read the bootstrap password without shell history
|
|
2401
|
+
--admin-password-file <path> Read the bootstrap/reset admin password without shell history
|
|
1320
2402
|
--owner-password <password> New owner (basic auth) password for credentials reset
|
|
2403
|
+
--owner-password-file <path> Read the new owner password without shell history
|
|
2404
|
+
--verification-email <email> Current owner email used only for this verification
|
|
2405
|
+
--verification-password-file <path> Read the current owner password for this verification
|
|
1321
2406
|
--port <port> Local OR3 port (default: 3000)
|
|
1322
2407
|
--keep <n> Backups to retain when pruning (default: 5)
|
|
1323
2408
|
--force Prune backups even when referenced by the rollback point
|
|
1324
2409
|
--tail <n> Log lines to show (default: 200)
|
|
2410
|
+
--public Require verification through the public HTTPS origin
|
|
1325
2411
|
--purge-data Remove data volumes and managed files (with remove)
|
|
1326
2412
|
--yes Confirm a destructive restore, rollback, credentials reset, or purge
|
|
1327
2413
|
--help Show this help
|
|
@@ -1351,9 +2437,10 @@ async function initCommand(positionals, flags) {
|
|
|
1351
2437
|
throw new Error("--port must be a valid TCP port.");
|
|
1352
2438
|
if (mode === "public" && [80, 443].includes(port))
|
|
1353
2439
|
throw new Error("Public mode reserves ports 80 and 443 for Caddy; choose another --port for OR3.");
|
|
1354
|
-
|
|
2440
|
+
const localDockerDaemon = await dockerDaemonIsLocal();
|
|
2441
|
+
if (localDockerDaemon && !await portAvailable(port))
|
|
1355
2442
|
throw new Error(`Port ${port} is already in use. Choose another port with --port.`);
|
|
1356
|
-
if (mode === "public") {
|
|
2443
|
+
if (mode === "public" && localDockerDaemon) {
|
|
1357
2444
|
for (const publicPort of [80, 443]) {
|
|
1358
2445
|
if (!await portAvailable(publicPort))
|
|
1359
2446
|
throw new Error(`Public port ${publicPort} is already in use. Stop the conflicting service before starting Caddy.`);
|
|
@@ -1361,23 +2448,31 @@ async function initCommand(positionals, flags) {
|
|
|
1361
2448
|
}
|
|
1362
2449
|
await ensureDocker();
|
|
1363
2450
|
const names = composeProjectNames(directory);
|
|
2451
|
+
await assertDockerProjectAbsent(names.project);
|
|
1364
2452
|
for (const volume of [names.volume, ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
|
|
1365
2453
|
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
1366
2454
|
if (existing.ok)
|
|
1367
2455
|
throw new Error(`Docker volume ${volume} already exists. Choose a new directory or inspect it before initializing.`);
|
|
2456
|
+
if (existing.exitCode !== 1 || !/no such volume/i.test(`${existing.stdout}
|
|
2457
|
+
${existing.stderr}`)) {
|
|
2458
|
+
throw new Error(`Could not confirm whether Docker volume ${volume} exists. Refusing initialization until Docker returns an explicit not-found result. ${existing.stderr.trim()}`);
|
|
2459
|
+
}
|
|
1368
2460
|
}
|
|
1369
2461
|
const email = await resolveAdminEmail(flags);
|
|
1370
2462
|
const password = await readPassword(flags);
|
|
1371
2463
|
const version = PACKAGE_VERSION;
|
|
1372
|
-
const
|
|
1373
|
-
await pullImage(
|
|
1374
|
-
await assertSupportedHostArchitecture(
|
|
2464
|
+
const imageTag = imageFor(version);
|
|
2465
|
+
const digest = await pullImage(imageTag, expectedImageDigest(version));
|
|
2466
|
+
await assertSupportedHostArchitecture(imageTag);
|
|
2467
|
+
await assertImageReleaseIdentity(imageTag, version);
|
|
2468
|
+
const image = imageAtDigest(imageTag, digest);
|
|
1375
2469
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
1376
2470
|
await chmod(directory, 448);
|
|
1377
2471
|
await mkdir(deploymentPaths(directory).operations, { recursive: true, mode: 448 });
|
|
1378
2472
|
await mkdir(deploymentPaths(directory).backups, { recursive: true, mode: 448 });
|
|
2473
|
+
const operator = await prepareVerifiedDashboardOperator(directory, version);
|
|
1379
2474
|
await copyAssets(directory, mode);
|
|
1380
|
-
const env = buildEnv({ mode, version, directory, email, password, domain, port });
|
|
2475
|
+
const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
|
|
1381
2476
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
|
|
1382
2477
|
await writeSecure(join(directory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
1383
2478
|
bootstrapEmail: email,
|
|
@@ -1385,7 +2480,6 @@ async function initCommand(positionals, flags) {
|
|
|
1385
2480
|
adminUsername: email,
|
|
1386
2481
|
adminPassword: password
|
|
1387
2482
|
}));
|
|
1388
|
-
const digest = await imageDigest(image);
|
|
1389
2483
|
const state = stateFromEnv(directory, env, mode, "init", digest);
|
|
1390
2484
|
await markPending(directory, state, {
|
|
1391
2485
|
id: id("init"),
|
|
@@ -1395,12 +2489,19 @@ async function initCommand(positionals, flags) {
|
|
|
1395
2489
|
});
|
|
1396
2490
|
try {
|
|
1397
2491
|
await startProject(directory, mode, env);
|
|
2492
|
+
await provisionManagedCredentials(mode === "public" ? new URL(`https://${domain}`) : new URL(`http://127.0.0.1:${port}`), email, password);
|
|
2493
|
+
const runtimeEnv = withoutProvisioningCredentials(env);
|
|
2494
|
+
await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
|
|
2495
|
+
await stopProject(directory, mode);
|
|
2496
|
+
await startProject(directory, mode, runtimeEnv);
|
|
1398
2497
|
await clearPending(directory, state);
|
|
1399
2498
|
console.log(`
|
|
1400
2499
|
OR3 Cloud ${version} is running at ${mode === "public" ? `https://${domain}` : `http://127.0.0.1:${port}`}`);
|
|
1401
2500
|
console.log(`Credentials were written to ${join(directory, ".or3-initial-credentials")} (mode 0600). Save them, then remove that file.`);
|
|
1402
2501
|
console.log(`
|
|
1403
2502
|
Check: cd ${quote(directory)} && npx @or3/cloud doctor`);
|
|
2503
|
+
if (operator)
|
|
2504
|
+
console.log("Dashboard updates are enabled for super admins in Operations.");
|
|
1404
2505
|
} catch (error) {
|
|
1405
2506
|
state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(env));
|
|
1406
2507
|
await writeState(directory, state);
|
|
@@ -1416,6 +2517,7 @@ async function loadManaged(directory = process.cwd()) {
|
|
|
1416
2517
|
const state = await readState(resolved);
|
|
1417
2518
|
const env = parseEnv(await readText(deploymentPaths(resolved).env));
|
|
1418
2519
|
assertDeploymentIdentity(state, env);
|
|
2520
|
+
assertDeploymentDirectoryIdentity(resolved, state);
|
|
1419
2521
|
return { directory: resolved, state, env };
|
|
1420
2522
|
}
|
|
1421
2523
|
function operationToStateOperation(operation, fallback) {
|
|
@@ -1427,6 +2529,14 @@ async function commitRecoveredState(directory, previous, next) {
|
|
|
1427
2529
|
await removeOperationRecord(directory, previous.incompleteOperation?.id);
|
|
1428
2530
|
await writeState(directory, next);
|
|
1429
2531
|
}
|
|
2532
|
+
async function commitPreMutationRecovery(directory, state, message) {
|
|
2533
|
+
const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
2534
|
+
const recovered = stateFromEnv(directory, restoredEnv, state.mode, state.lastSuccessfulOperation, await imageDigest(restoredEnv.OR3_IMAGE));
|
|
2535
|
+
recovered.rollback = state.rollback;
|
|
2536
|
+
recovered.lastError = message;
|
|
2537
|
+
await commitRecoveredState(directory, state, recovered);
|
|
2538
|
+
return recovered;
|
|
2539
|
+
}
|
|
1430
2540
|
async function recoverCommand(directory) {
|
|
1431
2541
|
await ensureDocker();
|
|
1432
2542
|
const loaded = await loadManaged(directory);
|
|
@@ -1436,6 +2546,14 @@ async function recoverCommand(directory) {
|
|
|
1436
2546
|
return;
|
|
1437
2547
|
}
|
|
1438
2548
|
try {
|
|
2549
|
+
if (pending.phase === "prepared") {
|
|
2550
|
+
if (pending.operation === "backup" || pending.operation === "update" || pending.operation === "adopt") {
|
|
2551
|
+
await cleanupJournaledPartialBackup(loaded.directory, pending.backupId, pending.backupPath);
|
|
2552
|
+
}
|
|
2553
|
+
if (pending.operation === "restore" || pending.operation === "rollback") {
|
|
2554
|
+
await cleanupJournaledPartialBackup(loaded.directory, pending.previousBackupId, pending.previousBackupPath);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
1439
2557
|
if (pending.operation === "credentials-reset") {
|
|
1440
2558
|
const nextEnv = pending.credentialReset?.nextEnv;
|
|
1441
2559
|
if (!nextEnv)
|
|
@@ -1447,91 +2565,69 @@ async function recoverCommand(directory) {
|
|
|
1447
2565
|
return;
|
|
1448
2566
|
}
|
|
1449
2567
|
if (pending.operation === "restore" || pending.operation === "rollback") {
|
|
1450
|
-
if (
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
2568
|
+
if (pending.phase === "prepared" || pending.phase === "snapshot-created") {
|
|
2569
|
+
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
2570
|
+
loaded.state.lastError = undefined;
|
|
2571
|
+
await clearPending(loaded.directory, loaded.state);
|
|
2572
|
+
console.log(`Recovered the incomplete ${pending.operation} before data replacement. OR3 ${loaded.state.appVersion} is deeply healthy.`);
|
|
2573
|
+
return;
|
|
1457
2574
|
}
|
|
1458
|
-
if (pending.
|
|
1459
|
-
throw new Error(`
|
|
2575
|
+
if (!pending.previousBackupPath && !pending.previousBackupId) {
|
|
2576
|
+
throw new Error(`The incomplete ${pending.operation} has no verified pre-mutation snapshot. Refusing to replay a target that may have been partially restored; inspect the deployment and restore an authenticated backup explicitly.`);
|
|
1460
2577
|
}
|
|
1461
|
-
|
|
1462
|
-
await
|
|
1463
|
-
|
|
1464
|
-
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1465
|
-
const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
|
|
1466
|
-
restored.lastError = undefined;
|
|
1467
|
-
await commitRecoveredState(loaded.directory, loaded.state, restored);
|
|
1468
|
-
console.log(`Recovered the incomplete ${pending.operation} operation from backup ${manifest.backupId}. OR3 ${restored.appVersion} is deeply healthy.`);
|
|
2578
|
+
const previous = await restorePreMutationSnapshot(loaded.directory, loaded.state, loaded.env);
|
|
2579
|
+
const recovered2 = await commitPreMutationRecovery(loaded.directory, loaded.state, `Interrupted ${pending.operation} was rolled back to pre-mutation snapshot ${previous.manifest.backupId}.`);
|
|
2580
|
+
console.log(`Recovered the incomplete ${pending.operation} by restoring pre-mutation snapshot ${previous.manifest.backupId}. OR3 ${recovered2.appVersion} is deeply healthy.`);
|
|
1469
2581
|
return;
|
|
1470
2582
|
}
|
|
1471
2583
|
if (pending.operation === "update") {
|
|
1472
|
-
|
|
1473
|
-
if (oldDeployment) {
|
|
2584
|
+
if (pending.phase === "prepared" || pending.phase === "snapshot-created") {
|
|
1474
2585
|
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
1475
2586
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
await commitRecoveredState(loaded.directory, loaded.state,
|
|
1480
|
-
console.log(`Recovered the incomplete update before the replacement was applied. OR3 ${
|
|
2587
|
+
const recovered3 = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(loaded.env.OR3_IMAGE));
|
|
2588
|
+
recovered3.rollback = loaded.state.rollback;
|
|
2589
|
+
recovered3.lastError = undefined;
|
|
2590
|
+
await commitRecoveredState(loaded.directory, loaded.state, recovered3);
|
|
2591
|
+
console.log(`Recovered the incomplete update before the replacement was applied. OR3 ${recovered3.appVersion} is deeply healthy.`);
|
|
1481
2592
|
return;
|
|
1482
2593
|
}
|
|
1483
|
-
if (!pending.
|
|
1484
|
-
throw new Error("The incomplete update has
|
|
2594
|
+
if (!pending.backupPath && !pending.backupId) {
|
|
2595
|
+
throw new Error("The incomplete update has no verified pre-update snapshot. Refusing to guess which image or data should be live.");
|
|
1485
2596
|
}
|
|
1486
|
-
const
|
|
1487
|
-
const
|
|
1488
|
-
|
|
1489
|
-
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1490
|
-
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1491
|
-
}
|
|
1492
|
-
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1493
|
-
try {
|
|
1494
|
-
if (pending.targetImageDigest)
|
|
1495
|
-
await pullAndRequireImage(pending.targetImage, pending.targetImageDigest, `OR3 ${pending.targetVersion}`);
|
|
1496
|
-
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1497
|
-
} catch (replacementError) {
|
|
1498
|
-
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
|
|
1499
|
-
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1500
|
-
const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(restoredEnv.OR3_IMAGE));
|
|
1501
|
-
restored.rollback = loaded.state.rollback;
|
|
1502
|
-
restored.lastError = `Interrupted update was restored: ${redact(replacementError instanceof Error ? replacementError.message : String(replacementError), secretValues(restoredEnv))}`;
|
|
1503
|
-
await commitRecoveredState(loaded.directory, loaded.state, restored);
|
|
1504
|
-
console.log(`Recovered the incomplete update by restoring backup ${manifest.backupId}. OR3 ${restored.appVersion} is deeply healthy.`);
|
|
1505
|
-
return;
|
|
1506
|
-
}
|
|
1507
|
-
const updated = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, "update", await imageDigest(loaded.env.OR3_IMAGE));
|
|
1508
|
-
updated.rollback = {
|
|
1509
|
-
appVersion: loaded.state.appVersion,
|
|
1510
|
-
image: loaded.state.image,
|
|
1511
|
-
imageDigest: loaded.state.imageDigest,
|
|
1512
|
-
backupId: pending.backupId,
|
|
1513
|
-
createdAt: now()
|
|
1514
|
-
};
|
|
1515
|
-
updated.lastError = undefined;
|
|
1516
|
-
await commitRecoveredState(loaded.directory, loaded.state, updated);
|
|
1517
|
-
console.log(`Recovered the incomplete update. OR3 ${updated.appVersion} is deeply healthy.`);
|
|
2597
|
+
const previous = await restorePreMutationSnapshot(loaded.directory, loaded.state, loaded.env);
|
|
2598
|
+
const recovered2 = await commitPreMutationRecovery(loaded.directory, loaded.state, `Interrupted update was rolled back to pre-update snapshot ${previous.manifest.backupId}.`);
|
|
2599
|
+
console.log(`Recovered the incomplete update by restoring pre-update snapshot ${previous.manifest.backupId}. OR3 ${recovered2.appVersion} is deeply healthy.`);
|
|
1518
2600
|
return;
|
|
1519
2601
|
}
|
|
1520
2602
|
if (pending.operation === "adopt") {
|
|
1521
2603
|
if (!pending.backupId)
|
|
1522
2604
|
throw new Error("The incomplete adoption has no source backup ID. Refusing to claim that data was transferred.");
|
|
1523
2605
|
const sourceBackupPath = await resolveBackup(loaded.directory, pending.backupId);
|
|
1524
|
-
const sourceManifest = await readManifest(sourceBackupPath);
|
|
2606
|
+
const sourceManifest = await readManifest(sourceBackupPath, loaded.directory);
|
|
1525
2607
|
if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
|
|
1526
2608
|
throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
|
|
1527
2609
|
}
|
|
1528
2610
|
await stopProject(loaded.directory, loaded.state.mode);
|
|
1529
2611
|
await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
|
|
1530
2612
|
}
|
|
2613
|
+
if (pending.operation === "backup" && pending.initialAppRunning === false) {
|
|
2614
|
+
loaded.state.lastError = undefined;
|
|
2615
|
+
await clearPending(loaded.directory, loaded.state);
|
|
2616
|
+
console.log("Recovered the incomplete backup operation and preserved the intentionally stopped OR3 service.");
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
1531
2619
|
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
1532
2620
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1533
|
-
|
|
1534
|
-
|
|
2621
|
+
let recoveredEnv = loaded.env;
|
|
2622
|
+
if ((pending.operation === "init" || pending.operation === "adopt") && loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL && loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD && loaded.env.OR3_ADMIN_PASSWORD) {
|
|
2623
|
+
await provisionManagedCredentials(loaded.state.mode === "public" ? new URL(`https://${loaded.env.OR3_PUBLIC_DOMAIN}`) : new URL(`http://127.0.0.1:${loaded.env.OR3_PORT}`), loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL, loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD, loaded.env.OR3_ADMIN_USERNAME, loaded.env.OR3_ADMIN_PASSWORD);
|
|
2624
|
+
recoveredEnv = withoutProvisioningCredentials(loaded.env);
|
|
2625
|
+
await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(recoveredEnv));
|
|
2626
|
+
await stopProject(loaded.directory, loaded.state.mode);
|
|
2627
|
+
await startProject(loaded.directory, loaded.state.mode, recoveredEnv);
|
|
2628
|
+
}
|
|
2629
|
+
const digest = await imageDigest(recoveredEnv.OR3_IMAGE);
|
|
2630
|
+
const recovered = stateFromEnv(loaded.directory, recoveredEnv, loaded.state.mode, operationToStateOperation(pending.operation, loaded.state.lastSuccessfulOperation), digest);
|
|
1535
2631
|
recovered.rollback = loaded.state.rollback;
|
|
1536
2632
|
recovered.lastError = undefined;
|
|
1537
2633
|
await commitRecoveredState(loaded.directory, loaded.state, recovered);
|
|
@@ -1549,7 +2645,9 @@ async function recoverCommand(directory) {
|
|
|
1549
2645
|
const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
|
|
1550
2646
|
if (sourceModeValue === "public")
|
|
1551
2647
|
sourceFiles.push("-f", join(pending.sourceDirectory, "compose.public.yaml"));
|
|
1552
|
-
|
|
2648
|
+
if (pending.sourceInitiallyRunning !== false) {
|
|
2649
|
+
await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
|
|
2650
|
+
}
|
|
1553
2651
|
} catch (recovery) {
|
|
1554
2652
|
loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
1555
2653
|
}
|
|
@@ -1567,16 +2665,23 @@ async function backupCreateCommand(directory) {
|
|
|
1567
2665
|
await ensureDocker();
|
|
1568
2666
|
const loaded = await loadManaged(directory);
|
|
1569
2667
|
assertNoPending(loaded.state);
|
|
2668
|
+
const backupId = id("backup");
|
|
2669
|
+
const initialAppRunning = await projectServiceRunning(loaded.directory, loaded.state.mode);
|
|
1570
2670
|
const pending = {
|
|
1571
|
-
id: id("backup"),
|
|
2671
|
+
id: id("backup-operation"),
|
|
1572
2672
|
operation: "backup",
|
|
1573
2673
|
startedAt: now(),
|
|
1574
|
-
message: "Creating a stopped-volume backup"
|
|
2674
|
+
message: "Creating a stopped-volume backup",
|
|
2675
|
+
backupId,
|
|
2676
|
+
backupPath: backupDirectory(loaded.directory, backupId),
|
|
2677
|
+
initialAppRunning,
|
|
2678
|
+
phase: "prepared"
|
|
1575
2679
|
};
|
|
1576
2680
|
await markPending(loaded.directory, loaded.state, pending);
|
|
1577
2681
|
try {
|
|
1578
|
-
const result = await createBackup(loaded.directory, loaded.state, loaded.env);
|
|
2682
|
+
const result = await createBackup(loaded.directory, loaded.state, loaded.env, { backupId, initiallyRunning: initialAppRunning });
|
|
1579
2683
|
await clearPending(loaded.directory, loaded.state);
|
|
2684
|
+
await pruneBackups(loaded.directory, loaded.state, BACKUP_RETENTION_KEEP, false);
|
|
1580
2685
|
console.log(`Backup ${result.backupId} created at ${result.backupDir}`);
|
|
1581
2686
|
console.log(`SHA-256: ${result.manifest.dataSha256}`);
|
|
1582
2687
|
} catch (error) {
|
|
@@ -1612,21 +2717,34 @@ async function backupPruneCommand(directory, flags) {
|
|
|
1612
2717
|
}
|
|
1613
2718
|
async function backupExportCommand(directory, backupId, destination) {
|
|
1614
2719
|
const backupPath = await resolveBackup(directory, backupId);
|
|
1615
|
-
const manifest = await readManifest(backupPath);
|
|
2720
|
+
const manifest = await readManifest(backupPath, directory);
|
|
1616
2721
|
const dest = resolve(destination);
|
|
1617
2722
|
const source = resolve(backupPath);
|
|
2723
|
+
const deploymentRoot = await realpath(directory);
|
|
1618
2724
|
if (source === dest || dest.startsWith(`${source}${sep}`)) {
|
|
1619
2725
|
throw new Error("Choose a destination directory different from the backup itself.");
|
|
1620
2726
|
}
|
|
1621
2727
|
if (await fileExists(dest))
|
|
1622
2728
|
throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
|
|
1623
2729
|
await mkdir(dirname(dest), { recursive: true, mode: 448 });
|
|
2730
|
+
const canonicalDestination = join(await realpath(dirname(dest)), basename(dest));
|
|
2731
|
+
if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`)) {
|
|
2732
|
+
throw new Error("Backup exports must live outside the managed deployment directory so `remove --purge-data` can never delete the remaining copy.");
|
|
2733
|
+
}
|
|
1624
2734
|
await mkdir(dest, { mode: 448 });
|
|
1625
2735
|
try {
|
|
2736
|
+
if (await realpath(dest) !== canonicalDestination) {
|
|
2737
|
+
throw new Error("Backup export destination changed while it was being created. Refusing to write an export through an unexpected path.");
|
|
2738
|
+
}
|
|
1626
2739
|
await chmod(dest, 448);
|
|
1627
|
-
for (const file of ["data.tgz", "config.env", "manifest.json"]) {
|
|
2740
|
+
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
|
|
1628
2741
|
await copySecure(join(backupPath, file), join(dest, file));
|
|
1629
2742
|
}
|
|
2743
|
+
if (manifest.managedAssetSha256) {
|
|
2744
|
+
for (const name of managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion)) {
|
|
2745
|
+
await copySecure(join(backupPath, "managed-assets", name), join(dest, "managed-assets", name));
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
1630
2748
|
const dataSha = await sha256File(join(dest, "data.tgz"));
|
|
1631
2749
|
if (dataSha !== manifest.dataSha256) {
|
|
1632
2750
|
throw new Error(`Exported data.tgz checksum mismatch for ${manifest.backupId}. Expected ${manifest.dataSha256}, got ${dataSha}.`);
|
|
@@ -1637,6 +2755,7 @@ async function backupExportCommand(directory, backupId, destination) {
|
|
|
1637
2755
|
throw new Error(`Exported config.env checksum mismatch for ${manifest.backupId}.`);
|
|
1638
2756
|
}
|
|
1639
2757
|
}
|
|
2758
|
+
await readManifest(dest, directory);
|
|
1640
2759
|
const destinationDevice = (await stat(dest)).dev;
|
|
1641
2760
|
await mkdir(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
|
|
1642
2761
|
const receipt = {
|
|
@@ -1650,7 +2769,12 @@ async function backupExportCommand(directory, backupId, destination) {
|
|
|
1650
2769
|
};
|
|
1651
2770
|
await writeSecure(join(deploymentPaths(directory).exports, `${manifest.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
|
|
1652
2771
|
`);
|
|
1653
|
-
|
|
2772
|
+
let bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size + (await stat(join(dest, "manifest.auth"))).size;
|
|
2773
|
+
if (manifest.managedAssetSha256) {
|
|
2774
|
+
for (const name of managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion)) {
|
|
2775
|
+
bytes += (await stat(join(dest, "managed-assets", name))).size;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
1654
2778
|
console.log(`Exported backup ${manifest.backupId} (${bytes} bytes) to ${dest}`);
|
|
1655
2779
|
console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
|
|
1656
2780
|
if ((await stat(backupPath)).dev === destinationDevice) {
|
|
@@ -1697,12 +2821,19 @@ async function updateCommand(directory, flags) {
|
|
|
1697
2821
|
const targetVersion = stringFlag(flags, "to")?.trim() ?? PACKAGE_VERSION;
|
|
1698
2822
|
if (!isVersion(targetVersion))
|
|
1699
2823
|
throw new Error(`--to must be a complete semantic version such as ${PACKAGE_VERSION}.`);
|
|
2824
|
+
if (targetVersion !== PACKAGE_VERSION) {
|
|
2825
|
+
throw new Error(`This CLI contains deployment assets for OR3 ${PACKAGE_VERSION}. Run \`npx --yes @or3/cloud@${targetVersion} update --to ${targetVersion}\` so the image and generated assets match.`);
|
|
2826
|
+
}
|
|
1700
2827
|
if (targetVersion === state.appVersion)
|
|
1701
2828
|
throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
|
|
1702
|
-
const
|
|
1703
|
-
const targetDigest = await pullImage(
|
|
1704
|
-
await assertSupportedHostArchitecture(
|
|
2829
|
+
const targetImageTag = imageFor(targetVersion);
|
|
2830
|
+
const targetDigest = await pullImage(targetImageTag, expectedImageDigest(targetVersion));
|
|
2831
|
+
await assertSupportedHostArchitecture(targetImageTag);
|
|
2832
|
+
await assertImageReleaseIdentity(targetImageTag, targetVersion);
|
|
2833
|
+
const targetImage = imageAtDigest(targetImageTag, targetDigest);
|
|
1705
2834
|
const oldEnv = { ...env };
|
|
2835
|
+
const targetDeploymentId = env.OR3_DEPLOYMENT_ID ?? state.deploymentId ?? id("deployment");
|
|
2836
|
+
const backupId = id("backup");
|
|
1706
2837
|
const pending = {
|
|
1707
2838
|
id: id("update"),
|
|
1708
2839
|
operation: "update",
|
|
@@ -1710,49 +2841,75 @@ async function updateCommand(directory, flags) {
|
|
|
1710
2841
|
message: `Updating from ${state.appVersion} to ${targetVersion}`,
|
|
1711
2842
|
targetVersion,
|
|
1712
2843
|
targetImage,
|
|
1713
|
-
targetImageDigest: targetDigest
|
|
2844
|
+
targetImageDigest: targetDigest,
|
|
2845
|
+
targetDeploymentId,
|
|
2846
|
+
backupId,
|
|
2847
|
+
backupPath: backupDirectory(loaded.directory, backupId),
|
|
2848
|
+
phase: "prepared"
|
|
1714
2849
|
};
|
|
1715
2850
|
await markPending(loaded.directory, state, pending);
|
|
1716
2851
|
try {
|
|
1717
|
-
const backup = await createBackup(loaded.directory, state, env);
|
|
1718
|
-
await updatePending(loaded.directory, state, {
|
|
1719
|
-
|
|
2852
|
+
const backup = await createBackup(loaded.directory, state, env, { restartAfter: false, backupId });
|
|
2853
|
+
await updatePending(loaded.directory, state, {
|
|
2854
|
+
backupId: backup.backupId,
|
|
2855
|
+
backupPath: backup.backupDir,
|
|
2856
|
+
backupDataSha256: backup.manifest.dataSha256,
|
|
2857
|
+
backupConfigSha256: backup.manifest.configSha256,
|
|
2858
|
+
phase: "snapshot-created"
|
|
2859
|
+
});
|
|
2860
|
+
const operator = await prepareVerifiedDashboardOperator(loaded.directory, targetVersion);
|
|
2861
|
+
if (env.OR3_DASHBOARD_UPDATES_ENABLED === "true" && !operator && !process.env.OR3_DASHBOARD_UPDATE_JOB_ID) {
|
|
2862
|
+
await removeDashboardOperator(loaded.directory, state.mode);
|
|
2863
|
+
}
|
|
2864
|
+
const nextEnv = {
|
|
2865
|
+
...withoutDashboardOperator(env),
|
|
2866
|
+
OR3_VERSION: targetVersion,
|
|
2867
|
+
OR3_IMAGE: targetImage,
|
|
2868
|
+
OR3_DEPLOYMENT_ID: targetDeploymentId,
|
|
2869
|
+
...operator ?? {}
|
|
2870
|
+
};
|
|
1720
2871
|
const previousRootOwnership = await managedVolumeRootOwnership(targetImage, state.volumeName);
|
|
1721
2872
|
const migrateLegacyVolume = previousRootOwnership.uid !== MANAGED_RUNTIME_UID || previousRootOwnership.gid !== MANAGED_RUNTIME_GID;
|
|
2873
|
+
const recreateDataVolume = updateRequiresVolumeRecreation(state, env);
|
|
2874
|
+
await updatePending(loaded.directory, state, {
|
|
2875
|
+
phase: "target-mutating",
|
|
2876
|
+
previousRootOwnership,
|
|
2877
|
+
recreateDataVolume
|
|
2878
|
+
});
|
|
1722
2879
|
try {
|
|
1723
|
-
|
|
1724
|
-
|
|
2880
|
+
if (recreateDataVolume) {
|
|
2881
|
+
await removeManagedDataVolumeForRecreation(loaded.directory, state);
|
|
2882
|
+
} else {
|
|
2883
|
+
await stopProject(loaded.directory, state.mode);
|
|
2884
|
+
}
|
|
2885
|
+
if (migrateLegacyVolume && !recreateDataVolume) {
|
|
1725
2886
|
await setManagedVolumeRootOwnership(targetImage, state.volumeName, {
|
|
1726
2887
|
uid: MANAGED_RUNTIME_UID,
|
|
1727
2888
|
gid: MANAGED_RUNTIME_GID
|
|
1728
2889
|
});
|
|
1729
2890
|
}
|
|
1730
2891
|
await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
|
|
1731
|
-
|
|
2892
|
+
await copyAssets(loaded.directory, state.mode);
|
|
2893
|
+
if (migrateLegacyVolume || recreateDataVolume) {
|
|
1732
2894
|
await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
|
|
1733
2895
|
}
|
|
1734
2896
|
await startProject(loaded.directory, state.mode, nextEnv);
|
|
1735
2897
|
} catch (error) {
|
|
1736
|
-
await
|
|
2898
|
+
await updatePending(loaded.directory, state, { phase: "restoring-previous" });
|
|
1737
2899
|
try {
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
2900
|
+
if (!recreateDataVolume)
|
|
2901
|
+
await stopProject(loaded.directory, state.mode).catch(() => {
|
|
2902
|
+
return;
|
|
2903
|
+
});
|
|
2904
|
+
if (migrateLegacyVolume && !recreateDataVolume) {
|
|
1742
2905
|
await setManagedVolumeRootOwnership(targetImage, state.volumeName, previousRootOwnership);
|
|
1743
2906
|
}
|
|
1744
|
-
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
|
|
2907
|
+
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, { recreateDataVolume });
|
|
1745
2908
|
} catch (restoreError) {
|
|
1746
2909
|
throw new Error(`Update to ${targetVersion} failed, and automatic backup restoration also failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Original update error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1747
2910
|
}
|
|
1748
|
-
const
|
|
1749
|
-
|
|
1750
|
-
state.lastError = `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`;
|
|
1751
|
-
const operationId = pending.id;
|
|
1752
|
-
delete state.incompleteOperation;
|
|
1753
|
-
await removeOperationRecord(loaded.directory, operationId);
|
|
1754
|
-
await writeState(loaded.directory, state);
|
|
1755
|
-
throw new Error(state.lastError);
|
|
2911
|
+
const recovered = await commitPreMutationRecovery(loaded.directory, state, `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`);
|
|
2912
|
+
throw new Error(recovered.lastError);
|
|
1756
2913
|
}
|
|
1757
2914
|
const digest = await requireImageDigest(targetImage, targetDigest, `OR3 ${targetVersion}`);
|
|
1758
2915
|
state.rollback = {
|
|
@@ -1765,9 +2922,12 @@ async function updateCommand(directory, flags) {
|
|
|
1765
2922
|
state.appVersion = targetVersion;
|
|
1766
2923
|
state.image = targetImage;
|
|
1767
2924
|
state.imageDigest = digest;
|
|
2925
|
+
state.deploymentId = targetDeploymentId;
|
|
2926
|
+
state.deploymentRoot = resolve(loaded.directory);
|
|
1768
2927
|
state.lastSuccessfulOperation = "update";
|
|
1769
2928
|
state.lastError = undefined;
|
|
1770
2929
|
await clearPending(loaded.directory, state);
|
|
2930
|
+
await pruneBackups(loaded.directory, state, BACKUP_RETENTION_KEEP, false);
|
|
1771
2931
|
console.log(`OR3 updated to ${targetVersion}. Image digest: ${digest}`);
|
|
1772
2932
|
console.log(`Rollback point: ${backup.backupId}. Keep it until login, chat, and file checks pass.`);
|
|
1773
2933
|
} catch (error) {
|
|
@@ -1779,11 +2939,52 @@ async function updateCommand(directory, flags) {
|
|
|
1779
2939
|
}
|
|
1780
2940
|
}
|
|
1781
2941
|
async function resolveBackup(directory, value) {
|
|
2942
|
+
if (!isAbsolute(value) && !BACKUP_ID_PATTERN.test(value)) {
|
|
2943
|
+
throw new Error(`Backup ID "${value}" is invalid. Use an OR3-generated backup ID or an absolute external backup path.`);
|
|
2944
|
+
}
|
|
1782
2945
|
const candidate = isAbsolute(value) ? value : backupDirectory(directory, value);
|
|
1783
2946
|
if (!await fileExists(join(candidate, "manifest.json")))
|
|
1784
2947
|
throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
|
|
1785
2948
|
return resolve(candidate);
|
|
1786
2949
|
}
|
|
2950
|
+
async function recordedBackupPath(directory, pending, previous = false) {
|
|
2951
|
+
const path = previous ? pending.previousBackupPath : pending.backupPath;
|
|
2952
|
+
const backupId = previous ? pending.previousBackupId : pending.backupId;
|
|
2953
|
+
const resolved = path ? resolve(path) : backupId ? await resolveBackup(directory, backupId) : undefined;
|
|
2954
|
+
if (!resolved || !await fileExists(join(resolved, "manifest.json"))) {
|
|
2955
|
+
throw new Error(`The incomplete ${pending.operation} has no readable ${previous ? "pre-mutation" : "target"} backup source.`);
|
|
2956
|
+
}
|
|
2957
|
+
const manifest = await readManifest(resolved, directory);
|
|
2958
|
+
if (!previous) {
|
|
2959
|
+
if (pending.backupDataSha256 && pending.backupDataSha256 !== manifest.dataSha256) {
|
|
2960
|
+
throw new Error(`The recorded target backup at ${resolved} no longer has its expected data checksum.`);
|
|
2961
|
+
}
|
|
2962
|
+
if (pending.backupConfigSha256 && pending.backupConfigSha256 !== manifest.configSha256) {
|
|
2963
|
+
throw new Error(`The recorded target backup at ${resolved} no longer has its expected configuration checksum.`);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
return { path: resolved, manifest };
|
|
2967
|
+
}
|
|
2968
|
+
async function createPreMutationSnapshot(directory, state, env, backupId) {
|
|
2969
|
+
return await createBackup(directory, state, env, { restartAfter: false, backupId });
|
|
2970
|
+
}
|
|
2971
|
+
async function restorePreMutationSnapshot(directory, state, env) {
|
|
2972
|
+
const pending = state.incompleteOperation;
|
|
2973
|
+
if (!pending)
|
|
2974
|
+
throw new Error("No incomplete operation is available to restore.");
|
|
2975
|
+
const previous = await recordedBackupPath(directory, pending, true);
|
|
2976
|
+
await updatePending(directory, state, { phase: "restoring-previous" });
|
|
2977
|
+
if (pending.recreateDataVolume) {
|
|
2978
|
+
await restoreBackupData(directory, state, env, previous.path, { recreateDataVolume: true });
|
|
2979
|
+
return previous;
|
|
2980
|
+
}
|
|
2981
|
+
if (pending.previousRootOwnership) {
|
|
2982
|
+
await pullAndRequireImage(state.image, state.imageDigest, "Previous deployment");
|
|
2983
|
+
await setManagedVolumeRootOwnership(state.image, state.volumeName, pending.previousRootOwnership);
|
|
2984
|
+
}
|
|
2985
|
+
await restoreBackupData(directory, state, env, previous.path);
|
|
2986
|
+
return previous;
|
|
2987
|
+
}
|
|
1787
2988
|
async function restoreCommand(directory, flags, positionals) {
|
|
1788
2989
|
if (!boolFlag(flags, "yes"))
|
|
1789
2990
|
throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
|
|
@@ -1794,12 +2995,14 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
1794
2995
|
if (!backupValue)
|
|
1795
2996
|
throw new Error("restore requires a backup ID or path.");
|
|
1796
2997
|
const backupPath = await resolveBackup(loaded.directory, backupValue);
|
|
1797
|
-
const manifest = await readManifest(backupPath);
|
|
2998
|
+
const manifest = await readManifest(backupPath, loaded.directory);
|
|
2999
|
+
assertRestorableManagedAssets(manifest);
|
|
1798
3000
|
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
1799
3001
|
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1800
3002
|
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1801
3003
|
}
|
|
1802
3004
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
3005
|
+
const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
|
|
1803
3006
|
if (manifest.imageDigest)
|
|
1804
3007
|
await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
1805
3008
|
await assertSupportedHostArchitecture(manifest.image);
|
|
@@ -1810,13 +3013,30 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
1810
3013
|
startedAt: now(),
|
|
1811
3014
|
message: `Restoring backup ${manifest.backupId}`,
|
|
1812
3015
|
backupId: manifest.backupId,
|
|
3016
|
+
backupPath,
|
|
3017
|
+
backupDataSha256: manifest.dataSha256,
|
|
3018
|
+
backupConfigSha256: manifest.configSha256,
|
|
1813
3019
|
targetVersion: manifest.appVersion,
|
|
1814
3020
|
targetImage: manifest.image,
|
|
1815
|
-
targetImageDigest: manifest.imageDigest
|
|
3021
|
+
targetImageDigest: manifest.imageDigest,
|
|
3022
|
+
recreateDataVolume,
|
|
3023
|
+
phase: "prepared"
|
|
1816
3024
|
};
|
|
1817
3025
|
await markPending(loaded.directory, loaded.state, pending);
|
|
1818
3026
|
try {
|
|
1819
|
-
|
|
3027
|
+
const previousBackupId = id("backup-before-restore");
|
|
3028
|
+
await updatePending(loaded.directory, loaded.state, {
|
|
3029
|
+
previousBackupId,
|
|
3030
|
+
previousBackupPath: backupDirectory(loaded.directory, previousBackupId)
|
|
3031
|
+
});
|
|
3032
|
+
const previous = await createPreMutationSnapshot(loaded.directory, loaded.state, loaded.env, previousBackupId);
|
|
3033
|
+
await updatePending(loaded.directory, loaded.state, {
|
|
3034
|
+
previousBackupId: previous.backupId,
|
|
3035
|
+
previousBackupPath: previous.backupDir,
|
|
3036
|
+
phase: "snapshot-created"
|
|
3037
|
+
});
|
|
3038
|
+
await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
|
|
3039
|
+
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath, { recreateDataVolume });
|
|
1820
3040
|
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1821
3041
|
const digest = await imageDigest(restoredEnv.OR3_IMAGE);
|
|
1822
3042
|
const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", digest);
|
|
@@ -1825,8 +3045,33 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
1825
3045
|
await writeState(loaded.directory, nextState);
|
|
1826
3046
|
console.log(`Restored ${manifest.backupId}. Verify sign-in, a conversation, and a previously uploaded file.`);
|
|
1827
3047
|
} catch (error) {
|
|
1828
|
-
|
|
1829
|
-
|
|
3048
|
+
const original = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
|
|
3049
|
+
const phase = loaded.state.incompleteOperation?.phase;
|
|
3050
|
+
const targetMayHaveMutated = phase === "target-mutating" || phase === "restoring-previous" || phase === "starting-target";
|
|
3051
|
+
if (targetMayHaveMutated && (loaded.state.incompleteOperation?.previousBackupPath || loaded.state.incompleteOperation?.previousBackupId)) {
|
|
3052
|
+
let recoveredMessage;
|
|
3053
|
+
try {
|
|
3054
|
+
const previous = await restorePreMutationSnapshot(loaded.directory, loaded.state, loaded.env);
|
|
3055
|
+
recoveredMessage = `Restore of ${manifest.backupId} failed and the pre-restore snapshot ${previous.manifest.backupId} was restored: ${original}`;
|
|
3056
|
+
await commitPreMutationRecovery(loaded.directory, loaded.state, recoveredMessage);
|
|
3057
|
+
} catch (recoveryError) {
|
|
3058
|
+
const recovery = redact(recoveryError instanceof Error ? recoveryError.message : String(recoveryError), secretValues(loaded.env));
|
|
3059
|
+
loaded.state.lastError = `Restore of ${manifest.backupId} failed, and automatic restoration of the pre-restore snapshot also failed: ${recovery}. Original restore error: ${original}`;
|
|
3060
|
+
await writeState(loaded.directory, loaded.state);
|
|
3061
|
+
throw new Error(loaded.state.lastError);
|
|
3062
|
+
}
|
|
3063
|
+
throw new Error(recoveredMessage);
|
|
3064
|
+
}
|
|
3065
|
+
try {
|
|
3066
|
+
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
3067
|
+
loaded.state.lastError = `Restore of ${manifest.backupId} stopped before data replacement: ${original}`;
|
|
3068
|
+
await clearPending(loaded.directory, loaded.state);
|
|
3069
|
+
} catch (restartError) {
|
|
3070
|
+
const restart = redact(restartError instanceof Error ? restartError.message : String(restartError), secretValues(loaded.env));
|
|
3071
|
+
loaded.state.lastError = `Restore of ${manifest.backupId} stopped before data replacement, but the original deployment could not restart: ${restart}. Original restore error: ${original}`;
|
|
3072
|
+
await writeState(loaded.directory, loaded.state);
|
|
3073
|
+
throw new Error(loaded.state.lastError);
|
|
3074
|
+
}
|
|
1830
3075
|
throw error;
|
|
1831
3076
|
}
|
|
1832
3077
|
}
|
|
@@ -1840,7 +3085,8 @@ async function rollbackCommand(directory, flags) {
|
|
|
1840
3085
|
if (!point)
|
|
1841
3086
|
throw new Error("No immediate rollback point is recorded for this deployment.");
|
|
1842
3087
|
const backupPath = await resolveBackup(loaded.directory, point.backupId);
|
|
1843
|
-
const manifest = await readManifest(backupPath);
|
|
3088
|
+
const manifest = await readManifest(backupPath, loaded.directory);
|
|
3089
|
+
assertRestorableManagedAssets(manifest);
|
|
1844
3090
|
if (manifest.appVersion !== point.appVersion || manifest.image !== point.image || manifest.imageDigest !== point.imageDigest) {
|
|
1845
3091
|
throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
|
|
1846
3092
|
}
|
|
@@ -1849,6 +3095,7 @@ async function rollbackCommand(directory, flags) {
|
|
|
1849
3095
|
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1850
3096
|
}
|
|
1851
3097
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
3098
|
+
const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
|
|
1852
3099
|
await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
|
|
1853
3100
|
await assertSupportedHostArchitecture(point.image);
|
|
1854
3101
|
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
|
|
@@ -1858,13 +3105,30 @@ async function rollbackCommand(directory, flags) {
|
|
|
1858
3105
|
startedAt: now(),
|
|
1859
3106
|
message: `Rolling back to ${point.appVersion}`,
|
|
1860
3107
|
backupId: point.backupId,
|
|
3108
|
+
backupPath,
|
|
3109
|
+
backupDataSha256: manifest.dataSha256,
|
|
3110
|
+
backupConfigSha256: manifest.configSha256,
|
|
1861
3111
|
targetVersion: point.appVersion,
|
|
1862
3112
|
targetImage: point.image,
|
|
1863
|
-
targetImageDigest: point.imageDigest
|
|
3113
|
+
targetImageDigest: point.imageDigest,
|
|
3114
|
+
recreateDataVolume,
|
|
3115
|
+
phase: "prepared"
|
|
1864
3116
|
};
|
|
1865
3117
|
await markPending(loaded.directory, loaded.state, pending);
|
|
1866
3118
|
try {
|
|
1867
|
-
|
|
3119
|
+
const previousBackupId = id("backup-before-rollback");
|
|
3120
|
+
await updatePending(loaded.directory, loaded.state, {
|
|
3121
|
+
previousBackupId,
|
|
3122
|
+
previousBackupPath: backupDirectory(loaded.directory, previousBackupId)
|
|
3123
|
+
});
|
|
3124
|
+
const previous = await createPreMutationSnapshot(loaded.directory, loaded.state, loaded.env, previousBackupId);
|
|
3125
|
+
await updatePending(loaded.directory, loaded.state, {
|
|
3126
|
+
previousBackupId: previous.backupId,
|
|
3127
|
+
previousBackupPath: previous.backupDir,
|
|
3128
|
+
phase: "snapshot-created"
|
|
3129
|
+
});
|
|
3130
|
+
await updatePending(loaded.directory, loaded.state, { phase: "target-mutating" });
|
|
3131
|
+
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath, { recreateDataVolume });
|
|
1868
3132
|
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1869
3133
|
const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
|
|
1870
3134
|
nextState.rollback = undefined;
|
|
@@ -1872,8 +3136,33 @@ async function rollbackCommand(directory, flags) {
|
|
|
1872
3136
|
await writeState(loaded.directory, nextState);
|
|
1873
3137
|
console.log(`Rolled back to OR3 ${nextState.appVersion}. Verify sign-in, chat, and file access.`);
|
|
1874
3138
|
} catch (error) {
|
|
1875
|
-
|
|
1876
|
-
|
|
3139
|
+
const original = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
|
|
3140
|
+
const phase = loaded.state.incompleteOperation?.phase;
|
|
3141
|
+
const targetMayHaveMutated = phase === "target-mutating" || phase === "restoring-previous" || phase === "starting-target";
|
|
3142
|
+
if (targetMayHaveMutated && (loaded.state.incompleteOperation?.previousBackupPath || loaded.state.incompleteOperation?.previousBackupId)) {
|
|
3143
|
+
let recoveredMessage;
|
|
3144
|
+
try {
|
|
3145
|
+
const previous = await restorePreMutationSnapshot(loaded.directory, loaded.state, loaded.env);
|
|
3146
|
+
recoveredMessage = `Rollback to ${point.appVersion} failed and the pre-rollback snapshot ${previous.manifest.backupId} was restored: ${original}`;
|
|
3147
|
+
await commitPreMutationRecovery(loaded.directory, loaded.state, recoveredMessage);
|
|
3148
|
+
} catch (recoveryError) {
|
|
3149
|
+
const recovery = redact(recoveryError instanceof Error ? recoveryError.message : String(recoveryError), secretValues(loaded.env));
|
|
3150
|
+
loaded.state.lastError = `Rollback to ${point.appVersion} failed, and automatic restoration of the pre-rollback snapshot also failed: ${recovery}. Original rollback error: ${original}`;
|
|
3151
|
+
await writeState(loaded.directory, loaded.state);
|
|
3152
|
+
throw new Error(loaded.state.lastError);
|
|
3153
|
+
}
|
|
3154
|
+
throw new Error(recoveredMessage);
|
|
3155
|
+
}
|
|
3156
|
+
try {
|
|
3157
|
+
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
3158
|
+
loaded.state.lastError = `Rollback to ${point.appVersion} stopped before data replacement: ${original}`;
|
|
3159
|
+
await clearPending(loaded.directory, loaded.state);
|
|
3160
|
+
} catch (restartError) {
|
|
3161
|
+
const restart = redact(restartError instanceof Error ? restartError.message : String(restartError), secretValues(loaded.env));
|
|
3162
|
+
loaded.state.lastError = `Rollback to ${point.appVersion} stopped before data replacement, but the original deployment could not restart: ${restart}. Original rollback error: ${original}`;
|
|
3163
|
+
await writeState(loaded.directory, loaded.state);
|
|
3164
|
+
throw new Error(loaded.state.lastError);
|
|
3165
|
+
}
|
|
1877
3166
|
throw error;
|
|
1878
3167
|
}
|
|
1879
3168
|
}
|
|
@@ -1901,7 +3190,7 @@ async function doctorCommand(directory) {
|
|
|
1901
3190
|
failures.push(error instanceof Error ? error.message : String(error));
|
|
1902
3191
|
console.log("✗ Managed state or .env is missing/invalid");
|
|
1903
3192
|
}
|
|
1904
|
-
for (const file of [paths.env, paths.state]) {
|
|
3193
|
+
for (const file of [paths.env, paths.state, join(resolved, ".or3-initial-credentials")]) {
|
|
1905
3194
|
if (!existsSync(file))
|
|
1906
3195
|
continue;
|
|
1907
3196
|
const mode = (await stat(file)).mode & 511;
|
|
@@ -1942,6 +3231,53 @@ async function doctorCommand(directory) {
|
|
|
1942
3231
|
failures.push(error instanceof Error ? error.message : String(error));
|
|
1943
3232
|
console.log("✗ Deep health is not passing");
|
|
1944
3233
|
}
|
|
3234
|
+
try {
|
|
3235
|
+
const operatorEnabled = env.OR3_DASHBOARD_UPDATES_ENABLED === "true";
|
|
3236
|
+
const operatorContainer = await run("docker", [
|
|
3237
|
+
"ps",
|
|
3238
|
+
"-aq",
|
|
3239
|
+
"--filter",
|
|
3240
|
+
`label=com.docker.compose.project=${state.composeProject}`,
|
|
3241
|
+
"--filter",
|
|
3242
|
+
"label=com.docker.compose.service=or3-operator"
|
|
3243
|
+
], resolved);
|
|
3244
|
+
if (!operatorContainer.ok)
|
|
3245
|
+
throw new Error(`Could not inspect the dashboard operator service. ${operatorContainer.stderr.trim()}`);
|
|
3246
|
+
const containerId = operatorContainer.stdout.trim();
|
|
3247
|
+
if (!operatorEnabled) {
|
|
3248
|
+
if (containerId)
|
|
3249
|
+
throw new Error("A dashboard operator container exists even though the bridge is disabled.");
|
|
3250
|
+
console.log("✓ Dashboard operator is disabled with no orphaned service");
|
|
3251
|
+
} else {
|
|
3252
|
+
if (!containerId)
|
|
3253
|
+
throw new Error("Dashboard updates are enabled but the operator container is missing.");
|
|
3254
|
+
const inspected = await run("docker", ["inspect", containerId]);
|
|
3255
|
+
if (!inspected.ok)
|
|
3256
|
+
throw new Error(`Could not inspect dashboard operator container ${containerId}.`);
|
|
3257
|
+
const container = JSON.parse(inspected.stdout)?.[0];
|
|
3258
|
+
if (container.Config?.Image !== env.OR3_OPERATOR_IMAGE)
|
|
3259
|
+
throw new Error("Dashboard operator runtime image does not match the digest-qualified managed environment.");
|
|
3260
|
+
if (state.deploymentId && container.Config?.Labels?.["io.or3.cloud.deployment-id"] !== state.deploymentId) {
|
|
3261
|
+
throw new Error("Dashboard operator deployment identity does not match managed state.");
|
|
3262
|
+
}
|
|
3263
|
+
const destinations = new Set(container.Mounts?.map((mount) => mount.Destination));
|
|
3264
|
+
for (const destination of ["/var/run/docker.sock", "/deployment", "/run/or3-operator"]) {
|
|
3265
|
+
if (!destinations.has(destination))
|
|
3266
|
+
throw new Error(`Dashboard operator is missing its required ${destination} mount.`);
|
|
3267
|
+
}
|
|
3268
|
+
const ipcDirectory = await stat(paths.operatorIpc);
|
|
3269
|
+
const operatorSocket = await stat(join(paths.operatorIpc, "operator.sock"));
|
|
3270
|
+
const operatorUid = Number(env.OR3_OPERATOR_UID);
|
|
3271
|
+
const operatorGid = Number(env.OR3_OPERATOR_GID);
|
|
3272
|
+
if ((ipcDirectory.mode & 511) !== 456 || ipcDirectory.uid !== operatorUid || ipcDirectory.gid !== operatorGid || !operatorSocket.isSocket() || (operatorSocket.mode & 511) !== 432 || operatorSocket.uid !== operatorUid || operatorSocket.gid !== operatorGid) {
|
|
3273
|
+
throw new Error("Dashboard operator IPC ownership boundary has unexpected owners, types, or modes.");
|
|
3274
|
+
}
|
|
3275
|
+
console.log("✓ Dashboard operator image, identity, mounts, and IPC modes are valid");
|
|
3276
|
+
}
|
|
3277
|
+
} catch (error) {
|
|
3278
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
3279
|
+
console.log("✗ Dashboard operator boundary is invalid");
|
|
3280
|
+
}
|
|
1945
3281
|
}
|
|
1946
3282
|
if (state.mode === "public" && state.domain) {
|
|
1947
3283
|
try {
|
|
@@ -1961,7 +3297,22 @@ async function doctorCommand(directory) {
|
|
|
1961
3297
|
console.log(`✗ DNS: ${state.domain} does not resolve`);
|
|
1962
3298
|
}
|
|
1963
3299
|
for (const port of [80, 443]) {
|
|
1964
|
-
|
|
3300
|
+
const mapping = await run("docker", composeArgs(resolved, state.mode, ["port", "caddy", String(port)]), resolved);
|
|
3301
|
+
if (!mapping.ok || !mapping.stdout.trim().split(/\r?\n/).some((line) => line.trim().endsWith(`:${port}`))) {
|
|
3302
|
+
failures.push(`Caddy does not publish TCP ${port} through the selected Docker daemon.`);
|
|
3303
|
+
console.log(`✗ Caddy does not publish TCP ${port}`);
|
|
3304
|
+
} else {
|
|
3305
|
+
console.log(`✓ Caddy publishes TCP ${port}`);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
try {
|
|
3309
|
+
const response = await verificationFetch(new URL(`https://${state.domain}`));
|
|
3310
|
+
if (response.status !== 200)
|
|
3311
|
+
throw new Error(`Public HTTPS root returned HTTP ${response.status}; redirects are not accepted.`);
|
|
3312
|
+
console.log("✓ Public HTTPS root returns HTTP 200 without redirects");
|
|
3313
|
+
} catch (error) {
|
|
3314
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
3315
|
+
console.log("✗ Public HTTPS root is unreachable or redirects");
|
|
1965
3316
|
}
|
|
1966
3317
|
console.log("ℹ Firewall: allow TCP 80/443 (and optional UDP 443) in your existing nftables or UFW rules; OR3 did not modify them.");
|
|
1967
3318
|
}
|
|
@@ -1971,6 +3322,358 @@ async function doctorCommand(directory) {
|
|
|
1971
3322
|
}
|
|
1972
3323
|
console.log("OR3 Cloud doctor passed.");
|
|
1973
3324
|
}
|
|
3325
|
+
function validateVerificationHealth(value) {
|
|
3326
|
+
const health = value;
|
|
3327
|
+
if (!health || health.status !== "ok" || health.providers?.auth?.provider !== "basic-auth" || health.providers?.sync?.provider !== "sqlite" || health.providers?.storage?.provider !== "fs") {
|
|
3328
|
+
throw new Error("Public deep health does not report the managed Basic Auth + SQLite + filesystem profile.");
|
|
3329
|
+
}
|
|
3330
|
+
return health;
|
|
3331
|
+
}
|
|
3332
|
+
async function verificationFetch(url, init = {}) {
|
|
3333
|
+
return fetch(url, {
|
|
3334
|
+
...init,
|
|
3335
|
+
redirect: "manual",
|
|
3336
|
+
signal: AbortSignal.timeout(20000)
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
async function verificationJson(baseUrl, path, options = {}) {
|
|
3340
|
+
const method = options.method ?? (options.body === undefined ? "GET" : "POST");
|
|
3341
|
+
const response = await verificationFetch(new URL(path, baseUrl), {
|
|
3342
|
+
method,
|
|
3343
|
+
headers: {
|
|
3344
|
+
accept: "application/json",
|
|
3345
|
+
...options.body === undefined ? {} : { "content-type": "application/json" },
|
|
3346
|
+
...options.cookie ? { cookie: options.cookie } : {},
|
|
3347
|
+
...method === "GET" ? {} : { origin: baseUrl.origin }
|
|
3348
|
+
},
|
|
3349
|
+
...options.body === undefined ? {} : { body: JSON.stringify(options.body) }
|
|
3350
|
+
});
|
|
3351
|
+
if (response.status !== 200) {
|
|
3352
|
+
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
3353
|
+
throw new Error(`${response.url} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
3354
|
+
}
|
|
3355
|
+
return response.json();
|
|
3356
|
+
}
|
|
3357
|
+
function sameOriginVerificationUrl(baseUrl, value, label) {
|
|
3358
|
+
if (typeof value !== "string")
|
|
3359
|
+
throw new Error(`Filesystem storage did not return a ${label} URL.`);
|
|
3360
|
+
const url = new URL(value, baseUrl);
|
|
3361
|
+
if (url.origin !== baseUrl.origin) {
|
|
3362
|
+
throw new Error(`Filesystem storage returned a cross-origin ${label} URL. Refusing to send the verification session to ${url.origin}.`);
|
|
3363
|
+
}
|
|
3364
|
+
return url;
|
|
3365
|
+
}
|
|
3366
|
+
function assertVerificationGrant(value, expectedMethod, label) {
|
|
3367
|
+
if (value.method !== undefined && value.method !== expectedMethod) {
|
|
3368
|
+
throw new Error(`Filesystem storage returned unexpected ${label} method ${String(value.method)}.`);
|
|
3369
|
+
}
|
|
3370
|
+
if (value.headers !== undefined && (!value.headers || typeof value.headers !== "object" || Array.isArray(value.headers) || Object.keys(value.headers).length > 0)) {
|
|
3371
|
+
throw new Error(`Filesystem storage returned unexpected ${label} headers.`);
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
async function verificationCredentials(directory, flags, env) {
|
|
3375
|
+
const passwordFile = stringFlag(flags, "verification-password-file");
|
|
3376
|
+
if (flags["verification-email"] !== undefined && !stringFlag(flags, "verification-email")) {
|
|
3377
|
+
throw new Error("--verification-email requires a value.");
|
|
3378
|
+
}
|
|
3379
|
+
if (flags["verification-password-file"] !== undefined && !passwordFile) {
|
|
3380
|
+
throw new Error("--verification-password-file requires a path.");
|
|
3381
|
+
}
|
|
3382
|
+
const configuredEmail = stringFlag(flags, "verification-email")?.trim() || env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
3383
|
+
let initialCredentials = {};
|
|
3384
|
+
if (!configuredEmail || !passwordFile && !env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD) {
|
|
3385
|
+
const initialCredentialsPath = join(directory, ".or3-initial-credentials");
|
|
3386
|
+
if (await fileExists(initialCredentialsPath)) {
|
|
3387
|
+
initialCredentials = parseEnv(await readOwnerOnlyText(initialCredentialsPath, "The initial credentials file"));
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
const email = configuredEmail || initialCredentials.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
3391
|
+
if (!email)
|
|
3392
|
+
throw new Error("A current owner email is required for verification.");
|
|
3393
|
+
validateEmail(email);
|
|
3394
|
+
const password = passwordFile ? (await readOwnerOnlyText(resolve(passwordFile), "The verification password file")).trim() : env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD || initialCredentials.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
3395
|
+
if (!password) {
|
|
3396
|
+
throw new Error("A current owner password is required. Pass --verification-password-file after changing the bootstrap password.");
|
|
3397
|
+
}
|
|
3398
|
+
if (password.includes("\x00") || /\r|\n/.test(password))
|
|
3399
|
+
throw new Error("The verification password file must contain exactly one password line.");
|
|
3400
|
+
return { email, password };
|
|
3401
|
+
}
|
|
3402
|
+
async function verifyPublicApplication(baseUrl, credentials) {
|
|
3403
|
+
const root = await verificationFetch(baseUrl);
|
|
3404
|
+
if (root.status !== 200)
|
|
3405
|
+
throw new Error(`${baseUrl} returned HTTP ${root.status}; redirects are not accepted during verification.`);
|
|
3406
|
+
const health = validateVerificationHealth(await verificationJson(baseUrl, "/api/health?deep=true"));
|
|
3407
|
+
const { email, password } = credentials;
|
|
3408
|
+
const signIn = await verificationFetch(new URL("/api/basic-auth/sign-in", baseUrl), {
|
|
3409
|
+
method: "POST",
|
|
3410
|
+
headers: { accept: "application/json", "content-type": "application/json", origin: baseUrl.origin },
|
|
3411
|
+
body: JSON.stringify({ email, password })
|
|
3412
|
+
});
|
|
3413
|
+
if (signIn.status !== 200)
|
|
3414
|
+
throw new Error(`Public Basic Auth sign-in returned HTTP ${signIn.status}.`);
|
|
3415
|
+
const responseHeaders = signIn.headers;
|
|
3416
|
+
const setCookies = responseHeaders.getSetCookie?.() ?? [signIn.headers.get("set-cookie")].filter((value) => Boolean(value));
|
|
3417
|
+
const cookie = setCookies.map((value) => value.split(";", 1)[0]).join("; ");
|
|
3418
|
+
if (!cookie)
|
|
3419
|
+
throw new Error("Public Basic Auth sign-in did not set a session cookie.");
|
|
3420
|
+
try {
|
|
3421
|
+
const session = await verificationJson(baseUrl, "/api/auth/session", { cookie });
|
|
3422
|
+
if (session.session?.user?.email !== email || !session.session?.workspace?.id) {
|
|
3423
|
+
throw new Error("Public session hydration did not return the verification user and workspace.");
|
|
3424
|
+
}
|
|
3425
|
+
const workspaceId = String(session.session.workspace.id);
|
|
3426
|
+
const pull = await verificationJson(baseUrl, "/api/sync/pull", {
|
|
3427
|
+
cookie,
|
|
3428
|
+
body: { scope: { workspaceId }, cursor: 0, limit: 1, tables: ["messages"] }
|
|
3429
|
+
});
|
|
3430
|
+
if (!Array.isArray(pull.changes) || typeof pull.nextCursor !== "number") {
|
|
3431
|
+
throw new Error("Public SQLite sync pull returned an invalid response.");
|
|
3432
|
+
}
|
|
3433
|
+
const probeBytes = Buffer.concat([
|
|
3434
|
+
Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489", "hex"),
|
|
3435
|
+
randomBytes(8)
|
|
3436
|
+
]);
|
|
3437
|
+
const hash = `sha256:${createHash("sha256").update(probeBytes).digest("hex")}`;
|
|
3438
|
+
let storageId;
|
|
3439
|
+
let metadataAttempted = false;
|
|
3440
|
+
try {
|
|
3441
|
+
const presign = await verificationJson(baseUrl, "/api/storage/presign-upload", {
|
|
3442
|
+
cookie,
|
|
3443
|
+
body: {
|
|
3444
|
+
workspace_id: workspaceId,
|
|
3445
|
+
hash,
|
|
3446
|
+
mime_type: "image/png",
|
|
3447
|
+
size_bytes: probeBytes.length,
|
|
3448
|
+
disposition: "inline"
|
|
3449
|
+
}
|
|
3450
|
+
});
|
|
3451
|
+
storageId = typeof presign.storageId === "string" && presign.storageId.trim() ? presign.storageId : undefined;
|
|
3452
|
+
if (!storageId)
|
|
3453
|
+
throw new Error("Filesystem storage did not return a non-empty storage ID.");
|
|
3454
|
+
assertVerificationGrant(presign, "PUT", "upload");
|
|
3455
|
+
const upload = await verificationFetch(sameOriginVerificationUrl(baseUrl, presign.url, "upload"), {
|
|
3456
|
+
method: "PUT",
|
|
3457
|
+
headers: { "content-type": "image/png", cookie },
|
|
3458
|
+
body: probeBytes
|
|
3459
|
+
});
|
|
3460
|
+
if (!upload.ok)
|
|
3461
|
+
throw new Error(`Filesystem verification upload returned HTTP ${upload.status}.`);
|
|
3462
|
+
await verificationJson(baseUrl, "/api/storage/commit", {
|
|
3463
|
+
cookie,
|
|
3464
|
+
body: {
|
|
3465
|
+
workspace_id: workspaceId,
|
|
3466
|
+
hash,
|
|
3467
|
+
storage_id: storageId,
|
|
3468
|
+
storage_provider_id: "fs",
|
|
3469
|
+
mime_type: "image/png",
|
|
3470
|
+
size_bytes: probeBytes.length,
|
|
3471
|
+
name: "or3-production-verification.png",
|
|
3472
|
+
kind: "image"
|
|
3473
|
+
}
|
|
3474
|
+
});
|
|
3475
|
+
const metadataCreatedAt = Date.now();
|
|
3476
|
+
const metadataOpId = randomUUID();
|
|
3477
|
+
metadataAttempted = true;
|
|
3478
|
+
const pushed = await verificationJson(baseUrl, "/api/sync/push", {
|
|
3479
|
+
cookie,
|
|
3480
|
+
body: {
|
|
3481
|
+
scope: { workspaceId },
|
|
3482
|
+
ops: [{
|
|
3483
|
+
id: `or3-verification-${metadataOpId}`,
|
|
3484
|
+
tableName: "file_meta",
|
|
3485
|
+
operation: "put",
|
|
3486
|
+
pk: hash,
|
|
3487
|
+
payload: {
|
|
3488
|
+
hash,
|
|
3489
|
+
kind: "image",
|
|
3490
|
+
mime_type: "image/png",
|
|
3491
|
+
size_bytes: probeBytes.length,
|
|
3492
|
+
storage_id: storageId,
|
|
3493
|
+
name: "or3-production-verification.png",
|
|
3494
|
+
deleted: false,
|
|
3495
|
+
created_at: metadataCreatedAt,
|
|
3496
|
+
updated_at: metadataCreatedAt,
|
|
3497
|
+
clock: metadataCreatedAt
|
|
3498
|
+
},
|
|
3499
|
+
stamp: {
|
|
3500
|
+
deviceId: "or3-cloud-verification",
|
|
3501
|
+
opId: metadataOpId,
|
|
3502
|
+
hlc: `${String(metadataCreatedAt).padStart(13, "0")}:0000:or3-cloud-verification`,
|
|
3503
|
+
clock: metadataCreatedAt
|
|
3504
|
+
},
|
|
3505
|
+
createdAt: metadataCreatedAt,
|
|
3506
|
+
attempts: 0,
|
|
3507
|
+
status: "pending"
|
|
3508
|
+
}]
|
|
3509
|
+
}
|
|
3510
|
+
});
|
|
3511
|
+
if (pushed.results?.[0]?.success !== true) {
|
|
3512
|
+
throw new Error(`Filesystem verification metadata sync failed: ${JSON.stringify(pushed)}`);
|
|
3513
|
+
}
|
|
3514
|
+
const downloadGrant = await verificationJson(baseUrl, "/api/storage/presign-download", {
|
|
3515
|
+
cookie,
|
|
3516
|
+
body: { workspace_id: workspaceId, hash, storage_id: storageId, disposition: "attachment" }
|
|
3517
|
+
});
|
|
3518
|
+
assertVerificationGrant(downloadGrant, "GET", "download");
|
|
3519
|
+
const download = await verificationFetch(sameOriginVerificationUrl(baseUrl, downloadGrant.url, "download"), { headers: { cookie } });
|
|
3520
|
+
if (!download.ok || !Buffer.from(await download.arrayBuffer()).equals(probeBytes)) {
|
|
3521
|
+
throw new Error("Filesystem verification download did not match the uploaded probe.");
|
|
3522
|
+
}
|
|
3523
|
+
} finally {
|
|
3524
|
+
if (storageId) {
|
|
3525
|
+
try {
|
|
3526
|
+
await verificationJson(baseUrl, "/api/storage/delete", {
|
|
3527
|
+
cookie,
|
|
3528
|
+
body: { workspace_id: workspaceId, hash, storage_id: storageId }
|
|
3529
|
+
});
|
|
3530
|
+
} finally {
|
|
3531
|
+
if (metadataAttempted) {
|
|
3532
|
+
const metadataDeleteAt = Date.now();
|
|
3533
|
+
const metadataDeleteOpId = randomUUID();
|
|
3534
|
+
const deleted = await verificationJson(baseUrl, "/api/sync/push", {
|
|
3535
|
+
cookie,
|
|
3536
|
+
body: {
|
|
3537
|
+
scope: { workspaceId },
|
|
3538
|
+
ops: [{
|
|
3539
|
+
id: `or3-verification-${metadataDeleteOpId}`,
|
|
3540
|
+
tableName: "file_meta",
|
|
3541
|
+
operation: "delete",
|
|
3542
|
+
pk: hash,
|
|
3543
|
+
payload: { hash },
|
|
3544
|
+
stamp: {
|
|
3545
|
+
deviceId: "or3-cloud-verification",
|
|
3546
|
+
opId: metadataDeleteOpId,
|
|
3547
|
+
hlc: `${String(metadataDeleteAt).padStart(13, "0")}:0000:or3-cloud-verification`,
|
|
3548
|
+
clock: metadataDeleteAt
|
|
3549
|
+
},
|
|
3550
|
+
createdAt: metadataDeleteAt,
|
|
3551
|
+
attempts: 0,
|
|
3552
|
+
status: "pending"
|
|
3553
|
+
}]
|
|
3554
|
+
}
|
|
3555
|
+
});
|
|
3556
|
+
if (deleted.results?.[0]?.success !== true) {
|
|
3557
|
+
throw new Error(`Filesystem verification metadata cleanup failed: ${JSON.stringify(deleted)}`);
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
return health;
|
|
3564
|
+
} finally {
|
|
3565
|
+
await verificationJson(baseUrl, "/api/basic-auth/sign-out", {
|
|
3566
|
+
cookie,
|
|
3567
|
+
method: "POST"
|
|
3568
|
+
});
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
function responseCookie(response, label) {
|
|
3572
|
+
const headers = response.headers;
|
|
3573
|
+
const values = headers.getSetCookie?.() ?? [response.headers.get("set-cookie")].filter((value) => Boolean(value));
|
|
3574
|
+
const cookie = values.map((value) => value.split(";", 1)[0]).join("; ");
|
|
3575
|
+
if (!cookie)
|
|
3576
|
+
throw new Error(`${label} did not set a session cookie.`);
|
|
3577
|
+
return cookie;
|
|
3578
|
+
}
|
|
3579
|
+
async function provisionManagedCredentialsOnce(baseUrl, email, password, adminUsername = email, adminPassword = password) {
|
|
3580
|
+
const appSignIn = await verificationFetch(new URL("/api/basic-auth/sign-in", baseUrl), {
|
|
3581
|
+
method: "POST",
|
|
3582
|
+
headers: { accept: "application/json", "content-type": "application/json", origin: baseUrl.origin },
|
|
3583
|
+
body: JSON.stringify({ email, password })
|
|
3584
|
+
});
|
|
3585
|
+
if (appSignIn.status !== 200)
|
|
3586
|
+
throw new Error(`Managed owner provisioning returned HTTP ${appSignIn.status}.`);
|
|
3587
|
+
const appCookie = responseCookie(appSignIn, "Managed owner provisioning");
|
|
3588
|
+
try {
|
|
3589
|
+
const session = await verificationJson(baseUrl, "/api/auth/session", { cookie: appCookie });
|
|
3590
|
+
if (session.session?.user?.email !== email || !session.session?.workspace?.id) {
|
|
3591
|
+
throw new Error("Managed owner provisioning did not create the expected account and workspace.");
|
|
3592
|
+
}
|
|
3593
|
+
} finally {
|
|
3594
|
+
await verificationJson(baseUrl, "/api/basic-auth/sign-out", { cookie: appCookie, method: "POST" });
|
|
3595
|
+
}
|
|
3596
|
+
const adminSignIn = await verificationFetch(new URL("/api/admin/auth/login", baseUrl), {
|
|
3597
|
+
method: "POST",
|
|
3598
|
+
headers: { accept: "application/json", "content-type": "application/json", origin: baseUrl.origin },
|
|
3599
|
+
body: JSON.stringify({ username: adminUsername, password: adminPassword })
|
|
3600
|
+
});
|
|
3601
|
+
if (adminSignIn.status !== 200)
|
|
3602
|
+
throw new Error(`Managed admin provisioning returned HTTP ${adminSignIn.status}.`);
|
|
3603
|
+
const adminCookie = responseCookie(adminSignIn, "Managed admin provisioning");
|
|
3604
|
+
await verificationJson(baseUrl, "/api/admin/auth/logout", { cookie: adminCookie, method: "POST" });
|
|
3605
|
+
}
|
|
3606
|
+
async function provisionManagedCredentials(baseUrl, email, password, adminUsername = email, adminPassword = password) {
|
|
3607
|
+
const deadline = Date.now() + 60000;
|
|
3608
|
+
let lastError;
|
|
3609
|
+
do {
|
|
3610
|
+
try {
|
|
3611
|
+
await provisionManagedCredentialsOnce(baseUrl, email, password, adminUsername, adminPassword);
|
|
3612
|
+
return;
|
|
3613
|
+
} catch (error) {
|
|
3614
|
+
lastError = error;
|
|
3615
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 2000));
|
|
3616
|
+
}
|
|
3617
|
+
} while (Date.now() < deadline);
|
|
3618
|
+
throw new Error(`Managed credential provisioning did not become ready within 60 seconds: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
3619
|
+
}
|
|
3620
|
+
async function verifyCommand(directory, flags, positionals) {
|
|
3621
|
+
if (positionals.length)
|
|
3622
|
+
throw new Error("verify accepts no positional arguments.");
|
|
3623
|
+
if (flags.public !== undefined && !boolFlag(flags, "public"))
|
|
3624
|
+
throw new Error("--public does not accept a value.");
|
|
3625
|
+
await ensureDocker();
|
|
3626
|
+
const loaded = await loadManaged(directory);
|
|
3627
|
+
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
3628
|
+
assertNoPending(loaded.state);
|
|
3629
|
+
if (boolFlag(flags, "public") && loaded.state.mode !== "public") {
|
|
3630
|
+
throw new Error("--public requires a managed public deployment.");
|
|
3631
|
+
}
|
|
3632
|
+
if (loaded.env.OR3_VERSION !== loaded.state.appVersion || loaded.env.OR3_IMAGE !== loaded.state.image) {
|
|
3633
|
+
throw new Error("Managed state and .env version/image do not match.");
|
|
3634
|
+
}
|
|
3635
|
+
const digest = await imageDigest(loaded.state.image);
|
|
3636
|
+
if (digest !== loaded.state.imageDigest) {
|
|
3637
|
+
throw new Error(`Managed image digest differs from state. Expected ${loaded.state.imageDigest}, found ${digest}.`);
|
|
3638
|
+
}
|
|
3639
|
+
await waitForDeepHealth(loaded.directory, loaded.state.mode, secretValues(loaded.env));
|
|
3640
|
+
const baseUrl = new URL(loaded.state.mode === "public" ? `https://${loaded.state.domain}` : `http://127.0.0.1:${loaded.state.port}`);
|
|
3641
|
+
await verifyPublicApplication(baseUrl, await verificationCredentials(loaded.directory, flags, loaded.env));
|
|
3642
|
+
const databaseCheck = await run("docker", [
|
|
3643
|
+
...composeArgs(loaded.directory, loaded.state.mode, ["exec", "-T", "or3", ...containerNodeCommand(VERIFY_DATABASES_SCRIPT)])
|
|
3644
|
+
], loaded.directory);
|
|
3645
|
+
if (!databaseCheck.ok)
|
|
3646
|
+
throw new Error(`SQLite integrity or runtime proxy verification failed. ${databaseCheck.stderr.trim()}`);
|
|
3647
|
+
const databases = JSON.parse(databaseCheck.stdout.trim());
|
|
3648
|
+
if (databases.length !== 2 || databases.some((entry) => entry.quickCheck !== "ok" || entry.tables < 1)) {
|
|
3649
|
+
throw new Error("SQLite verification did not confirm both managed databases.");
|
|
3650
|
+
}
|
|
3651
|
+
const services = loaded.state.mode === "public" ? ["or3", "caddy"] : ["or3"];
|
|
3652
|
+
const logs = await run("docker", composeArgs(loaded.directory, loaded.state.mode, [
|
|
3653
|
+
"logs",
|
|
3654
|
+
"--no-color",
|
|
3655
|
+
"--since",
|
|
3656
|
+
"10m",
|
|
3657
|
+
"--tail",
|
|
3658
|
+
"250",
|
|
3659
|
+
...services
|
|
3660
|
+
]), loaded.directory);
|
|
3661
|
+
if (!logs.ok)
|
|
3662
|
+
throw new Error(`Could not read bounded deployment logs. ${logs.stderr.trim()}`);
|
|
3663
|
+
const serious = `${logs.stdout}
|
|
3664
|
+
${logs.stderr}`.split(/\r?\n/).filter((line) => /\b(?:fatal|panic|oomkilled)\b|unhandled (?:rejection|exception)|out of memory/i.test(line));
|
|
3665
|
+
if (serious.length)
|
|
3666
|
+
throw new Error(`Recent deployment logs contain serious errors:
|
|
3667
|
+
${redact(serious.slice(-20).join(`
|
|
3668
|
+
`), secretValues(loaded.env))}`);
|
|
3669
|
+
console.log(`✓ OR3 ${loaded.state.appVersion} image digest matches managed state`);
|
|
3670
|
+
console.log(`✓ ${baseUrl.origin} root and deep health return HTTP 200 without redirects`);
|
|
3671
|
+
console.log("✓ Basic Auth sign-in, session hydration, and SQLite sync pull passed");
|
|
3672
|
+
console.log("✓ Filesystem storage write/read/delete probe passed");
|
|
3673
|
+
console.log("✓ auth.sqlite and sync.sqlite quick_check passed with managed ownership");
|
|
3674
|
+
console.log("✓ Recent bounded logs contain no fatal, panic, unhandled, or OOM events");
|
|
3675
|
+
console.log("OR3 production verification passed.");
|
|
3676
|
+
}
|
|
1974
3677
|
async function readSourceVolume(sourceDirectory) {
|
|
1975
3678
|
const composeResult = await run("docker", [
|
|
1976
3679
|
"compose",
|
|
@@ -2026,6 +3729,9 @@ function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
|
|
|
2026
3729
|
if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
|
|
2027
3730
|
throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
|
|
2028
3731
|
}
|
|
3732
|
+
if (sourceEnv.OR3_AUTH_REGISTRATION_MODE !== "invite_only" || sourceEnv.OR3_AUTH_AUTO_PROVISION !== "false") {
|
|
3733
|
+
throw new Error("V1 project does not use the managed invite-only registration policy. Adoption refuses to carry open registration into OR3 Cloud.");
|
|
3734
|
+
}
|
|
2029
3735
|
if (sourceEnv.OR3_SYNC_PROVIDER !== "sqlite" || sourceEnv.OR3_STORAGE_FS_ROOT === undefined || sourceEnv.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs") {
|
|
2030
3736
|
throw new Error("V1 project is not the supported Basic Auth + SQLite + filesystem profile.");
|
|
2031
3737
|
}
|
|
@@ -2086,6 +3792,9 @@ async function adoptCommand(positionals, flags) {
|
|
|
2086
3792
|
const release = JSON.parse(await readText(join(sourceDirectory, "or3-release.json")));
|
|
2087
3793
|
if (!release.or3Version || !isVersion(release.or3Version))
|
|
2088
3794
|
throw new Error("V1 release metadata does not contain a usable OR3 version.");
|
|
3795
|
+
if (release.or3Version !== PACKAGE_VERSION) {
|
|
3796
|
+
throw new Error(`V1 is on OR3 ${release.or3Version}, but this CLI ships managed assets for ${PACKAGE_VERSION}. Run the matching @or3/cloud version or perform a qualified host CLI update before adoption.`);
|
|
3797
|
+
}
|
|
2089
3798
|
const sourceEnv = parseEnv(await readText(join(sourceDirectory, ".env")));
|
|
2090
3799
|
const providers = await readText(join(sourceDirectory, "or3.providers.generated.ts"));
|
|
2091
3800
|
assertSupportedSource(sourceDirectory, sourceEnv, providers);
|
|
@@ -2110,15 +3819,17 @@ async function adoptCommand(positionals, flags) {
|
|
|
2110
3819
|
], sourceDirectory);
|
|
2111
3820
|
if (!sourceConfig.ok)
|
|
2112
3821
|
throw new Error(`The V1 Compose configuration is invalid. ${redact(sourceConfig.stderr, secretValues(sourceEnv))}`);
|
|
2113
|
-
const
|
|
3822
|
+
const sourceImage = imageFor(release.or3Version);
|
|
2114
3823
|
if (sourceEnv.OR3_VERSION && sourceEnv.OR3_VERSION !== release.or3Version) {
|
|
2115
3824
|
throw new Error(`V1 OR3_VERSION ${sourceEnv.OR3_VERSION} does not match or3-release.json ${release.or3Version}.`);
|
|
2116
3825
|
}
|
|
2117
|
-
|
|
2118
|
-
|
|
3826
|
+
const digest = await pullImage(sourceImage, expectedImageDigest(release.or3Version));
|
|
3827
|
+
await assertSupportedHostArchitecture(sourceImage);
|
|
3828
|
+
await assertImageReleaseIdentity(sourceImage, release.or3Version);
|
|
3829
|
+
const image = imageAtDigest(sourceImage, digest);
|
|
3830
|
+
if (sourceEnv.OR3_IMAGE && sourceEnv.OR3_IMAGE !== sourceImage && sourceEnv.OR3_IMAGE !== image) {
|
|
3831
|
+
throw new Error(`V1 OR3_IMAGE ${sourceEnv.OR3_IMAGE} does not match the authenticated image for ${release.or3Version}.`);
|
|
2119
3832
|
}
|
|
2120
|
-
await pullImage(image);
|
|
2121
|
-
await assertSupportedHostArchitecture(image);
|
|
2122
3833
|
const sourceVolume = await readSourceVolume(sourceDirectory);
|
|
2123
3834
|
const email = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
2124
3835
|
const password = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
@@ -2130,15 +3841,40 @@ async function adoptCommand(positionals, flags) {
|
|
|
2130
3841
|
if (mode === "public" && [80, 443].includes(sourcePort))
|
|
2131
3842
|
throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
|
|
2132
3843
|
assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort);
|
|
3844
|
+
const sourceInitiallyRunning = await sourceServiceRunning(sourceDirectory, sourceComposeFiles);
|
|
3845
|
+
const targetNames = composeProjectNames(targetDirectory);
|
|
3846
|
+
await assertDockerProjectAbsent(targetNames.project);
|
|
3847
|
+
for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
|
|
3848
|
+
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
3849
|
+
if (existing.ok)
|
|
3850
|
+
throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
|
|
3851
|
+
if (existing.exitCode !== 1 || !/no such volume/i.test(`${existing.stdout}
|
|
3852
|
+
${existing.stderr}`)) {
|
|
3853
|
+
throw new Error(`Could not confirm whether Docker volume ${volume} exists. Refusing adoption until Docker returns an explicit not-found result. ${existing.stderr.trim()}`);
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
2133
3856
|
await mkdir(targetDirectory, { recursive: true, mode: 448 });
|
|
3857
|
+
await chmod(targetDirectory, 448);
|
|
2134
3858
|
await mkdir(deploymentPaths(targetDirectory).operations, { recursive: true, mode: 448 });
|
|
2135
3859
|
await mkdir(deploymentPaths(targetDirectory).backups, { recursive: true, mode: 448 });
|
|
3860
|
+
const operator = await prepareVerifiedDashboardOperator(targetDirectory, release.or3Version);
|
|
2136
3861
|
await copyAssets(targetDirectory, mode);
|
|
2137
3862
|
const copiedSecrets = {};
|
|
2138
3863
|
for (const key of SECRET_KEYS)
|
|
2139
3864
|
if (sourceEnv[key])
|
|
2140
3865
|
copiedSecrets[key] = sourceEnv[key];
|
|
2141
|
-
const targetEnv = buildEnv({
|
|
3866
|
+
const targetEnv = buildEnv({
|
|
3867
|
+
mode,
|
|
3868
|
+
version: release.or3Version,
|
|
3869
|
+
directory: targetDirectory,
|
|
3870
|
+
image,
|
|
3871
|
+
email,
|
|
3872
|
+
password,
|
|
3873
|
+
domain,
|
|
3874
|
+
port: sourcePort,
|
|
3875
|
+
secrets: copiedSecrets,
|
|
3876
|
+
dashboardOperator: operator
|
|
3877
|
+
});
|
|
2142
3878
|
for (const key of ALLOWED_ENV_KEYS)
|
|
2143
3879
|
if (sourceEnv[key] !== undefined)
|
|
2144
3880
|
targetEnv[key] = sourceEnv[key];
|
|
@@ -2156,50 +3892,50 @@ async function adoptCommand(positionals, flags) {
|
|
|
2156
3892
|
OR3_PUBLIC_DOMAIN: domain ?? "localhost",
|
|
2157
3893
|
OR3_ALLOWED_ORIGINS: mode === "public" ? `https://${domain}` : `http://127.0.0.1:${sourcePort}`,
|
|
2158
3894
|
OR3_FORCE_HTTPS: mode === "public" ? "true" : "false",
|
|
2159
|
-
OR3_TRUST_PROXY: mode === "public" ? "true" : "false"
|
|
3895
|
+
OR3_TRUST_PROXY: mode === "public" ? "true" : "false",
|
|
3896
|
+
OR3_AUTH_REGISTRATION_MODE: "invite_only",
|
|
3897
|
+
OR3_AUTH_AUTO_PROVISION: "false",
|
|
3898
|
+
OR3_GUEST_ACCESS_ENABLED: "false"
|
|
2160
3899
|
});
|
|
2161
|
-
const targetNames = composeProjectNames(targetDirectory);
|
|
2162
|
-
for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
|
|
2163
|
-
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
2164
|
-
if (existing.ok)
|
|
2165
|
-
throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
|
|
2166
|
-
}
|
|
2167
3900
|
await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
|
|
2168
3901
|
await writeSecure(join(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
2169
3902
|
bootstrapEmail: email,
|
|
2170
3903
|
bootstrapPassword: password,
|
|
2171
|
-
adminUsername:
|
|
2172
|
-
adminPassword:
|
|
3904
|
+
adminUsername: targetEnv.OR3_ADMIN_USERNAME,
|
|
3905
|
+
adminPassword: targetEnv.OR3_ADMIN_PASSWORD
|
|
2173
3906
|
}));
|
|
2174
|
-
const digest = await imageDigest(image);
|
|
2175
3907
|
const state = stateFromEnv(targetDirectory, targetEnv, mode, "adopt", digest);
|
|
3908
|
+
const sourceBackupId = id("backup-adopt-source");
|
|
3909
|
+
const sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
|
|
2176
3910
|
await markPending(targetDirectory, state, {
|
|
2177
3911
|
id: id("adopt"),
|
|
2178
3912
|
operation: "adopt",
|
|
2179
3913
|
startedAt: now(),
|
|
2180
3914
|
message: `Adopting ${sourceDirectory}`,
|
|
2181
|
-
sourceDirectory
|
|
3915
|
+
sourceDirectory,
|
|
3916
|
+
sourceInitiallyRunning,
|
|
3917
|
+
backupId: sourceBackupId,
|
|
3918
|
+
backupPath: sourceBackupDir,
|
|
3919
|
+
phase: "prepared"
|
|
2182
3920
|
});
|
|
2183
3921
|
let sourceStopAttempted = false;
|
|
2184
|
-
let sourceBackupDir;
|
|
2185
3922
|
try {
|
|
2186
3923
|
sourceStopAttempted = true;
|
|
2187
3924
|
const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
|
|
2188
3925
|
if (!sourceStop.ok)
|
|
2189
3926
|
throw new Error(`Could not stop the V1 deployment. ${redact(sourceStop.stderr, secretValues(sourceEnv))}`);
|
|
2190
|
-
|
|
3927
|
+
const localDockerDaemon = await dockerDaemonIsLocal();
|
|
3928
|
+
if (localDockerDaemon && !await portAvailable(Number(targetEnv.OR3_PORT)))
|
|
2191
3929
|
throw new Error(`Port ${targetEnv.OR3_PORT} is still in use after stopping the V1 deployment.`);
|
|
2192
|
-
if (mode === "public") {
|
|
3930
|
+
if (mode === "public" && localDockerDaemon) {
|
|
2193
3931
|
for (const publicPort of [80, 443]) {
|
|
2194
3932
|
if (!await portAvailable(publicPort))
|
|
2195
3933
|
throw new Error(`Public port ${publicPort} is still in use after stopping the V1 deployment.`);
|
|
2196
3934
|
}
|
|
2197
3935
|
}
|
|
2198
|
-
const sourceBackupId = id("adopt-source");
|
|
2199
|
-
sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
|
|
2200
3936
|
await mkdir(sourceBackupDir, { recursive: true, mode: 448 });
|
|
2201
3937
|
await copySecure(join(sourceDirectory, ".env"), join(sourceBackupDir, "config.env"));
|
|
2202
|
-
await archiveExternalVolume(
|
|
3938
|
+
await archiveExternalVolume(sourceImage, sourceVolume, sourceBackupDir);
|
|
2203
3939
|
const sourceManifest = {
|
|
2204
3940
|
schemaVersion: 1,
|
|
2205
3941
|
backupId: sourceBackupId,
|
|
@@ -2217,11 +3953,19 @@ async function adoptCommand(positionals, flags) {
|
|
|
2217
3953
|
caddyConfigVolume: sourceEnv.OR3_CADDY_CONFIG_VOLUME,
|
|
2218
3954
|
port: sourcePort
|
|
2219
3955
|
};
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
await
|
|
3956
|
+
const sourceManifestContents = `${JSON.stringify(sourceManifest, null, 2)}
|
|
3957
|
+
`;
|
|
3958
|
+
await writeSecure(join(sourceBackupDir, "manifest.json"), sourceManifestContents);
|
|
3959
|
+
await writeBackupAuthentication(targetDirectory, sourceBackupDir, sourceManifestContents);
|
|
3960
|
+
await readManifest(sourceBackupDir, targetDirectory);
|
|
3961
|
+
await updatePending(targetDirectory, state, { phase: "snapshot-created" });
|
|
2223
3962
|
await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
|
|
2224
3963
|
await startProject(targetDirectory, mode, targetEnv);
|
|
3964
|
+
await provisionManagedCredentials(mode === "public" ? new URL(`https://${domain}`) : new URL(`http://127.0.0.1:${sourcePort}`), email, password, targetEnv.OR3_ADMIN_USERNAME, targetEnv.OR3_ADMIN_PASSWORD);
|
|
3965
|
+
const runtimeEnv = withoutProvisioningCredentials(targetEnv);
|
|
3966
|
+
await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(runtimeEnv));
|
|
3967
|
+
await stopProject(targetDirectory, mode);
|
|
3968
|
+
await startProject(targetDirectory, mode, runtimeEnv);
|
|
2225
3969
|
await clearPending(targetDirectory, state);
|
|
2226
3970
|
console.log(`Adopted ${sourceDirectory} into ${targetDirectory} at OR3 ${release.or3Version}.`);
|
|
2227
3971
|
console.log(`Source backup: ${sourceBackupDir}`);
|
|
@@ -2231,7 +3975,7 @@ async function adoptCommand(positionals, flags) {
|
|
|
2231
3975
|
return;
|
|
2232
3976
|
});
|
|
2233
3977
|
let sourceRecoveryError = "";
|
|
2234
|
-
if (sourceStopAttempted) {
|
|
3978
|
+
if (sourceStopAttempted && sourceInitiallyRunning) {
|
|
2235
3979
|
try {
|
|
2236
3980
|
await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
|
|
2237
3981
|
} catch (recovery) {
|
|
@@ -2246,27 +3990,58 @@ ${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "
|
|
|
2246
3990
|
}
|
|
2247
3991
|
async function resolveResetPasswords(flags) {
|
|
2248
3992
|
const ownerPassword = stringFlag(flags, "owner-password");
|
|
3993
|
+
const ownerPasswordFile = stringFlag(flags, "owner-password-file");
|
|
2249
3994
|
const adminPassword = stringFlag(flags, "admin-password");
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
3995
|
+
const adminPasswordFile = stringFlag(flags, "admin-password-file");
|
|
3996
|
+
if (flags["owner-password"] !== undefined && ownerPassword === undefined)
|
|
3997
|
+
throw new Error("--owner-password requires a value.");
|
|
3998
|
+
if (flags["admin-password"] !== undefined && adminPassword === undefined)
|
|
3999
|
+
throw new Error("--admin-password requires a value.");
|
|
4000
|
+
if (flags["owner-password-file"] !== undefined && !ownerPasswordFile)
|
|
4001
|
+
throw new Error("--owner-password-file requires a path.");
|
|
4002
|
+
if (flags["admin-password-file"] !== undefined && !adminPasswordFile)
|
|
4003
|
+
throw new Error("--admin-password-file requires a path.");
|
|
4004
|
+
if (ownerPassword !== undefined && ownerPasswordFile)
|
|
4005
|
+
throw new Error("Use either --owner-password or --owner-password-file, not both.");
|
|
4006
|
+
if (adminPassword !== undefined && adminPasswordFile)
|
|
4007
|
+
throw new Error("Use either --admin-password or --admin-password-file, not both.");
|
|
4008
|
+
const suppliedOwner = ownerPassword ?? (ownerPasswordFile ? (await readText(resolve(ownerPasswordFile))).trim() : undefined);
|
|
4009
|
+
const suppliedAdmin = adminPassword ?? (adminPasswordFile ? (await readText(resolve(adminPasswordFile))).trim() : undefined);
|
|
4010
|
+
if (suppliedOwner !== undefined || suppliedAdmin !== undefined) {
|
|
4011
|
+
if (suppliedOwner === undefined || suppliedAdmin === undefined) {
|
|
4012
|
+
throw new Error("Supply both owner and admin passwords so a reset never applies one credential without the other.");
|
|
2253
4013
|
}
|
|
2254
|
-
validatePassword(
|
|
2255
|
-
validatePassword(
|
|
2256
|
-
return { ownerPassword, adminPassword };
|
|
4014
|
+
validatePassword(suppliedOwner);
|
|
4015
|
+
validatePassword(suppliedAdmin);
|
|
4016
|
+
return { ownerPassword: suppliedOwner, adminPassword: suppliedAdmin };
|
|
2257
4017
|
}
|
|
2258
4018
|
if (!input.isTTY || !output.isTTY) {
|
|
2259
|
-
throw new Error("--owner-password and --admin-password are required in a non-interactive session. Credentials are never generated automatically.");
|
|
4019
|
+
throw new Error("--owner-password-file and --admin-password-file are required in a non-interactive session. Credentials are never generated automatically.");
|
|
2260
4020
|
}
|
|
2261
|
-
const
|
|
4021
|
+
const owner = (await maskedQuestion("New owner (basic auth) password: ")).trim();
|
|
4022
|
+
validatePassword(owner);
|
|
4023
|
+
const admin = (await maskedQuestion("New admin password: ")).trim();
|
|
4024
|
+
validatePassword(admin);
|
|
4025
|
+
return { ownerPassword: owner, adminPassword: admin };
|
|
4026
|
+
}
|
|
4027
|
+
async function maskedQuestion(question) {
|
|
4028
|
+
let muted = false;
|
|
4029
|
+
const maskedOutput = new Writable({
|
|
4030
|
+
write(chunk, _encoding, callback) {
|
|
4031
|
+
if (!muted)
|
|
4032
|
+
output.write(chunk);
|
|
4033
|
+
callback();
|
|
4034
|
+
}
|
|
4035
|
+
});
|
|
4036
|
+
const prompt = readline.createInterface({ input, output: maskedOutput, terminal: true });
|
|
2262
4037
|
try {
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
validatePassword(admin);
|
|
2267
|
-
return { ownerPassword: owner, adminPassword: admin };
|
|
4038
|
+
const answer = prompt.question(question);
|
|
4039
|
+
muted = true;
|
|
4040
|
+
return await answer;
|
|
2268
4041
|
} finally {
|
|
2269
4042
|
prompt.close();
|
|
4043
|
+
output.write(`
|
|
4044
|
+
`);
|
|
2270
4045
|
}
|
|
2271
4046
|
}
|
|
2272
4047
|
function buildCredentialsResetScript(input2) {
|
|
@@ -2334,24 +4109,32 @@ try {
|
|
|
2334
4109
|
credentials.username = adminUsername;
|
|
2335
4110
|
credentials.password_hash_bcrypt = adminHash;
|
|
2336
4111
|
credentials.updated_at = new Date().toISOString();
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
4112
|
+
const atomicWrite = (target, contents) => {
|
|
4113
|
+
const temporary = target + '.reset-' + process.pid + '-' + Date.now();
|
|
4114
|
+
const descriptor = fs.openSync(temporary, 'w', 0o600);
|
|
4115
|
+
try {
|
|
4116
|
+
fs.writeFileSync(descriptor, contents, 'utf8');
|
|
4117
|
+
fs.fsyncSync(descriptor);
|
|
4118
|
+
} finally {
|
|
4119
|
+
fs.closeSync(descriptor);
|
|
4120
|
+
}
|
|
4121
|
+
fs.renameSync(temporary, target);
|
|
4122
|
+
const directory = fs.openSync(path.dirname(target), 'r');
|
|
4123
|
+
try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
|
|
4124
|
+
};
|
|
2343
4125
|
try {
|
|
2344
4126
|
db.transaction(() => {
|
|
2345
4127
|
db.prepare('UPDATE basic_auth_accounts SET password_hash = ?, token_version = token_version + 1, updated_at = ? WHERE id = ?').run(ownerHash, now, account.id);
|
|
2346
4128
|
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);
|
|
4129
|
+
atomicWrite(adminCredentialsPath, JSON.stringify(credentials, null, 2) + '\\n');
|
|
2347
4130
|
})();
|
|
2348
4131
|
} catch (error) {
|
|
2349
4132
|
if (previousCredentials !== null) {
|
|
2350
|
-
try {
|
|
4133
|
+
try { atomicWrite(adminCredentialsPath, previousCredentials); } catch {}
|
|
2351
4134
|
} else {
|
|
2352
4135
|
try { fs.rmSync(adminCredentialsPath, { force: true }); } catch {}
|
|
2353
4136
|
}
|
|
2354
|
-
console.error('OR3 credentials reset failed: the
|
|
4137
|
+
console.error('OR3 credentials reset failed: the atomic database/file update did not complete. Replay is safe through the managed recovery journal. ' + (error && error.message ? error.message : String(error)));
|
|
2355
4138
|
process.exit(1);
|
|
2356
4139
|
}
|
|
2357
4140
|
db.close();
|
|
@@ -2359,7 +4142,7 @@ console.log('credentials-reset: owner hash, sessions, and admin credentials upda
|
|
|
2359
4142
|
`;
|
|
2360
4143
|
}
|
|
2361
4144
|
function credentialResetValues(env) {
|
|
2362
|
-
const ownerEmail = env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
4145
|
+
const ownerEmail = env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
2363
4146
|
const ownerPassword = env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
2364
4147
|
const adminUsername = env.OR3_ADMIN_USERNAME;
|
|
2365
4148
|
const adminPassword = env.OR3_ADMIN_PASSWORD;
|
|
@@ -2385,43 +4168,57 @@ Promise.all([
|
|
|
2385
4168
|
`;
|
|
2386
4169
|
async function runCredentialsResetScript(directory, mode, values) {
|
|
2387
4170
|
const script = buildCredentialsResetScript(values);
|
|
4171
|
+
const resetEnvironment = {
|
|
4172
|
+
...composeProcessEnv(directory),
|
|
4173
|
+
OR3_RESET_OWNER_EMAIL: values.ownerEmail,
|
|
4174
|
+
OR3_RESET_OWNER_PASSWORD: values.ownerPassword,
|
|
4175
|
+
OR3_RESET_ADMIN_USERNAME: values.adminUsername,
|
|
4176
|
+
OR3_RESET_ADMIN_PASSWORD: values.adminPassword
|
|
4177
|
+
};
|
|
2388
4178
|
const result = await run("docker", [
|
|
2389
4179
|
...composeArgs(directory, mode, [
|
|
2390
4180
|
"exec",
|
|
2391
4181
|
"-T",
|
|
2392
4182
|
"-e",
|
|
2393
|
-
|
|
4183
|
+
"OR3_RESET_OWNER_EMAIL",
|
|
2394
4184
|
"-e",
|
|
2395
|
-
|
|
4185
|
+
"OR3_RESET_OWNER_PASSWORD",
|
|
2396
4186
|
"-e",
|
|
2397
|
-
|
|
4187
|
+
"OR3_RESET_ADMIN_USERNAME",
|
|
2398
4188
|
"-e",
|
|
2399
|
-
|
|
4189
|
+
"OR3_RESET_ADMIN_PASSWORD",
|
|
2400
4190
|
"or3",
|
|
2401
4191
|
...containerNodeCommand(script)
|
|
2402
4192
|
])
|
|
2403
|
-
], directory);
|
|
4193
|
+
], directory, resetEnvironment);
|
|
2404
4194
|
if (!result.ok)
|
|
2405
4195
|
throw new Error(`${result.command}
|
|
2406
4196
|
${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
|
|
2407
4197
|
}
|
|
2408
4198
|
async function verifyCredentialsInsideContainer(directory, mode, values) {
|
|
4199
|
+
const resetEnvironment = {
|
|
4200
|
+
...composeProcessEnv(directory),
|
|
4201
|
+
OR3_RESET_OWNER_EMAIL: values.ownerEmail,
|
|
4202
|
+
OR3_RESET_OWNER_PASSWORD: values.ownerPassword,
|
|
4203
|
+
OR3_RESET_ADMIN_USERNAME: values.adminUsername,
|
|
4204
|
+
OR3_RESET_ADMIN_PASSWORD: values.adminPassword
|
|
4205
|
+
};
|
|
2409
4206
|
const result = await run("docker", [
|
|
2410
4207
|
...composeArgs(directory, mode, [
|
|
2411
4208
|
"exec",
|
|
2412
4209
|
"-T",
|
|
2413
4210
|
"-e",
|
|
2414
|
-
|
|
4211
|
+
"OR3_RESET_OWNER_EMAIL",
|
|
2415
4212
|
"-e",
|
|
2416
|
-
|
|
4213
|
+
"OR3_RESET_OWNER_PASSWORD",
|
|
2417
4214
|
"-e",
|
|
2418
|
-
|
|
4215
|
+
"OR3_RESET_ADMIN_USERNAME",
|
|
2419
4216
|
"-e",
|
|
2420
|
-
|
|
4217
|
+
"OR3_RESET_ADMIN_PASSWORD",
|
|
2421
4218
|
"or3",
|
|
2422
4219
|
...containerNodeCommand(CREDENTIALS_VERIFY_SCRIPT)
|
|
2423
4220
|
])
|
|
2424
|
-
], directory);
|
|
4221
|
+
], directory, resetEnvironment);
|
|
2425
4222
|
if (!result.ok)
|
|
2426
4223
|
throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
|
|
2427
4224
|
}
|
|
@@ -2433,18 +4230,12 @@ async function applyCredentialReset(directory, state, nextEnv) {
|
|
|
2433
4230
|
await startProject(directory, state.mode, nextEnv);
|
|
2434
4231
|
}
|
|
2435
4232
|
await runCredentialsResetScript(directory, state.mode, values);
|
|
2436
|
-
|
|
4233
|
+
const runtimeEnv = withoutProvisioningCredentials(nextEnv);
|
|
4234
|
+
await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
|
|
2437
4235
|
const initialCredentials = join(directory, ".or3-initial-credentials");
|
|
2438
|
-
|
|
2439
|
-
await writeSecure(initialCredentials, serializeInitialCredentials({
|
|
2440
|
-
bootstrapEmail: values.ownerEmail,
|
|
2441
|
-
bootstrapPassword: values.ownerPassword,
|
|
2442
|
-
adminUsername: values.adminUsername,
|
|
2443
|
-
adminPassword: values.adminPassword
|
|
2444
|
-
}));
|
|
2445
|
-
}
|
|
4236
|
+
await rm(initialCredentials, { force: true });
|
|
2446
4237
|
await stopProject(directory, state.mode);
|
|
2447
|
-
await startProject(directory, state.mode,
|
|
4238
|
+
await startProject(directory, state.mode, runtimeEnv);
|
|
2448
4239
|
await verifyCredentialsInsideContainer(directory, state.mode, values);
|
|
2449
4240
|
}
|
|
2450
4241
|
async function credentialsResetCommand(directory, flags) {
|
|
@@ -2454,15 +4245,16 @@ async function credentialsResetCommand(directory, flags) {
|
|
|
2454
4245
|
const loaded = await loadManaged(directory);
|
|
2455
4246
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2456
4247
|
assertNoPending(loaded.state);
|
|
2457
|
-
const ownerEmail = loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
4248
|
+
const ownerEmail = loaded.env.OR3_MANAGED_OWNER_EMAIL || loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
2458
4249
|
const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
|
|
2459
4250
|
if (!ownerEmail)
|
|
2460
|
-
throw new Error("
|
|
4251
|
+
throw new Error("The managed owner email is missing from .env, so the owner account cannot be reset.");
|
|
2461
4252
|
if (!adminUsername)
|
|
2462
4253
|
throw new Error("OR3_ADMIN_USERNAME is missing from .env, so the admin password cannot be reset.");
|
|
2463
4254
|
const { ownerPassword, adminPassword } = await resolveResetPasswords(flags);
|
|
2464
4255
|
const nextEnv = {
|
|
2465
4256
|
...loaded.env,
|
|
4257
|
+
OR3_MANAGED_OWNER_EMAIL: ownerEmail,
|
|
2466
4258
|
OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: ownerPassword,
|
|
2467
4259
|
OR3_ADMIN_PASSWORD: adminPassword,
|
|
2468
4260
|
OR3_ADMIN_JWT_SECRET: randomSecret()
|
|
@@ -2479,7 +4271,7 @@ async function credentialsResetCommand(directory, flags) {
|
|
|
2479
4271
|
await applyCredentialReset(loaded.directory, loaded.state, nextEnv);
|
|
2480
4272
|
await clearPending(loaded.directory, loaded.state);
|
|
2481
4273
|
console.log("Owner and admin credentials are now separate. The old owner password and all app sessions were revoked.");
|
|
2482
|
-
console.log("Admin session cookies were invalidated by rotating OR3_ADMIN_JWT_SECRET. New passwords
|
|
4274
|
+
console.log("Admin session cookies were invalidated by rotating OR3_ADMIN_JWT_SECRET. New passwords persist only as account hashes; sign in again with them.");
|
|
2483
4275
|
} catch (error) {
|
|
2484
4276
|
loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(nextEnv));
|
|
2485
4277
|
await writeState(loaded.directory, loaded.state);
|
|
@@ -2629,8 +4421,12 @@ async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
|
|
|
2629
4421
|
const receipt = await readBackupExportReceipt(directory, backup.backupId);
|
|
2630
4422
|
if (!receipt || !await fileExists(receipt.destination))
|
|
2631
4423
|
continue;
|
|
4424
|
+
const deploymentRoot = await realpath(directory);
|
|
2632
4425
|
try {
|
|
2633
|
-
const
|
|
4426
|
+
const canonicalDestination = await realpath(receipt.destination);
|
|
4427
|
+
if (canonicalDestination === deploymentRoot || canonicalDestination.startsWith(`${deploymentRoot}${sep}`))
|
|
4428
|
+
continue;
|
|
4429
|
+
const exported = await readManifest(receipt.destination, directory);
|
|
2634
4430
|
const sourceDevice = (await stat(backup.path)).dev;
|
|
2635
4431
|
const destinationDevice = (await stat(receipt.destination)).dev;
|
|
2636
4432
|
if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
|
|
@@ -2655,13 +4451,19 @@ async function removeCommand(directory, flags) {
|
|
|
2655
4451
|
}
|
|
2656
4452
|
const exportReceipt = await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
|
|
2657
4453
|
const volumes = purgeVolumesFromState(state);
|
|
2658
|
-
const managedFiles = [
|
|
4454
|
+
const managedFiles = [
|
|
4455
|
+
".env",
|
|
4456
|
+
".or3-cloud",
|
|
4457
|
+
...managedAssetNames(state.mode),
|
|
4458
|
+
".or3-initial-credentials"
|
|
4459
|
+
];
|
|
2659
4460
|
console.log("Removing exactly these targets:");
|
|
2660
4461
|
for (const volume of volumes)
|
|
2661
4462
|
console.log(` docker volume ${volume}`);
|
|
2662
4463
|
for (const file of managedFiles)
|
|
2663
4464
|
console.log(` ${join(directory, file)}`);
|
|
2664
4465
|
await compose(directory, state.mode, ["down"]);
|
|
4466
|
+
await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
|
|
2665
4467
|
for (const volume of volumes) {
|
|
2666
4468
|
const result = await run("docker", ["volume", "rm", volume], directory);
|
|
2667
4469
|
if (!result.ok)
|
|
@@ -2682,6 +4484,30 @@ async function probeDeepHealth(directory, mode) {
|
|
|
2682
4484
|
return "degraded";
|
|
2683
4485
|
return "unreachable";
|
|
2684
4486
|
}
|
|
4487
|
+
var MUTATING_COMMANDS = new Set([
|
|
4488
|
+
"init",
|
|
4489
|
+
"update",
|
|
4490
|
+
"backup",
|
|
4491
|
+
"verify",
|
|
4492
|
+
"restore",
|
|
4493
|
+
"rollback",
|
|
4494
|
+
"credentials",
|
|
4495
|
+
"recover",
|
|
4496
|
+
"adopt",
|
|
4497
|
+
"start",
|
|
4498
|
+
"stop",
|
|
4499
|
+
"restart",
|
|
4500
|
+
"remove"
|
|
4501
|
+
]);
|
|
4502
|
+
function mutationDirectory(command, positionals, flags) {
|
|
4503
|
+
if (command === "init")
|
|
4504
|
+
return resolve(process.cwd(), positionals[0] ?? "or3-cloud");
|
|
4505
|
+
if (command === "adopt") {
|
|
4506
|
+
const sourceDirectory = requireStringFlag(flags, "from");
|
|
4507
|
+
return resolve(process.cwd(), positionals[0] ?? `${basename(sourceDirectory)}-managed`);
|
|
4508
|
+
}
|
|
4509
|
+
return process.cwd();
|
|
4510
|
+
}
|
|
2685
4511
|
async function main(argv = process.argv.slice(2)) {
|
|
2686
4512
|
const [command = "help", ...rest] = argv;
|
|
2687
4513
|
if (command === "--help" || command === "help")
|
|
@@ -2693,37 +4519,46 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
2693
4519
|
if (parsed.flags.help)
|
|
2694
4520
|
return help();
|
|
2695
4521
|
assertCommandFlags(command, parsed.flags);
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
4522
|
+
assertCommandPositionals(command, parsed.positionals);
|
|
4523
|
+
const dispatch = async () => {
|
|
4524
|
+
if (command === "init")
|
|
4525
|
+
return await initCommand(parsed.positionals, parsed.flags);
|
|
4526
|
+
if (command === "update")
|
|
4527
|
+
return await updateCommand(process.cwd(), parsed.flags);
|
|
4528
|
+
if (command === "backup")
|
|
4529
|
+
return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
|
|
4530
|
+
if (command === "restore")
|
|
4531
|
+
return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
4532
|
+
if (command === "rollback")
|
|
4533
|
+
return await rollbackCommand(process.cwd(), parsed.flags);
|
|
4534
|
+
if (command === "credentials")
|
|
4535
|
+
return await credentialsCommand(process.cwd(), parsed.positionals, parsed.flags);
|
|
4536
|
+
if (command === "doctor")
|
|
4537
|
+
return await doctorCommand(process.cwd());
|
|
4538
|
+
if (command === "verify")
|
|
4539
|
+
return await verifyCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
4540
|
+
if (command === "recover")
|
|
4541
|
+
return await recoverCommand(process.cwd());
|
|
4542
|
+
if (command === "adopt")
|
|
4543
|
+
return await adoptCommand(parsed.positionals, parsed.flags);
|
|
4544
|
+
if (command === "status")
|
|
4545
|
+
return await statusCommand(process.cwd());
|
|
4546
|
+
if (command === "logs")
|
|
4547
|
+
return await logsCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
4548
|
+
if (command === "start")
|
|
4549
|
+
return await startCommand(process.cwd());
|
|
4550
|
+
if (command === "stop")
|
|
4551
|
+
return await stopCommand(process.cwd());
|
|
4552
|
+
if (command === "restart")
|
|
4553
|
+
return await restartCommand(process.cwd());
|
|
4554
|
+
if (command === "remove")
|
|
4555
|
+
return await removeCommand(process.cwd(), parsed.flags);
|
|
4556
|
+
throw new Error(`Unknown command "${command}". Run npx @or3/cloud --help.`);
|
|
4557
|
+
};
|
|
4558
|
+
if (MUTATING_COMMANDS.has(command)) {
|
|
4559
|
+
return await withDeploymentLease(mutationDirectory(command, parsed.positionals, parsed.flags), command, dispatch);
|
|
4560
|
+
}
|
|
4561
|
+
return await dispatch();
|
|
2727
4562
|
} catch (error) {
|
|
2728
4563
|
console.error(`
|
|
2729
4564
|
OR3 Cloud failed: ${redact(error instanceof Error ? error.message : String(error))}`);
|
|
@@ -2735,27 +4570,39 @@ if (invokedAsCli || process.argv[1] && resolve(process.argv[1]) === resolve(file
|
|
|
2735
4570
|
await main();
|
|
2736
4571
|
}
|
|
2737
4572
|
export {
|
|
4573
|
+
withoutProvisioningCredentials,
|
|
4574
|
+
validateVerificationHealth,
|
|
2738
4575
|
validatePassword,
|
|
4576
|
+
updateRequiresVolumeRecreation,
|
|
2739
4577
|
supportedImageArchitectures,
|
|
2740
4578
|
stateFromEnv,
|
|
4579
|
+
snapshotManagedAssets,
|
|
2741
4580
|
serializeInitialCredentials,
|
|
2742
4581
|
serializeEnv,
|
|
2743
4582
|
selectPruneTargets,
|
|
4583
|
+
sameOriginVerificationUrl,
|
|
4584
|
+
restoreRequiresVolumeRecreation,
|
|
4585
|
+
restoreManagedAssets,
|
|
2744
4586
|
requiredArchiveSpace,
|
|
2745
4587
|
redact,
|
|
2746
4588
|
purgeVolumesFromState,
|
|
2747
4589
|
parseFlags,
|
|
2748
4590
|
parseEnv,
|
|
4591
|
+
managedAssetNames,
|
|
2749
4592
|
isVersion,
|
|
4593
|
+
copyAssets,
|
|
2750
4594
|
checkResolvedLoopbackBinding,
|
|
2751
4595
|
buildEnv,
|
|
2752
4596
|
buildCredentialsResetScript,
|
|
4597
|
+
assertVerificationGrant,
|
|
2753
4598
|
assertSupportedSourceCompose,
|
|
2754
4599
|
assertSupportedSource,
|
|
2755
4600
|
assertSupportedArchitecture,
|
|
2756
4601
|
assertRemovableArtifactName,
|
|
2757
4602
|
assertPurgeBackupFreshness,
|
|
4603
|
+
assertImageReleaseLabels,
|
|
2758
4604
|
assertEnoughFreeSpace,
|
|
4605
|
+
assertCommandPositionals,
|
|
2759
4606
|
assertCommandFlags,
|
|
2760
4607
|
assertBackupMatchesDeployment,
|
|
2761
4608
|
PACKAGE_VERSION,
|