@or3/cloud 0.1.38 → 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 +100 -4
- 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 +2071 -343
- 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) {
|
|
@@ -795,12 +1513,21 @@ async function readPassword(flags) {
|
|
|
795
1513
|
return password;
|
|
796
1514
|
}
|
|
797
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
|
+
}
|
|
798
1524
|
return ["compose.yaml", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
|
|
799
1525
|
}
|
|
800
1526
|
async function installManagedAssets(directory, assets) {
|
|
801
1527
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
802
1528
|
const token = randomBytes(4).toString("hex");
|
|
803
1529
|
const staged = [];
|
|
1530
|
+
let retainRollbackCopies = false;
|
|
804
1531
|
try {
|
|
805
1532
|
for (const [name, contents] of assets) {
|
|
806
1533
|
const destination = join(directory, name);
|
|
@@ -821,16 +1548,29 @@ async function installManagedAssets(directory, assets) {
|
|
|
821
1548
|
const applied = [];
|
|
822
1549
|
try {
|
|
823
1550
|
for (const entry of staged) {
|
|
824
|
-
await rename(entry.replacement, entry.destination);
|
|
825
1551
|
applied.push(entry);
|
|
826
|
-
await
|
|
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
|
+
}
|
|
827
1558
|
}
|
|
828
1559
|
} catch (error) {
|
|
1560
|
+
const rollbackErrors = [];
|
|
829
1561
|
for (const entry of applied.reverse()) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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.");
|
|
834
1574
|
}
|
|
835
1575
|
throw error;
|
|
836
1576
|
}
|
|
@@ -839,7 +1579,7 @@ async function installManagedAssets(directory, assets) {
|
|
|
839
1579
|
await rm(entry.replacement, { force: true }).catch(() => {
|
|
840
1580
|
return;
|
|
841
1581
|
});
|
|
842
|
-
if (entry.rollback)
|
|
1582
|
+
if (entry.rollback && !retainRollbackCopies)
|
|
843
1583
|
await rm(entry.rollback, { force: true }).catch(() => {
|
|
844
1584
|
return;
|
|
845
1585
|
});
|
|
@@ -859,6 +1599,8 @@ async function snapshotManagedAssets(directory, mode, backupDir) {
|
|
|
859
1599
|
await chmod(assetDir, 448);
|
|
860
1600
|
const checksums = {};
|
|
861
1601
|
for (const name of managedAssetNames(mode)) {
|
|
1602
|
+
if ((name === "dashboard-operator.mjs" || name === "compose.operator.yaml") && !await fileExists(join(directory, name)))
|
|
1603
|
+
continue;
|
|
862
1604
|
const destination = join(assetDir, name);
|
|
863
1605
|
await copySecure(join(directory, name), destination);
|
|
864
1606
|
checksums[name] = await sha256File(destination);
|
|
@@ -869,9 +1611,9 @@ async function verifiedManagedAssetContents(backupPath, manifest) {
|
|
|
869
1611
|
const checksums = manifest.managedAssetSha256;
|
|
870
1612
|
if (!checksums)
|
|
871
1613
|
return;
|
|
872
|
-
const expected =
|
|
1614
|
+
const expected = managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion).sort();
|
|
873
1615
|
const actual = Object.keys(checksums).sort();
|
|
874
|
-
if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
|
|
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])) {
|
|
875
1617
|
throw new Error(`Backup ${manifest.backupId} has an invalid managed asset inventory.`);
|
|
876
1618
|
}
|
|
877
1619
|
const assets = new Map;
|
|
@@ -891,11 +1633,23 @@ async function verifiedManagedAssetContents(backupPath, manifest) {
|
|
|
891
1633
|
}
|
|
892
1634
|
async function restoreManagedAssets(directory, backupPath, manifest) {
|
|
893
1635
|
const assets = await verifiedManagedAssetContents(backupPath, manifest);
|
|
894
|
-
if (!assets)
|
|
895
|
-
|
|
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
|
+
}
|
|
896
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
|
+
}
|
|
897
1646
|
return true;
|
|
898
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.`);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
899
1653
|
async function checkPublicPrerequisites(domain) {
|
|
900
1654
|
validateDomain(domain);
|
|
901
1655
|
let address;
|
|
@@ -907,6 +1661,10 @@ async function checkPublicPrerequisites(domain) {
|
|
|
907
1661
|
return address;
|
|
908
1662
|
}
|
|
909
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;
|
|
910
1668
|
state.incompleteOperation = operation;
|
|
911
1669
|
await writeState(directory, state);
|
|
912
1670
|
await writeSecure(join(deploymentPaths(directory).operations, `${operation.id}.json`), `${JSON.stringify(operation, null, 2)}
|
|
@@ -936,10 +1694,29 @@ async function clearPending(directory, state) {
|
|
|
936
1694
|
async function stopProject(directory, mode) {
|
|
937
1695
|
await compose(directory, mode, ["stop", "or3"]);
|
|
938
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
|
+
}
|
|
939
1714
|
async function startProject(directory, mode, env) {
|
|
940
1715
|
await assertSafeComposeBinding(directory, mode, env);
|
|
941
|
-
|
|
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));
|
|
942
1718
|
await waitForDeepHealth(directory, mode, secretValues(env));
|
|
1719
|
+
await assertRunningAppImage(directory, mode, env.OR3_IMAGE);
|
|
943
1720
|
}
|
|
944
1721
|
function backupDirectory(directory, backupId) {
|
|
945
1722
|
return join(deploymentPaths(directory).backups, backupId);
|
|
@@ -954,7 +1731,22 @@ async function dataVolumeSize(directory, mode, env) {
|
|
|
954
1731
|
const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", "du -sb /data 2>/dev/null"])], directory);
|
|
955
1732
|
if (exec.ok)
|
|
956
1733
|
return parseDuOutput(exec.stdout);
|
|
957
|
-
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);
|
|
958
1750
|
if (fallback.ok)
|
|
959
1751
|
return parseDuOutput(fallback.stdout);
|
|
960
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}`)}`);
|
|
@@ -974,37 +1766,41 @@ async function removeNamedBackupArtifact(directory, backupId) {
|
|
|
974
1766
|
await rm(target, { recursive: true, force: true });
|
|
975
1767
|
await rm(join(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
|
|
976
1768
|
}
|
|
977
|
-
async function
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
return manifest;
|
|
983
|
-
} catch {
|
|
984
|
-
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}.`);
|
|
985
1774
|
}
|
|
1775
|
+
await rm(target, { recursive: true, force: true });
|
|
1776
|
+
await rm(join(deploymentPaths(directory).exports, `${backup.backupId}.json`), { force: true });
|
|
986
1777
|
}
|
|
987
1778
|
async function enumerateBackups(directory) {
|
|
988
1779
|
const backupsRoot = deploymentPaths(directory).backups;
|
|
989
1780
|
let entries = [];
|
|
990
1781
|
try {
|
|
991
1782
|
entries = await readdir(backupsRoot);
|
|
992
|
-
} catch {
|
|
993
|
-
|
|
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)}`);
|
|
994
1787
|
}
|
|
995
1788
|
const result = [];
|
|
996
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
|
+
}
|
|
997
1793
|
const path = join(backupsRoot, entry);
|
|
998
|
-
const manifest = await
|
|
999
|
-
if (
|
|
1000
|
-
|
|
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
|
+
}
|
|
1001
1801
|
let bytes = 0;
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
bytes += (await stat(join(path, file))).size;
|
|
1005
|
-
}
|
|
1006
|
-
} catch {
|
|
1007
|
-
bytes = 0;
|
|
1802
|
+
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
|
|
1803
|
+
bytes += (await stat(join(path, file))).size;
|
|
1008
1804
|
}
|
|
1009
1805
|
result.push({
|
|
1010
1806
|
backupId: manifest.backupId,
|
|
@@ -1044,8 +1840,11 @@ async function pruneBackups(directory, state, keep, force) {
|
|
|
1044
1840
|
console.log(`--force --yes will permanently delete: ${targets.join(", ")}`);
|
|
1045
1841
|
}
|
|
1046
1842
|
for (const backupId of targets) {
|
|
1047
|
-
|
|
1048
|
-
|
|
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}`);
|
|
1049
1848
|
}
|
|
1050
1849
|
return targets.length;
|
|
1051
1850
|
}
|
|
@@ -1065,9 +1864,31 @@ function assertDeploymentIdentity(state, env) {
|
|
|
1065
1864
|
throw new Error(`Managed state does not match ${key} in .env. Refusing to operate on an unexpected deployment identity.`);
|
|
1066
1865
|
}
|
|
1067
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
|
+
}
|
|
1068
1886
|
}
|
|
1069
1887
|
function assertDeploymentDirectoryIdentity(directory, state) {
|
|
1070
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
|
+
}
|
|
1071
1892
|
const names = composeProjectNames(resolved);
|
|
1072
1893
|
const mismatches = [];
|
|
1073
1894
|
if (state.composeProject !== names.project) {
|
|
@@ -1121,6 +1942,12 @@ function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
|
|
|
1121
1942
|
if (manifest.caddyConfigVolume !== undefined && manifest.caddyConfigVolume !== state.caddyConfigVolume) {
|
|
1122
1943
|
throw new Error(`Backup ${manifest.backupId} belongs to a different Caddy config volume.`);
|
|
1123
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
|
+
}
|
|
1124
1951
|
if (manifest.port !== undefined && manifest.port !== state.port) {
|
|
1125
1952
|
throw new Error(`Backup ${manifest.backupId} belongs to port ${manifest.port}, not ${state.port}.`);
|
|
1126
1953
|
}
|
|
@@ -1135,14 +1962,35 @@ async function sha256File(path) {
|
|
|
1135
1962
|
});
|
|
1136
1963
|
return digest.digest("hex");
|
|
1137
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
|
+
}
|
|
1138
1976
|
async function streamCommandToFile(command, args, destination, cwd, secrets = []) {
|
|
1139
1977
|
if (await fileExists(destination)) {
|
|
1140
1978
|
throw new Error(`Refusing to overwrite existing archive ${destination}.`);
|
|
1141
1979
|
}
|
|
1142
1980
|
await mkdir(dirname(destination), { recursive: true, mode: 448 });
|
|
1143
1981
|
const temporary = `${destination}.${randomBytes(8).toString("hex")}.partial`;
|
|
1144
|
-
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
|
+
});
|
|
1145
1988
|
let stderr = "";
|
|
1989
|
+
let timedOut = false;
|
|
1990
|
+
const timeout = setTimeout(() => {
|
|
1991
|
+
timedOut = true;
|
|
1992
|
+
terminateChildProcess(child);
|
|
1993
|
+
}, STREAM_COMMAND_TIMEOUT_MS);
|
|
1146
1994
|
child.stderr.setEncoding("utf8");
|
|
1147
1995
|
child.stderr.on("data", (chunk) => {
|
|
1148
1996
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
@@ -1156,25 +2004,38 @@ async function streamCommandToFile(command, args, destination, cwd, secrets = []
|
|
|
1156
2004
|
exit,
|
|
1157
2005
|
pipeline(child.stdout, createWriteStream(temporary, { flags: "wx", mode: 384 }))
|
|
1158
2006
|
]);
|
|
2007
|
+
if (timedOut)
|
|
2008
|
+
throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
|
|
1159
2009
|
if (exitCode !== 0) {
|
|
1160
2010
|
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
1161
2011
|
}
|
|
1162
2012
|
await chmod(temporary, 384);
|
|
1163
|
-
await
|
|
2013
|
+
await durableRename(temporary, destination);
|
|
1164
2014
|
} catch (error) {
|
|
1165
|
-
|
|
1166
|
-
child.kill("SIGTERM");
|
|
2015
|
+
terminateChildProcess(child);
|
|
1167
2016
|
await rm(temporary, { force: true }).catch(() => {
|
|
1168
2017
|
return;
|
|
1169
2018
|
});
|
|
1170
2019
|
const detail = error instanceof Error ? error.message : String(error);
|
|
1171
2020
|
throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
|
|
1172
2021
|
${redact(stderr, secrets)}` : ""}`);
|
|
2022
|
+
} finally {
|
|
2023
|
+
clearTimeout(timeout);
|
|
1173
2024
|
}
|
|
1174
2025
|
}
|
|
1175
2026
|
async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
1176
|
-
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
|
+
});
|
|
1177
2033
|
let stderr = "";
|
|
2034
|
+
let timedOut = false;
|
|
2035
|
+
const timeout = setTimeout(() => {
|
|
2036
|
+
timedOut = true;
|
|
2037
|
+
terminateChildProcess(child);
|
|
2038
|
+
}, STREAM_COMMAND_TIMEOUT_MS);
|
|
1178
2039
|
child.stderr.setEncoding("utf8");
|
|
1179
2040
|
child.stderr.on("data", (chunk) => {
|
|
1180
2041
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024);
|
|
@@ -1188,15 +2049,18 @@ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
|
|
|
1188
2049
|
exit,
|
|
1189
2050
|
pipeline(createReadStream(source), child.stdin)
|
|
1190
2051
|
]);
|
|
2052
|
+
if (timedOut)
|
|
2053
|
+
throw new Error(`${command} exceeded the ${STREAM_COMMAND_TIMEOUT_MS / 1000}-second archive deadline.`);
|
|
1191
2054
|
if (exitCode !== 0) {
|
|
1192
2055
|
throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
|
|
1193
2056
|
}
|
|
1194
2057
|
} catch (error) {
|
|
1195
|
-
|
|
1196
|
-
child.kill("SIGTERM");
|
|
2058
|
+
terminateChildProcess(child);
|
|
1197
2059
|
const detail = error instanceof Error ? error.message : String(error);
|
|
1198
2060
|
throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
|
|
1199
2061
|
${redact(stderr, secrets)}` : ""}`);
|
|
2062
|
+
} finally {
|
|
2063
|
+
clearTimeout(timeout);
|
|
1200
2064
|
}
|
|
1201
2065
|
}
|
|
1202
2066
|
async function gzipUncompressedBytes(path) {
|
|
@@ -1214,38 +2078,121 @@ async function volumeArchive(directory, mode, env, backupDir) {
|
|
|
1214
2078
|
await streamCommandToFile("docker", composeArgs(directory, mode, [
|
|
1215
2079
|
"run",
|
|
1216
2080
|
"--rm",
|
|
1217
|
-
"--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`,
|
|
1218
2157
|
"--entrypoint",
|
|
1219
2158
|
"sh",
|
|
1220
|
-
|
|
2159
|
+
env.OR3_IMAGE,
|
|
1221
2160
|
"-c",
|
|
1222
|
-
"tar
|
|
1223
|
-
]
|
|
1224
|
-
}
|
|
1225
|
-
async function archiveExternalVolume(image, volume, backupDir) {
|
|
1226
|
-
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));
|
|
1227
2163
|
}
|
|
1228
|
-
async function
|
|
1229
|
-
await streamFileToCommand("docker",
|
|
2164
|
+
async function validateVolumeArchive(directory, _mode, env, backupPath) {
|
|
2165
|
+
await streamFileToCommand("docker", [
|
|
1230
2166
|
"run",
|
|
1231
2167
|
"--rm",
|
|
1232
|
-
"-
|
|
1233
|
-
"--
|
|
2168
|
+
"-i",
|
|
2169
|
+
"--network",
|
|
2170
|
+
"none",
|
|
2171
|
+
"--read-only",
|
|
2172
|
+
"--security-opt",
|
|
2173
|
+
"no-new-privileges:true",
|
|
2174
|
+
"--cap-drop",
|
|
2175
|
+
"ALL",
|
|
1234
2176
|
"--entrypoint",
|
|
1235
2177
|
"sh",
|
|
1236
|
-
|
|
2178
|
+
env.OR3_IMAGE,
|
|
1237
2179
|
"-c",
|
|
1238
|
-
"
|
|
1239
|
-
]
|
|
2180
|
+
"tar tzf - >/dev/null"
|
|
2181
|
+
], join(backupPath, "data.tgz"), directory, secretValues(env));
|
|
1240
2182
|
}
|
|
1241
|
-
async function createBackup(directory, state, env) {
|
|
2183
|
+
async function createBackup(directory, state, env, options = {}) {
|
|
2184
|
+
const restartAfter = options.restartAfter ?? true;
|
|
1242
2185
|
await requireImageDigest(state.image, state.imageDigest, "Current deployment");
|
|
1243
|
-
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.`);
|
|
1244
2189
|
const backupDir = backupDirectory(directory, backupId);
|
|
2190
|
+
const initiallyRunning = options.initiallyRunning ?? await projectServiceRunning(directory, state.mode);
|
|
1245
2191
|
const volumeSize = await dataVolumeSize(directory, state.mode, env);
|
|
1246
2192
|
await assertFreeSpaceForArchive(backupDir, volumeSize, "Backup");
|
|
1247
2193
|
let manifestWritten = false;
|
|
1248
2194
|
let stopAttempted = false;
|
|
2195
|
+
let backupFailure;
|
|
1249
2196
|
try {
|
|
1250
2197
|
await mkdir(backupDir, { recursive: true, mode: 448 });
|
|
1251
2198
|
await chmod(backupDir, 448);
|
|
@@ -1265,44 +2212,53 @@ async function createBackup(directory, state, env) {
|
|
|
1265
2212
|
dataBytes: volumeSize,
|
|
1266
2213
|
configSha256: await sha256File(join(backupDir, "config.env")),
|
|
1267
2214
|
managedAssetSha256,
|
|
2215
|
+
managedAssetInventoryVersion: managedAssetSha256["compose.operator.yaml"] ? MANAGED_ASSET_INVENTORY_VERSION : managedAssetSha256["dashboard-operator.mjs"] ? 2 : undefined,
|
|
1268
2216
|
mode: state.mode,
|
|
1269
2217
|
domain: state.domain,
|
|
1270
2218
|
composeProject: state.composeProject,
|
|
1271
2219
|
volumeName: state.volumeName,
|
|
1272
2220
|
caddyDataVolume: state.caddyDataVolume,
|
|
1273
2221
|
caddyConfigVolume: state.caddyConfigVolume,
|
|
2222
|
+
deploymentId: state.deploymentId,
|
|
1274
2223
|
port: state.port
|
|
1275
2224
|
};
|
|
1276
|
-
|
|
1277
|
-
|
|
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);
|
|
1278
2230
|
manifestWritten = true;
|
|
1279
|
-
|
|
1280
|
-
await pruneBackups(directory, state, BACKUP_RETENTION_KEEP, false);
|
|
1281
|
-
} catch (error) {
|
|
1282
|
-
console.log(`Warning: automatic backup retention could not run: ${error instanceof Error ? error.message : String(error)}`);
|
|
1283
|
-
}
|
|
1284
|
-
return { backupId, backupDir, manifest };
|
|
2231
|
+
return { backupId, backupDir, manifest: verifiedManifest };
|
|
1285
2232
|
} catch (error) {
|
|
1286
2233
|
if (!manifestWritten) {
|
|
1287
2234
|
await removeNamedBackupArtifact(directory, backupId).catch(() => {
|
|
1288
2235
|
return;
|
|
1289
2236
|
});
|
|
1290
|
-
|
|
2237
|
+
backupFailure = new Error(`${error instanceof Error ? error.message : String(error)} The partial backup artifact at ${backupDir} was removed.`);
|
|
2238
|
+
throw backupFailure;
|
|
1291
2239
|
}
|
|
2240
|
+
backupFailure = error instanceof Error ? error : new Error(String(error));
|
|
1292
2241
|
throw error;
|
|
1293
2242
|
} finally {
|
|
1294
|
-
if (stopAttempted) {
|
|
2243
|
+
if (stopAttempted && restartAfter && initiallyRunning) {
|
|
1295
2244
|
try {
|
|
1296
2245
|
await startProject(directory, state.mode, env);
|
|
1297
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
|
+
}
|
|
1298
2251
|
throw new Error(`Backup was created but OR3 could not restart: ${error instanceof Error ? error.message : String(error)}`);
|
|
1299
2252
|
}
|
|
1300
2253
|
}
|
|
1301
2254
|
}
|
|
1302
2255
|
}
|
|
1303
|
-
async function readManifest(backupPath) {
|
|
1304
|
-
const
|
|
1305
|
-
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)) {
|
|
1306
2262
|
throw new Error(`Invalid backup manifest at ${backupPath}.`);
|
|
1307
2263
|
}
|
|
1308
2264
|
const actual = await sha256File(join(backupPath, "data.tgz"));
|
|
@@ -1316,6 +2272,18 @@ async function readManifest(backupPath) {
|
|
|
1316
2272
|
await verifiedManagedAssetContents(backupPath, manifest);
|
|
1317
2273
|
return manifest;
|
|
1318
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
|
+
}
|
|
1319
2287
|
async function dataVolumeFreeBytes(directory, mode, env) {
|
|
1320
2288
|
const parse = (stdout) => {
|
|
1321
2289
|
const blocks = Number(stdout.trim());
|
|
@@ -1327,7 +2295,22 @@ async function dataVolumeFreeBytes(directory, mode, env) {
|
|
|
1327
2295
|
const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", script])], directory);
|
|
1328
2296
|
if (exec.ok)
|
|
1329
2297
|
return parse(exec.stdout);
|
|
1330
|
-
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);
|
|
1331
2314
|
if (fallback.ok)
|
|
1332
2315
|
return parse(fallback.stdout);
|
|
1333
2316
|
throw new Error(`Could not measure free space in the Docker data volume. ${redact(`${exec.stderr} ${fallback.stderr}`, secretValues(env))}`);
|
|
@@ -1336,10 +2319,12 @@ async function assertRestoreFreeSpace(directory, state, env, manifest, backupPat
|
|
|
1336
2319
|
const archiveBytes = await gzipUncompressedBytes(join(backupPath, "data.tgz"));
|
|
1337
2320
|
const requiredBytes = Math.max(manifest.dataBytes ?? 0, archiveBytes);
|
|
1338
2321
|
const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
|
|
1339
|
-
|
|
2322
|
+
const reclaimableBytes = await dataVolumeSize(directory, state.mode, env);
|
|
2323
|
+
assertEnoughFreeSpace(freeBytes + reclaimableBytes, requiredBytes, "Restore");
|
|
1340
2324
|
}
|
|
1341
|
-
async function restoreBackupData(directory, state, env, backupPath) {
|
|
1342
|
-
const manifest = await readManifest(backupPath);
|
|
2325
|
+
async function restoreBackupData(directory, state, env, backupPath, options = {}) {
|
|
2326
|
+
const manifest = await readManifest(backupPath, directory);
|
|
2327
|
+
assertRestorableManagedAssets(manifest);
|
|
1343
2328
|
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
1344
2329
|
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1345
2330
|
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
@@ -1347,30 +2332,28 @@ async function restoreBackupData(directory, state, env, backupPath) {
|
|
|
1347
2332
|
assertBackupMatchesDeployment(manifest, backupEnv, state, env);
|
|
1348
2333
|
if (manifest.imageDigest)
|
|
1349
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);
|
|
1350
2337
|
await assertRestoreFreeSpace(directory, state, env, manifest, backupPath);
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
|
1355
2349
|
await stopProject(directory, state.mode);
|
|
1356
|
-
|
|
1357
|
-
await
|
|
1358
|
-
await restoreVolumeArchive(directory, state.mode, backupEnv, backupPath);
|
|
1359
|
-
const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
1360
|
-
await startProject(directory, state.mode, restoredEnv);
|
|
1361
|
-
started = true;
|
|
1362
|
-
} catch (error) {
|
|
1363
|
-
let restartError = "";
|
|
1364
|
-
if (stopAttempted && !started) {
|
|
1365
|
-
try {
|
|
1366
|
-
const recoveryEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
1367
|
-
await startProject(directory, state.mode, recoveryEnv);
|
|
1368
|
-
} catch (recovery) {
|
|
1369
|
-
restartError = ` OR3 could not be restarted: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
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);
|
|
1373
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);
|
|
1374
2357
|
return manifest;
|
|
1375
2358
|
}
|
|
1376
2359
|
function stateFromEnv(directory, env, mode, operation, digest) {
|
|
@@ -1381,6 +2364,8 @@ function stateFromEnv(directory, env, mode, operation, digest) {
|
|
|
1381
2364
|
volumeName: env.OR3_VOLUME_NAME,
|
|
1382
2365
|
caddyDataVolume: mode === "public" ? env.OR3_CADDY_DATA_VOLUME : undefined,
|
|
1383
2366
|
caddyConfigVolume: mode === "public" ? env.OR3_CADDY_CONFIG_VOLUME : undefined,
|
|
2367
|
+
deploymentId: env.OR3_DEPLOYMENT_ID,
|
|
2368
|
+
deploymentRoot: resolve(directory),
|
|
1384
2369
|
appVersion: env.OR3_VERSION,
|
|
1385
2370
|
image: env.OR3_IMAGE,
|
|
1386
2371
|
imageDigest: digest,
|
|
@@ -1400,8 +2385,9 @@ Usage:
|
|
|
1400
2385
|
npx @or3/cloud backup [list|prune [--keep <n>]|export <backup-id> <destination-dir>]
|
|
1401
2386
|
npx @or3/cloud restore <backup-id-or-path> --yes
|
|
1402
2387
|
npx @or3/cloud rollback --yes
|
|
1403
|
-
npx @or3/cloud credentials reset --yes [--owner-password <
|
|
2388
|
+
npx @or3/cloud credentials reset --yes [--owner-password-file <path> --admin-password-file <path>]
|
|
1404
2389
|
npx @or3/cloud doctor
|
|
2390
|
+
npx @or3/cloud verify [--public] [--verification-email <email> --verification-password-file <path>]
|
|
1405
2391
|
npx @or3/cloud recover
|
|
1406
2392
|
npx @or3/cloud adopt --from <v1-directory> [directory]
|
|
1407
2393
|
npx @or3/cloud status
|
|
@@ -1412,12 +2398,16 @@ Usage:
|
|
|
1412
2398
|
Options:
|
|
1413
2399
|
--admin-email <email> Administrator email for first login
|
|
1414
2400
|
--admin-password <password> Explicit password (prefer --admin-password-file); for credentials reset, the new admin password
|
|
1415
|
-
--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
|
|
1416
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
|
|
1417
2406
|
--port <port> Local OR3 port (default: 3000)
|
|
1418
2407
|
--keep <n> Backups to retain when pruning (default: 5)
|
|
1419
2408
|
--force Prune backups even when referenced by the rollback point
|
|
1420
2409
|
--tail <n> Log lines to show (default: 200)
|
|
2410
|
+
--public Require verification through the public HTTPS origin
|
|
1421
2411
|
--purge-data Remove data volumes and managed files (with remove)
|
|
1422
2412
|
--yes Confirm a destructive restore, rollback, credentials reset, or purge
|
|
1423
2413
|
--help Show this help
|
|
@@ -1447,9 +2437,10 @@ async function initCommand(positionals, flags) {
|
|
|
1447
2437
|
throw new Error("--port must be a valid TCP port.");
|
|
1448
2438
|
if (mode === "public" && [80, 443].includes(port))
|
|
1449
2439
|
throw new Error("Public mode reserves ports 80 and 443 for Caddy; choose another --port for OR3.");
|
|
1450
|
-
|
|
2440
|
+
const localDockerDaemon = await dockerDaemonIsLocal();
|
|
2441
|
+
if (localDockerDaemon && !await portAvailable(port))
|
|
1451
2442
|
throw new Error(`Port ${port} is already in use. Choose another port with --port.`);
|
|
1452
|
-
if (mode === "public") {
|
|
2443
|
+
if (mode === "public" && localDockerDaemon) {
|
|
1453
2444
|
for (const publicPort of [80, 443]) {
|
|
1454
2445
|
if (!await portAvailable(publicPort))
|
|
1455
2446
|
throw new Error(`Public port ${publicPort} is already in use. Stop the conflicting service before starting Caddy.`);
|
|
@@ -1457,23 +2448,31 @@ async function initCommand(positionals, flags) {
|
|
|
1457
2448
|
}
|
|
1458
2449
|
await ensureDocker();
|
|
1459
2450
|
const names = composeProjectNames(directory);
|
|
2451
|
+
await assertDockerProjectAbsent(names.project);
|
|
1460
2452
|
for (const volume of [names.volume, ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
|
|
1461
2453
|
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
1462
2454
|
if (existing.ok)
|
|
1463
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
|
+
}
|
|
1464
2460
|
}
|
|
1465
2461
|
const email = await resolveAdminEmail(flags);
|
|
1466
2462
|
const password = await readPassword(flags);
|
|
1467
2463
|
const version = PACKAGE_VERSION;
|
|
1468
|
-
const
|
|
1469
|
-
await pullImage(
|
|
1470
|
-
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);
|
|
1471
2469
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
1472
2470
|
await chmod(directory, 448);
|
|
1473
2471
|
await mkdir(deploymentPaths(directory).operations, { recursive: true, mode: 448 });
|
|
1474
2472
|
await mkdir(deploymentPaths(directory).backups, { recursive: true, mode: 448 });
|
|
2473
|
+
const operator = await prepareVerifiedDashboardOperator(directory, version);
|
|
1475
2474
|
await copyAssets(directory, mode);
|
|
1476
|
-
const env = buildEnv({ mode, version, directory, email, password, domain, port });
|
|
2475
|
+
const env = buildEnv({ mode, version, directory, image, email, password, domain, port, dashboardOperator: operator });
|
|
1477
2476
|
await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
|
|
1478
2477
|
await writeSecure(join(directory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
1479
2478
|
bootstrapEmail: email,
|
|
@@ -1481,7 +2480,6 @@ async function initCommand(positionals, flags) {
|
|
|
1481
2480
|
adminUsername: email,
|
|
1482
2481
|
adminPassword: password
|
|
1483
2482
|
}));
|
|
1484
|
-
const digest = await imageDigest(image);
|
|
1485
2483
|
const state = stateFromEnv(directory, env, mode, "init", digest);
|
|
1486
2484
|
await markPending(directory, state, {
|
|
1487
2485
|
id: id("init"),
|
|
@@ -1491,12 +2489,19 @@ async function initCommand(positionals, flags) {
|
|
|
1491
2489
|
});
|
|
1492
2490
|
try {
|
|
1493
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);
|
|
1494
2497
|
await clearPending(directory, state);
|
|
1495
2498
|
console.log(`
|
|
1496
2499
|
OR3 Cloud ${version} is running at ${mode === "public" ? `https://${domain}` : `http://127.0.0.1:${port}`}`);
|
|
1497
2500
|
console.log(`Credentials were written to ${join(directory, ".or3-initial-credentials")} (mode 0600). Save them, then remove that file.`);
|
|
1498
2501
|
console.log(`
|
|
1499
2502
|
Check: cd ${quote(directory)} && npx @or3/cloud doctor`);
|
|
2503
|
+
if (operator)
|
|
2504
|
+
console.log("Dashboard updates are enabled for super admins in Operations.");
|
|
1500
2505
|
} catch (error) {
|
|
1501
2506
|
state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(env));
|
|
1502
2507
|
await writeState(directory, state);
|
|
@@ -1512,6 +2517,7 @@ async function loadManaged(directory = process.cwd()) {
|
|
|
1512
2517
|
const state = await readState(resolved);
|
|
1513
2518
|
const env = parseEnv(await readText(deploymentPaths(resolved).env));
|
|
1514
2519
|
assertDeploymentIdentity(state, env);
|
|
2520
|
+
assertDeploymentDirectoryIdentity(resolved, state);
|
|
1515
2521
|
return { directory: resolved, state, env };
|
|
1516
2522
|
}
|
|
1517
2523
|
function operationToStateOperation(operation, fallback) {
|
|
@@ -1523,6 +2529,14 @@ async function commitRecoveredState(directory, previous, next) {
|
|
|
1523
2529
|
await removeOperationRecord(directory, previous.incompleteOperation?.id);
|
|
1524
2530
|
await writeState(directory, next);
|
|
1525
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
|
+
}
|
|
1526
2540
|
async function recoverCommand(directory) {
|
|
1527
2541
|
await ensureDocker();
|
|
1528
2542
|
const loaded = await loadManaged(directory);
|
|
@@ -1532,6 +2546,14 @@ async function recoverCommand(directory) {
|
|
|
1532
2546
|
return;
|
|
1533
2547
|
}
|
|
1534
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
|
+
}
|
|
1535
2557
|
if (pending.operation === "credentials-reset") {
|
|
1536
2558
|
const nextEnv = pending.credentialReset?.nextEnv;
|
|
1537
2559
|
if (!nextEnv)
|
|
@@ -1543,95 +2565,69 @@ async function recoverCommand(directory) {
|
|
|
1543
2565
|
return;
|
|
1544
2566
|
}
|
|
1545
2567
|
if (pending.operation === "restore" || pending.operation === "rollback") {
|
|
1546
|
-
if (
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
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;
|
|
1553
2574
|
}
|
|
1554
|
-
if (pending.
|
|
1555
|
-
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.`);
|
|
1556
2577
|
}
|
|
1557
|
-
|
|
1558
|
-
await
|
|
1559
|
-
|
|
1560
|
-
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1561
|
-
const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
|
|
1562
|
-
restored.lastError = undefined;
|
|
1563
|
-
await commitRecoveredState(loaded.directory, loaded.state, restored);
|
|
1564
|
-
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.`);
|
|
1565
2581
|
return;
|
|
1566
2582
|
}
|
|
1567
2583
|
if (pending.operation === "update") {
|
|
1568
|
-
|
|
1569
|
-
if (oldDeployment) {
|
|
2584
|
+
if (pending.phase === "prepared" || pending.phase === "snapshot-created") {
|
|
1570
2585
|
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
1571
2586
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1572
|
-
const
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
await commitRecoveredState(loaded.directory, loaded.state,
|
|
1576
|
-
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.`);
|
|
1577
2592
|
return;
|
|
1578
2593
|
}
|
|
1579
|
-
if (!pending.
|
|
1580
|
-
throw new Error("The incomplete update has
|
|
1581
|
-
}
|
|
1582
|
-
const backupPath = await resolveBackup(loaded.directory, pending.backupId);
|
|
1583
|
-
const manifest = await readManifest(backupPath);
|
|
1584
|
-
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
1585
|
-
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1586
|
-
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1587
|
-
}
|
|
1588
|
-
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1589
|
-
if (pending.targetVersion !== PACKAGE_VERSION) {
|
|
1590
|
-
throw new Error(`This interrupted update targets OR3 ${pending.targetVersion}. Run \`npx --yes @or3/cloud@${pending.targetVersion} recover\` so the image and generated assets match.`);
|
|
1591
|
-
}
|
|
1592
|
-
try {
|
|
1593
|
-
if (pending.targetImageDigest)
|
|
1594
|
-
await pullAndRequireImage(pending.targetImage, pending.targetImageDigest, `OR3 ${pending.targetVersion}`);
|
|
1595
|
-
await copyAssets(loaded.directory, loaded.state.mode);
|
|
1596
|
-
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1597
|
-
} catch (replacementError) {
|
|
1598
|
-
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
|
|
1599
|
-
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1600
|
-
const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(restoredEnv.OR3_IMAGE));
|
|
1601
|
-
restored.rollback = loaded.state.rollback;
|
|
1602
|
-
restored.lastError = `Interrupted update was restored: ${redact(replacementError instanceof Error ? replacementError.message : String(replacementError), secretValues(restoredEnv))}`;
|
|
1603
|
-
await commitRecoveredState(loaded.directory, loaded.state, restored);
|
|
1604
|
-
console.log(`Recovered the incomplete update by restoring backup ${manifest.backupId}. OR3 ${restored.appVersion} is deeply healthy.`);
|
|
1605
|
-
return;
|
|
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.");
|
|
1606
2596
|
}
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
image: loaded.state.image,
|
|
1611
|
-
imageDigest: loaded.state.imageDigest,
|
|
1612
|
-
backupId: pending.backupId,
|
|
1613
|
-
createdAt: now()
|
|
1614
|
-
};
|
|
1615
|
-
updated.lastError = undefined;
|
|
1616
|
-
await commitRecoveredState(loaded.directory, loaded.state, updated);
|
|
1617
|
-
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.`);
|
|
1618
2600
|
return;
|
|
1619
2601
|
}
|
|
1620
2602
|
if (pending.operation === "adopt") {
|
|
1621
2603
|
if (!pending.backupId)
|
|
1622
2604
|
throw new Error("The incomplete adoption has no source backup ID. Refusing to claim that data was transferred.");
|
|
1623
2605
|
const sourceBackupPath = await resolveBackup(loaded.directory, pending.backupId);
|
|
1624
|
-
const sourceManifest = await readManifest(sourceBackupPath);
|
|
2606
|
+
const sourceManifest = await readManifest(sourceBackupPath, loaded.directory);
|
|
1625
2607
|
if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
|
|
1626
2608
|
throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
|
|
1627
2609
|
}
|
|
1628
2610
|
await stopProject(loaded.directory, loaded.state.mode);
|
|
1629
2611
|
await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
|
|
1630
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
|
+
}
|
|
1631
2619
|
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
1632
2620
|
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1633
|
-
|
|
1634
|
-
|
|
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);
|
|
1635
2631
|
recovered.rollback = loaded.state.rollback;
|
|
1636
2632
|
recovered.lastError = undefined;
|
|
1637
2633
|
await commitRecoveredState(loaded.directory, loaded.state, recovered);
|
|
@@ -1649,7 +2645,9 @@ async function recoverCommand(directory) {
|
|
|
1649
2645
|
const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
|
|
1650
2646
|
if (sourceModeValue === "public")
|
|
1651
2647
|
sourceFiles.push("-f", join(pending.sourceDirectory, "compose.public.yaml"));
|
|
1652
|
-
|
|
2648
|
+
if (pending.sourceInitiallyRunning !== false) {
|
|
2649
|
+
await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
|
|
2650
|
+
}
|
|
1653
2651
|
} catch (recovery) {
|
|
1654
2652
|
loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
1655
2653
|
}
|
|
@@ -1667,16 +2665,23 @@ async function backupCreateCommand(directory) {
|
|
|
1667
2665
|
await ensureDocker();
|
|
1668
2666
|
const loaded = await loadManaged(directory);
|
|
1669
2667
|
assertNoPending(loaded.state);
|
|
2668
|
+
const backupId = id("backup");
|
|
2669
|
+
const initialAppRunning = await projectServiceRunning(loaded.directory, loaded.state.mode);
|
|
1670
2670
|
const pending = {
|
|
1671
|
-
id: id("backup"),
|
|
2671
|
+
id: id("backup-operation"),
|
|
1672
2672
|
operation: "backup",
|
|
1673
2673
|
startedAt: now(),
|
|
1674
|
-
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"
|
|
1675
2679
|
};
|
|
1676
2680
|
await markPending(loaded.directory, loaded.state, pending);
|
|
1677
2681
|
try {
|
|
1678
|
-
const result = await createBackup(loaded.directory, loaded.state, loaded.env);
|
|
2682
|
+
const result = await createBackup(loaded.directory, loaded.state, loaded.env, { backupId, initiallyRunning: initialAppRunning });
|
|
1679
2683
|
await clearPending(loaded.directory, loaded.state);
|
|
2684
|
+
await pruneBackups(loaded.directory, loaded.state, BACKUP_RETENTION_KEEP, false);
|
|
1680
2685
|
console.log(`Backup ${result.backupId} created at ${result.backupDir}`);
|
|
1681
2686
|
console.log(`SHA-256: ${result.manifest.dataSha256}`);
|
|
1682
2687
|
} catch (error) {
|
|
@@ -1712,23 +2717,31 @@ async function backupPruneCommand(directory, flags) {
|
|
|
1712
2717
|
}
|
|
1713
2718
|
async function backupExportCommand(directory, backupId, destination) {
|
|
1714
2719
|
const backupPath = await resolveBackup(directory, backupId);
|
|
1715
|
-
const manifest = await readManifest(backupPath);
|
|
2720
|
+
const manifest = await readManifest(backupPath, directory);
|
|
1716
2721
|
const dest = resolve(destination);
|
|
1717
2722
|
const source = resolve(backupPath);
|
|
2723
|
+
const deploymentRoot = await realpath(directory);
|
|
1718
2724
|
if (source === dest || dest.startsWith(`${source}${sep}`)) {
|
|
1719
2725
|
throw new Error("Choose a destination directory different from the backup itself.");
|
|
1720
2726
|
}
|
|
1721
2727
|
if (await fileExists(dest))
|
|
1722
2728
|
throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
|
|
1723
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
|
+
}
|
|
1724
2734
|
await mkdir(dest, { mode: 448 });
|
|
1725
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
|
+
}
|
|
1726
2739
|
await chmod(dest, 448);
|
|
1727
|
-
for (const file of ["data.tgz", "config.env", "manifest.json"]) {
|
|
2740
|
+
for (const file of ["data.tgz", "config.env", "manifest.json", "manifest.auth"]) {
|
|
1728
2741
|
await copySecure(join(backupPath, file), join(dest, file));
|
|
1729
2742
|
}
|
|
1730
2743
|
if (manifest.managedAssetSha256) {
|
|
1731
|
-
for (const name of
|
|
2744
|
+
for (const name of managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion)) {
|
|
1732
2745
|
await copySecure(join(backupPath, "managed-assets", name), join(dest, "managed-assets", name));
|
|
1733
2746
|
}
|
|
1734
2747
|
}
|
|
@@ -1742,7 +2755,7 @@ async function backupExportCommand(directory, backupId, destination) {
|
|
|
1742
2755
|
throw new Error(`Exported config.env checksum mismatch for ${manifest.backupId}.`);
|
|
1743
2756
|
}
|
|
1744
2757
|
}
|
|
1745
|
-
await readManifest(dest);
|
|
2758
|
+
await readManifest(dest, directory);
|
|
1746
2759
|
const destinationDevice = (await stat(dest)).dev;
|
|
1747
2760
|
await mkdir(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
|
|
1748
2761
|
const receipt = {
|
|
@@ -1756,9 +2769,9 @@ async function backupExportCommand(directory, backupId, destination) {
|
|
|
1756
2769
|
};
|
|
1757
2770
|
await writeSecure(join(deploymentPaths(directory).exports, `${manifest.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
|
|
1758
2771
|
`);
|
|
1759
|
-
let bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size;
|
|
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;
|
|
1760
2773
|
if (manifest.managedAssetSha256) {
|
|
1761
|
-
for (const name of
|
|
2774
|
+
for (const name of managedAssetNamesForInventory(manifest.mode, manifest.managedAssetInventoryVersion)) {
|
|
1762
2775
|
bytes += (await stat(join(dest, "managed-assets", name))).size;
|
|
1763
2776
|
}
|
|
1764
2777
|
}
|
|
@@ -1813,10 +2826,14 @@ async function updateCommand(directory, flags) {
|
|
|
1813
2826
|
}
|
|
1814
2827
|
if (targetVersion === state.appVersion)
|
|
1815
2828
|
throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
|
|
1816
|
-
const
|
|
1817
|
-
const targetDigest = await pullImage(
|
|
1818
|
-
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);
|
|
1819
2834
|
const oldEnv = { ...env };
|
|
2835
|
+
const targetDeploymentId = env.OR3_DEPLOYMENT_ID ?? state.deploymentId ?? id("deployment");
|
|
2836
|
+
const backupId = id("backup");
|
|
1820
2837
|
const pending = {
|
|
1821
2838
|
id: id("update"),
|
|
1822
2839
|
operation: "update",
|
|
@@ -1824,18 +2841,48 @@ async function updateCommand(directory, flags) {
|
|
|
1824
2841
|
message: `Updating from ${state.appVersion} to ${targetVersion}`,
|
|
1825
2842
|
targetVersion,
|
|
1826
2843
|
targetImage,
|
|
1827
|
-
targetImageDigest: targetDigest
|
|
2844
|
+
targetImageDigest: targetDigest,
|
|
2845
|
+
targetDeploymentId,
|
|
2846
|
+
backupId,
|
|
2847
|
+
backupPath: backupDirectory(loaded.directory, backupId),
|
|
2848
|
+
phase: "prepared"
|
|
1828
2849
|
};
|
|
1829
2850
|
await markPending(loaded.directory, state, pending);
|
|
1830
2851
|
try {
|
|
1831
|
-
const backup = await createBackup(loaded.directory, state, env);
|
|
1832
|
-
await updatePending(loaded.directory, state, {
|
|
1833
|
-
|
|
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
|
+
};
|
|
1834
2871
|
const previousRootOwnership = await managedVolumeRootOwnership(targetImage, state.volumeName);
|
|
1835
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
|
+
});
|
|
1836
2879
|
try {
|
|
1837
|
-
|
|
1838
|
-
|
|
2880
|
+
if (recreateDataVolume) {
|
|
2881
|
+
await removeManagedDataVolumeForRecreation(loaded.directory, state);
|
|
2882
|
+
} else {
|
|
2883
|
+
await stopProject(loaded.directory, state.mode);
|
|
2884
|
+
}
|
|
2885
|
+
if (migrateLegacyVolume && !recreateDataVolume) {
|
|
1839
2886
|
await setManagedVolumeRootOwnership(targetImage, state.volumeName, {
|
|
1840
2887
|
uid: MANAGED_RUNTIME_UID,
|
|
1841
2888
|
gid: MANAGED_RUNTIME_GID
|
|
@@ -1843,31 +2890,26 @@ async function updateCommand(directory, flags) {
|
|
|
1843
2890
|
}
|
|
1844
2891
|
await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
|
|
1845
2892
|
await copyAssets(loaded.directory, state.mode);
|
|
1846
|
-
if (migrateLegacyVolume) {
|
|
2893
|
+
if (migrateLegacyVolume || recreateDataVolume) {
|
|
1847
2894
|
await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
|
|
1848
2895
|
}
|
|
1849
2896
|
await startProject(loaded.directory, state.mode, nextEnv);
|
|
1850
2897
|
} catch (error) {
|
|
1851
|
-
await
|
|
2898
|
+
await updatePending(loaded.directory, state, { phase: "restoring-previous" });
|
|
1852
2899
|
try {
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
2900
|
+
if (!recreateDataVolume)
|
|
2901
|
+
await stopProject(loaded.directory, state.mode).catch(() => {
|
|
2902
|
+
return;
|
|
2903
|
+
});
|
|
2904
|
+
if (migrateLegacyVolume && !recreateDataVolume) {
|
|
1857
2905
|
await setManagedVolumeRootOwnership(targetImage, state.volumeName, previousRootOwnership);
|
|
1858
2906
|
}
|
|
1859
|
-
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
|
|
2907
|
+
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir, { recreateDataVolume });
|
|
1860
2908
|
} catch (restoreError) {
|
|
1861
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)}`);
|
|
1862
2910
|
}
|
|
1863
|
-
const
|
|
1864
|
-
|
|
1865
|
-
state.lastError = `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`;
|
|
1866
|
-
const operationId = pending.id;
|
|
1867
|
-
delete state.incompleteOperation;
|
|
1868
|
-
await removeOperationRecord(loaded.directory, operationId);
|
|
1869
|
-
await writeState(loaded.directory, state);
|
|
1870
|
-
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);
|
|
1871
2913
|
}
|
|
1872
2914
|
const digest = await requireImageDigest(targetImage, targetDigest, `OR3 ${targetVersion}`);
|
|
1873
2915
|
state.rollback = {
|
|
@@ -1880,9 +2922,12 @@ async function updateCommand(directory, flags) {
|
|
|
1880
2922
|
state.appVersion = targetVersion;
|
|
1881
2923
|
state.image = targetImage;
|
|
1882
2924
|
state.imageDigest = digest;
|
|
2925
|
+
state.deploymentId = targetDeploymentId;
|
|
2926
|
+
state.deploymentRoot = resolve(loaded.directory);
|
|
1883
2927
|
state.lastSuccessfulOperation = "update";
|
|
1884
2928
|
state.lastError = undefined;
|
|
1885
2929
|
await clearPending(loaded.directory, state);
|
|
2930
|
+
await pruneBackups(loaded.directory, state, BACKUP_RETENTION_KEEP, false);
|
|
1886
2931
|
console.log(`OR3 updated to ${targetVersion}. Image digest: ${digest}`);
|
|
1887
2932
|
console.log(`Rollback point: ${backup.backupId}. Keep it until login, chat, and file checks pass.`);
|
|
1888
2933
|
} catch (error) {
|
|
@@ -1894,11 +2939,52 @@ async function updateCommand(directory, flags) {
|
|
|
1894
2939
|
}
|
|
1895
2940
|
}
|
|
1896
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
|
+
}
|
|
1897
2945
|
const candidate = isAbsolute(value) ? value : backupDirectory(directory, value);
|
|
1898
2946
|
if (!await fileExists(join(candidate, "manifest.json")))
|
|
1899
2947
|
throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
|
|
1900
2948
|
return resolve(candidate);
|
|
1901
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
|
+
}
|
|
1902
2988
|
async function restoreCommand(directory, flags, positionals) {
|
|
1903
2989
|
if (!boolFlag(flags, "yes"))
|
|
1904
2990
|
throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
|
|
@@ -1909,12 +2995,14 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
1909
2995
|
if (!backupValue)
|
|
1910
2996
|
throw new Error("restore requires a backup ID or path.");
|
|
1911
2997
|
const backupPath = await resolveBackup(loaded.directory, backupValue);
|
|
1912
|
-
const manifest = await readManifest(backupPath);
|
|
2998
|
+
const manifest = await readManifest(backupPath, loaded.directory);
|
|
2999
|
+
assertRestorableManagedAssets(manifest);
|
|
1913
3000
|
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
1914
3001
|
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1915
3002
|
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1916
3003
|
}
|
|
1917
3004
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
3005
|
+
const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
|
|
1918
3006
|
if (manifest.imageDigest)
|
|
1919
3007
|
await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
1920
3008
|
await assertSupportedHostArchitecture(manifest.image);
|
|
@@ -1925,13 +3013,30 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
1925
3013
|
startedAt: now(),
|
|
1926
3014
|
message: `Restoring backup ${manifest.backupId}`,
|
|
1927
3015
|
backupId: manifest.backupId,
|
|
3016
|
+
backupPath,
|
|
3017
|
+
backupDataSha256: manifest.dataSha256,
|
|
3018
|
+
backupConfigSha256: manifest.configSha256,
|
|
1928
3019
|
targetVersion: manifest.appVersion,
|
|
1929
3020
|
targetImage: manifest.image,
|
|
1930
|
-
targetImageDigest: manifest.imageDigest
|
|
3021
|
+
targetImageDigest: manifest.imageDigest,
|
|
3022
|
+
recreateDataVolume,
|
|
3023
|
+
phase: "prepared"
|
|
1931
3024
|
};
|
|
1932
3025
|
await markPending(loaded.directory, loaded.state, pending);
|
|
1933
3026
|
try {
|
|
1934
|
-
|
|
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 });
|
|
1935
3040
|
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1936
3041
|
const digest = await imageDigest(restoredEnv.OR3_IMAGE);
|
|
1937
3042
|
const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", digest);
|
|
@@ -1940,8 +3045,33 @@ async function restoreCommand(directory, flags, positionals) {
|
|
|
1940
3045
|
await writeState(loaded.directory, nextState);
|
|
1941
3046
|
console.log(`Restored ${manifest.backupId}. Verify sign-in, a conversation, and a previously uploaded file.`);
|
|
1942
3047
|
} catch (error) {
|
|
1943
|
-
|
|
1944
|
-
|
|
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
|
+
}
|
|
1945
3075
|
throw error;
|
|
1946
3076
|
}
|
|
1947
3077
|
}
|
|
@@ -1955,7 +3085,8 @@ async function rollbackCommand(directory, flags) {
|
|
|
1955
3085
|
if (!point)
|
|
1956
3086
|
throw new Error("No immediate rollback point is recorded for this deployment.");
|
|
1957
3087
|
const backupPath = await resolveBackup(loaded.directory, point.backupId);
|
|
1958
|
-
const manifest = await readManifest(backupPath);
|
|
3088
|
+
const manifest = await readManifest(backupPath, loaded.directory);
|
|
3089
|
+
assertRestorableManagedAssets(manifest);
|
|
1959
3090
|
if (manifest.appVersion !== point.appVersion || manifest.image !== point.image || manifest.imageDigest !== point.imageDigest) {
|
|
1960
3091
|
throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
|
|
1961
3092
|
}
|
|
@@ -1964,6 +3095,7 @@ async function rollbackCommand(directory, flags) {
|
|
|
1964
3095
|
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1965
3096
|
}
|
|
1966
3097
|
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
3098
|
+
const recreateDataVolume = restoreRequiresVolumeRecreation(loaded.env, backupEnv);
|
|
1967
3099
|
await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
|
|
1968
3100
|
await assertSupportedHostArchitecture(point.image);
|
|
1969
3101
|
await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
|
|
@@ -1973,13 +3105,30 @@ async function rollbackCommand(directory, flags) {
|
|
|
1973
3105
|
startedAt: now(),
|
|
1974
3106
|
message: `Rolling back to ${point.appVersion}`,
|
|
1975
3107
|
backupId: point.backupId,
|
|
3108
|
+
backupPath,
|
|
3109
|
+
backupDataSha256: manifest.dataSha256,
|
|
3110
|
+
backupConfigSha256: manifest.configSha256,
|
|
1976
3111
|
targetVersion: point.appVersion,
|
|
1977
3112
|
targetImage: point.image,
|
|
1978
|
-
targetImageDigest: point.imageDigest
|
|
3113
|
+
targetImageDigest: point.imageDigest,
|
|
3114
|
+
recreateDataVolume,
|
|
3115
|
+
phase: "prepared"
|
|
1979
3116
|
};
|
|
1980
3117
|
await markPending(loaded.directory, loaded.state, pending);
|
|
1981
3118
|
try {
|
|
1982
|
-
|
|
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 });
|
|
1983
3132
|
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1984
3133
|
const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
|
|
1985
3134
|
nextState.rollback = undefined;
|
|
@@ -1987,8 +3136,33 @@ async function rollbackCommand(directory, flags) {
|
|
|
1987
3136
|
await writeState(loaded.directory, nextState);
|
|
1988
3137
|
console.log(`Rolled back to OR3 ${nextState.appVersion}. Verify sign-in, chat, and file access.`);
|
|
1989
3138
|
} catch (error) {
|
|
1990
|
-
|
|
1991
|
-
|
|
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
|
+
}
|
|
1992
3166
|
throw error;
|
|
1993
3167
|
}
|
|
1994
3168
|
}
|
|
@@ -2016,7 +3190,7 @@ async function doctorCommand(directory) {
|
|
|
2016
3190
|
failures.push(error instanceof Error ? error.message : String(error));
|
|
2017
3191
|
console.log("✗ Managed state or .env is missing/invalid");
|
|
2018
3192
|
}
|
|
2019
|
-
for (const file of [paths.env, paths.state]) {
|
|
3193
|
+
for (const file of [paths.env, paths.state, join(resolved, ".or3-initial-credentials")]) {
|
|
2020
3194
|
if (!existsSync(file))
|
|
2021
3195
|
continue;
|
|
2022
3196
|
const mode = (await stat(file)).mode & 511;
|
|
@@ -2057,6 +3231,53 @@ async function doctorCommand(directory) {
|
|
|
2057
3231
|
failures.push(error instanceof Error ? error.message : String(error));
|
|
2058
3232
|
console.log("✗ Deep health is not passing");
|
|
2059
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
|
+
}
|
|
2060
3281
|
}
|
|
2061
3282
|
if (state.mode === "public" && state.domain) {
|
|
2062
3283
|
try {
|
|
@@ -2076,7 +3297,22 @@ async function doctorCommand(directory) {
|
|
|
2076
3297
|
console.log(`✗ DNS: ${state.domain} does not resolve`);
|
|
2077
3298
|
}
|
|
2078
3299
|
for (const port of [80, 443]) {
|
|
2079
|
-
|
|
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");
|
|
2080
3316
|
}
|
|
2081
3317
|
console.log("ℹ Firewall: allow TCP 80/443 (and optional UDP 443) in your existing nftables or UFW rules; OR3 did not modify them.");
|
|
2082
3318
|
}
|
|
@@ -2086,6 +3322,358 @@ async function doctorCommand(directory) {
|
|
|
2086
3322
|
}
|
|
2087
3323
|
console.log("OR3 Cloud doctor passed.");
|
|
2088
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
|
+
}
|
|
2089
3677
|
async function readSourceVolume(sourceDirectory) {
|
|
2090
3678
|
const composeResult = await run("docker", [
|
|
2091
3679
|
"compose",
|
|
@@ -2141,6 +3729,9 @@ function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
|
|
|
2141
3729
|
if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
|
|
2142
3730
|
throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
|
|
2143
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
|
+
}
|
|
2144
3735
|
if (sourceEnv.OR3_SYNC_PROVIDER !== "sqlite" || sourceEnv.OR3_STORAGE_FS_ROOT === undefined || sourceEnv.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs") {
|
|
2145
3736
|
throw new Error("V1 project is not the supported Basic Auth + SQLite + filesystem profile.");
|
|
2146
3737
|
}
|
|
@@ -2201,6 +3792,9 @@ async function adoptCommand(positionals, flags) {
|
|
|
2201
3792
|
const release = JSON.parse(await readText(join(sourceDirectory, "or3-release.json")));
|
|
2202
3793
|
if (!release.or3Version || !isVersion(release.or3Version))
|
|
2203
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
|
+
}
|
|
2204
3798
|
const sourceEnv = parseEnv(await readText(join(sourceDirectory, ".env")));
|
|
2205
3799
|
const providers = await readText(join(sourceDirectory, "or3.providers.generated.ts"));
|
|
2206
3800
|
assertSupportedSource(sourceDirectory, sourceEnv, providers);
|
|
@@ -2225,15 +3819,17 @@ async function adoptCommand(positionals, flags) {
|
|
|
2225
3819
|
], sourceDirectory);
|
|
2226
3820
|
if (!sourceConfig.ok)
|
|
2227
3821
|
throw new Error(`The V1 Compose configuration is invalid. ${redact(sourceConfig.stderr, secretValues(sourceEnv))}`);
|
|
2228
|
-
const
|
|
3822
|
+
const sourceImage = imageFor(release.or3Version);
|
|
2229
3823
|
if (sourceEnv.OR3_VERSION && sourceEnv.OR3_VERSION !== release.or3Version) {
|
|
2230
3824
|
throw new Error(`V1 OR3_VERSION ${sourceEnv.OR3_VERSION} does not match or3-release.json ${release.or3Version}.`);
|
|
2231
3825
|
}
|
|
2232
|
-
|
|
2233
|
-
|
|
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}.`);
|
|
2234
3832
|
}
|
|
2235
|
-
await pullImage(image);
|
|
2236
|
-
await assertSupportedHostArchitecture(image);
|
|
2237
3833
|
const sourceVolume = await readSourceVolume(sourceDirectory);
|
|
2238
3834
|
const email = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
2239
3835
|
const password = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
@@ -2245,15 +3841,40 @@ async function adoptCommand(positionals, flags) {
|
|
|
2245
3841
|
if (mode === "public" && [80, 443].includes(sourcePort))
|
|
2246
3842
|
throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
|
|
2247
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
|
+
}
|
|
2248
3856
|
await mkdir(targetDirectory, { recursive: true, mode: 448 });
|
|
3857
|
+
await chmod(targetDirectory, 448);
|
|
2249
3858
|
await mkdir(deploymentPaths(targetDirectory).operations, { recursive: true, mode: 448 });
|
|
2250
3859
|
await mkdir(deploymentPaths(targetDirectory).backups, { recursive: true, mode: 448 });
|
|
3860
|
+
const operator = await prepareVerifiedDashboardOperator(targetDirectory, release.or3Version);
|
|
2251
3861
|
await copyAssets(targetDirectory, mode);
|
|
2252
3862
|
const copiedSecrets = {};
|
|
2253
3863
|
for (const key of SECRET_KEYS)
|
|
2254
3864
|
if (sourceEnv[key])
|
|
2255
3865
|
copiedSecrets[key] = sourceEnv[key];
|
|
2256
|
-
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
|
+
});
|
|
2257
3878
|
for (const key of ALLOWED_ENV_KEYS)
|
|
2258
3879
|
if (sourceEnv[key] !== undefined)
|
|
2259
3880
|
targetEnv[key] = sourceEnv[key];
|
|
@@ -2271,50 +3892,50 @@ async function adoptCommand(positionals, flags) {
|
|
|
2271
3892
|
OR3_PUBLIC_DOMAIN: domain ?? "localhost",
|
|
2272
3893
|
OR3_ALLOWED_ORIGINS: mode === "public" ? `https://${domain}` : `http://127.0.0.1:${sourcePort}`,
|
|
2273
3894
|
OR3_FORCE_HTTPS: mode === "public" ? "true" : "false",
|
|
2274
|
-
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"
|
|
2275
3899
|
});
|
|
2276
|
-
const targetNames = composeProjectNames(targetDirectory);
|
|
2277
|
-
for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
|
|
2278
|
-
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
2279
|
-
if (existing.ok)
|
|
2280
|
-
throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
|
|
2281
|
-
}
|
|
2282
3900
|
await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
|
|
2283
3901
|
await writeSecure(join(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
|
|
2284
3902
|
bootstrapEmail: email,
|
|
2285
3903
|
bootstrapPassword: password,
|
|
2286
|
-
adminUsername:
|
|
2287
|
-
adminPassword:
|
|
3904
|
+
adminUsername: targetEnv.OR3_ADMIN_USERNAME,
|
|
3905
|
+
adminPassword: targetEnv.OR3_ADMIN_PASSWORD
|
|
2288
3906
|
}));
|
|
2289
|
-
const digest = await imageDigest(image);
|
|
2290
3907
|
const state = stateFromEnv(targetDirectory, targetEnv, mode, "adopt", digest);
|
|
3908
|
+
const sourceBackupId = id("backup-adopt-source");
|
|
3909
|
+
const sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
|
|
2291
3910
|
await markPending(targetDirectory, state, {
|
|
2292
3911
|
id: id("adopt"),
|
|
2293
3912
|
operation: "adopt",
|
|
2294
3913
|
startedAt: now(),
|
|
2295
3914
|
message: `Adopting ${sourceDirectory}`,
|
|
2296
|
-
sourceDirectory
|
|
3915
|
+
sourceDirectory,
|
|
3916
|
+
sourceInitiallyRunning,
|
|
3917
|
+
backupId: sourceBackupId,
|
|
3918
|
+
backupPath: sourceBackupDir,
|
|
3919
|
+
phase: "prepared"
|
|
2297
3920
|
});
|
|
2298
3921
|
let sourceStopAttempted = false;
|
|
2299
|
-
let sourceBackupDir;
|
|
2300
3922
|
try {
|
|
2301
3923
|
sourceStopAttempted = true;
|
|
2302
3924
|
const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
|
|
2303
3925
|
if (!sourceStop.ok)
|
|
2304
3926
|
throw new Error(`Could not stop the V1 deployment. ${redact(sourceStop.stderr, secretValues(sourceEnv))}`);
|
|
2305
|
-
|
|
3927
|
+
const localDockerDaemon = await dockerDaemonIsLocal();
|
|
3928
|
+
if (localDockerDaemon && !await portAvailable(Number(targetEnv.OR3_PORT)))
|
|
2306
3929
|
throw new Error(`Port ${targetEnv.OR3_PORT} is still in use after stopping the V1 deployment.`);
|
|
2307
|
-
if (mode === "public") {
|
|
3930
|
+
if (mode === "public" && localDockerDaemon) {
|
|
2308
3931
|
for (const publicPort of [80, 443]) {
|
|
2309
3932
|
if (!await portAvailable(publicPort))
|
|
2310
3933
|
throw new Error(`Public port ${publicPort} is still in use after stopping the V1 deployment.`);
|
|
2311
3934
|
}
|
|
2312
3935
|
}
|
|
2313
|
-
const sourceBackupId = id("adopt-source");
|
|
2314
|
-
sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
|
|
2315
3936
|
await mkdir(sourceBackupDir, { recursive: true, mode: 448 });
|
|
2316
3937
|
await copySecure(join(sourceDirectory, ".env"), join(sourceBackupDir, "config.env"));
|
|
2317
|
-
await archiveExternalVolume(
|
|
3938
|
+
await archiveExternalVolume(sourceImage, sourceVolume, sourceBackupDir);
|
|
2318
3939
|
const sourceManifest = {
|
|
2319
3940
|
schemaVersion: 1,
|
|
2320
3941
|
backupId: sourceBackupId,
|
|
@@ -2332,11 +3953,19 @@ async function adoptCommand(positionals, flags) {
|
|
|
2332
3953
|
caddyConfigVolume: sourceEnv.OR3_CADDY_CONFIG_VOLUME,
|
|
2333
3954
|
port: sourcePort
|
|
2334
3955
|
};
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
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" });
|
|
2338
3962
|
await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
|
|
2339
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);
|
|
2340
3969
|
await clearPending(targetDirectory, state);
|
|
2341
3970
|
console.log(`Adopted ${sourceDirectory} into ${targetDirectory} at OR3 ${release.or3Version}.`);
|
|
2342
3971
|
console.log(`Source backup: ${sourceBackupDir}`);
|
|
@@ -2346,7 +3975,7 @@ async function adoptCommand(positionals, flags) {
|
|
|
2346
3975
|
return;
|
|
2347
3976
|
});
|
|
2348
3977
|
let sourceRecoveryError = "";
|
|
2349
|
-
if (sourceStopAttempted) {
|
|
3978
|
+
if (sourceStopAttempted && sourceInitiallyRunning) {
|
|
2350
3979
|
try {
|
|
2351
3980
|
await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
|
|
2352
3981
|
} catch (recovery) {
|
|
@@ -2361,27 +3990,58 @@ ${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "
|
|
|
2361
3990
|
}
|
|
2362
3991
|
async function resolveResetPasswords(flags) {
|
|
2363
3992
|
const ownerPassword = stringFlag(flags, "owner-password");
|
|
3993
|
+
const ownerPasswordFile = stringFlag(flags, "owner-password-file");
|
|
2364
3994
|
const adminPassword = stringFlag(flags, "admin-password");
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
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.");
|
|
2368
4013
|
}
|
|
2369
|
-
validatePassword(
|
|
2370
|
-
validatePassword(
|
|
2371
|
-
return { ownerPassword, adminPassword };
|
|
4014
|
+
validatePassword(suppliedOwner);
|
|
4015
|
+
validatePassword(suppliedAdmin);
|
|
4016
|
+
return { ownerPassword: suppliedOwner, adminPassword: suppliedAdmin };
|
|
2372
4017
|
}
|
|
2373
4018
|
if (!input.isTTY || !output.isTTY) {
|
|
2374
|
-
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.");
|
|
2375
4020
|
}
|
|
2376
|
-
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 });
|
|
2377
4037
|
try {
|
|
2378
|
-
const
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
validatePassword(admin);
|
|
2382
|
-
return { ownerPassword: owner, adminPassword: admin };
|
|
4038
|
+
const answer = prompt.question(question);
|
|
4039
|
+
muted = true;
|
|
4040
|
+
return await answer;
|
|
2383
4041
|
} finally {
|
|
2384
4042
|
prompt.close();
|
|
4043
|
+
output.write(`
|
|
4044
|
+
`);
|
|
2385
4045
|
}
|
|
2386
4046
|
}
|
|
2387
4047
|
function buildCredentialsResetScript(input2) {
|
|
@@ -2449,24 +4109,32 @@ try {
|
|
|
2449
4109
|
credentials.username = adminUsername;
|
|
2450
4110
|
credentials.password_hash_bcrypt = adminHash;
|
|
2451
4111
|
credentials.updated_at = new Date().toISOString();
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
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
|
+
};
|
|
2458
4125
|
try {
|
|
2459
4126
|
db.transaction(() => {
|
|
2460
4127
|
db.prepare('UPDATE basic_auth_accounts SET password_hash = ?, token_version = token_version + 1, updated_at = ? WHERE id = ?').run(ownerHash, now, account.id);
|
|
2461
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');
|
|
2462
4130
|
})();
|
|
2463
4131
|
} catch (error) {
|
|
2464
4132
|
if (previousCredentials !== null) {
|
|
2465
|
-
try {
|
|
4133
|
+
try { atomicWrite(adminCredentialsPath, previousCredentials); } catch {}
|
|
2466
4134
|
} else {
|
|
2467
4135
|
try { fs.rmSync(adminCredentialsPath, { force: true }); } catch {}
|
|
2468
4136
|
}
|
|
2469
|
-
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)));
|
|
2470
4138
|
process.exit(1);
|
|
2471
4139
|
}
|
|
2472
4140
|
db.close();
|
|
@@ -2474,7 +4142,7 @@ console.log('credentials-reset: owner hash, sessions, and admin credentials upda
|
|
|
2474
4142
|
`;
|
|
2475
4143
|
}
|
|
2476
4144
|
function credentialResetValues(env) {
|
|
2477
|
-
const ownerEmail = env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
4145
|
+
const ownerEmail = env.OR3_MANAGED_OWNER_EMAIL || env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
2478
4146
|
const ownerPassword = env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
2479
4147
|
const adminUsername = env.OR3_ADMIN_USERNAME;
|
|
2480
4148
|
const adminPassword = env.OR3_ADMIN_PASSWORD;
|
|
@@ -2500,43 +4168,57 @@ Promise.all([
|
|
|
2500
4168
|
`;
|
|
2501
4169
|
async function runCredentialsResetScript(directory, mode, values) {
|
|
2502
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
|
+
};
|
|
2503
4178
|
const result = await run("docker", [
|
|
2504
4179
|
...composeArgs(directory, mode, [
|
|
2505
4180
|
"exec",
|
|
2506
4181
|
"-T",
|
|
2507
4182
|
"-e",
|
|
2508
|
-
|
|
4183
|
+
"OR3_RESET_OWNER_EMAIL",
|
|
2509
4184
|
"-e",
|
|
2510
|
-
|
|
4185
|
+
"OR3_RESET_OWNER_PASSWORD",
|
|
2511
4186
|
"-e",
|
|
2512
|
-
|
|
4187
|
+
"OR3_RESET_ADMIN_USERNAME",
|
|
2513
4188
|
"-e",
|
|
2514
|
-
|
|
4189
|
+
"OR3_RESET_ADMIN_PASSWORD",
|
|
2515
4190
|
"or3",
|
|
2516
4191
|
...containerNodeCommand(script)
|
|
2517
4192
|
])
|
|
2518
|
-
], directory);
|
|
4193
|
+
], directory, resetEnvironment);
|
|
2519
4194
|
if (!result.ok)
|
|
2520
4195
|
throw new Error(`${result.command}
|
|
2521
4196
|
${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
|
|
2522
4197
|
}
|
|
2523
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
|
+
};
|
|
2524
4206
|
const result = await run("docker", [
|
|
2525
4207
|
...composeArgs(directory, mode, [
|
|
2526
4208
|
"exec",
|
|
2527
4209
|
"-T",
|
|
2528
4210
|
"-e",
|
|
2529
|
-
|
|
4211
|
+
"OR3_RESET_OWNER_EMAIL",
|
|
2530
4212
|
"-e",
|
|
2531
|
-
|
|
4213
|
+
"OR3_RESET_OWNER_PASSWORD",
|
|
2532
4214
|
"-e",
|
|
2533
|
-
|
|
4215
|
+
"OR3_RESET_ADMIN_USERNAME",
|
|
2534
4216
|
"-e",
|
|
2535
|
-
|
|
4217
|
+
"OR3_RESET_ADMIN_PASSWORD",
|
|
2536
4218
|
"or3",
|
|
2537
4219
|
...containerNodeCommand(CREDENTIALS_VERIFY_SCRIPT)
|
|
2538
4220
|
])
|
|
2539
|
-
], directory);
|
|
4221
|
+
], directory, resetEnvironment);
|
|
2540
4222
|
if (!result.ok)
|
|
2541
4223
|
throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
|
|
2542
4224
|
}
|
|
@@ -2548,18 +4230,12 @@ async function applyCredentialReset(directory, state, nextEnv) {
|
|
|
2548
4230
|
await startProject(directory, state.mode, nextEnv);
|
|
2549
4231
|
}
|
|
2550
4232
|
await runCredentialsResetScript(directory, state.mode, values);
|
|
2551
|
-
|
|
4233
|
+
const runtimeEnv = withoutProvisioningCredentials(nextEnv);
|
|
4234
|
+
await writeSecure(deploymentPaths(directory).env, serializeEnv(runtimeEnv));
|
|
2552
4235
|
const initialCredentials = join(directory, ".or3-initial-credentials");
|
|
2553
|
-
|
|
2554
|
-
await writeSecure(initialCredentials, serializeInitialCredentials({
|
|
2555
|
-
bootstrapEmail: values.ownerEmail,
|
|
2556
|
-
bootstrapPassword: values.ownerPassword,
|
|
2557
|
-
adminUsername: values.adminUsername,
|
|
2558
|
-
adminPassword: values.adminPassword
|
|
2559
|
-
}));
|
|
2560
|
-
}
|
|
4236
|
+
await rm(initialCredentials, { force: true });
|
|
2561
4237
|
await stopProject(directory, state.mode);
|
|
2562
|
-
await startProject(directory, state.mode,
|
|
4238
|
+
await startProject(directory, state.mode, runtimeEnv);
|
|
2563
4239
|
await verifyCredentialsInsideContainer(directory, state.mode, values);
|
|
2564
4240
|
}
|
|
2565
4241
|
async function credentialsResetCommand(directory, flags) {
|
|
@@ -2569,15 +4245,16 @@ async function credentialsResetCommand(directory, flags) {
|
|
|
2569
4245
|
const loaded = await loadManaged(directory);
|
|
2570
4246
|
assertDeploymentDirectoryIdentity(directory, loaded.state);
|
|
2571
4247
|
assertNoPending(loaded.state);
|
|
2572
|
-
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;
|
|
2573
4249
|
const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
|
|
2574
4250
|
if (!ownerEmail)
|
|
2575
|
-
throw new Error("
|
|
4251
|
+
throw new Error("The managed owner email is missing from .env, so the owner account cannot be reset.");
|
|
2576
4252
|
if (!adminUsername)
|
|
2577
4253
|
throw new Error("OR3_ADMIN_USERNAME is missing from .env, so the admin password cannot be reset.");
|
|
2578
4254
|
const { ownerPassword, adminPassword } = await resolveResetPasswords(flags);
|
|
2579
4255
|
const nextEnv = {
|
|
2580
4256
|
...loaded.env,
|
|
4257
|
+
OR3_MANAGED_OWNER_EMAIL: ownerEmail,
|
|
2581
4258
|
OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: ownerPassword,
|
|
2582
4259
|
OR3_ADMIN_PASSWORD: adminPassword,
|
|
2583
4260
|
OR3_ADMIN_JWT_SECRET: randomSecret()
|
|
@@ -2594,7 +4271,7 @@ async function credentialsResetCommand(directory, flags) {
|
|
|
2594
4271
|
await applyCredentialReset(loaded.directory, loaded.state, nextEnv);
|
|
2595
4272
|
await clearPending(loaded.directory, loaded.state);
|
|
2596
4273
|
console.log("Owner and admin credentials are now separate. The old owner password and all app sessions were revoked.");
|
|
2597
|
-
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.");
|
|
2598
4275
|
} catch (error) {
|
|
2599
4276
|
loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(nextEnv));
|
|
2600
4277
|
await writeState(loaded.directory, loaded.state);
|
|
@@ -2744,8 +4421,12 @@ async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
|
|
|
2744
4421
|
const receipt = await readBackupExportReceipt(directory, backup.backupId);
|
|
2745
4422
|
if (!receipt || !await fileExists(receipt.destination))
|
|
2746
4423
|
continue;
|
|
4424
|
+
const deploymentRoot = await realpath(directory);
|
|
2747
4425
|
try {
|
|
2748
|
-
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);
|
|
2749
4430
|
const sourceDevice = (await stat(backup.path)).dev;
|
|
2750
4431
|
const destinationDevice = (await stat(receipt.destination)).dev;
|
|
2751
4432
|
if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
|
|
@@ -2770,13 +4451,19 @@ async function removeCommand(directory, flags) {
|
|
|
2770
4451
|
}
|
|
2771
4452
|
const exportReceipt = await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
|
|
2772
4453
|
const volumes = purgeVolumesFromState(state);
|
|
2773
|
-
const managedFiles = [
|
|
4454
|
+
const managedFiles = [
|
|
4455
|
+
".env",
|
|
4456
|
+
".or3-cloud",
|
|
4457
|
+
...managedAssetNames(state.mode),
|
|
4458
|
+
".or3-initial-credentials"
|
|
4459
|
+
];
|
|
2774
4460
|
console.log("Removing exactly these targets:");
|
|
2775
4461
|
for (const volume of volumes)
|
|
2776
4462
|
console.log(` docker volume ${volume}`);
|
|
2777
4463
|
for (const file of managedFiles)
|
|
2778
4464
|
console.log(` ${join(directory, file)}`);
|
|
2779
4465
|
await compose(directory, state.mode, ["down"]);
|
|
4466
|
+
await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
|
|
2780
4467
|
for (const volume of volumes) {
|
|
2781
4468
|
const result = await run("docker", ["volume", "rm", volume], directory);
|
|
2782
4469
|
if (!result.ok)
|
|
@@ -2797,6 +4484,30 @@ async function probeDeepHealth(directory, mode) {
|
|
|
2797
4484
|
return "degraded";
|
|
2798
4485
|
return "unreachable";
|
|
2799
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
|
+
}
|
|
2800
4511
|
async function main(argv = process.argv.slice(2)) {
|
|
2801
4512
|
const [command = "help", ...rest] = argv;
|
|
2802
4513
|
if (command === "--help" || command === "help")
|
|
@@ -2808,37 +4519,46 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
2808
4519
|
if (parsed.flags.help)
|
|
2809
4520
|
return help();
|
|
2810
4521
|
assertCommandFlags(command, parsed.flags);
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
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();
|
|
2842
4562
|
} catch (error) {
|
|
2843
4563
|
console.error(`
|
|
2844
4564
|
OR3 Cloud failed: ${redact(error instanceof Error ? error.message : String(error))}`);
|
|
@@ -2850,13 +4570,18 @@ if (invokedAsCli || process.argv[1] && resolve(process.argv[1]) === resolve(file
|
|
|
2850
4570
|
await main();
|
|
2851
4571
|
}
|
|
2852
4572
|
export {
|
|
4573
|
+
withoutProvisioningCredentials,
|
|
4574
|
+
validateVerificationHealth,
|
|
2853
4575
|
validatePassword,
|
|
4576
|
+
updateRequiresVolumeRecreation,
|
|
2854
4577
|
supportedImageArchitectures,
|
|
2855
4578
|
stateFromEnv,
|
|
2856
4579
|
snapshotManagedAssets,
|
|
2857
4580
|
serializeInitialCredentials,
|
|
2858
4581
|
serializeEnv,
|
|
2859
4582
|
selectPruneTargets,
|
|
4583
|
+
sameOriginVerificationUrl,
|
|
4584
|
+
restoreRequiresVolumeRecreation,
|
|
2860
4585
|
restoreManagedAssets,
|
|
2861
4586
|
requiredArchiveSpace,
|
|
2862
4587
|
redact,
|
|
@@ -2869,12 +4594,15 @@ export {
|
|
|
2869
4594
|
checkResolvedLoopbackBinding,
|
|
2870
4595
|
buildEnv,
|
|
2871
4596
|
buildCredentialsResetScript,
|
|
4597
|
+
assertVerificationGrant,
|
|
2872
4598
|
assertSupportedSourceCompose,
|
|
2873
4599
|
assertSupportedSource,
|
|
2874
4600
|
assertSupportedArchitecture,
|
|
2875
4601
|
assertRemovableArtifactName,
|
|
2876
4602
|
assertPurgeBackupFreshness,
|
|
4603
|
+
assertImageReleaseLabels,
|
|
2877
4604
|
assertEnoughFreeSpace,
|
|
4605
|
+
assertCommandPositionals,
|
|
2878
4606
|
assertCommandFlags,
|
|
2879
4607
|
assertBackupMatchesDeployment,
|
|
2880
4608
|
PACKAGE_VERSION,
|