@or3/cloud 0.1.12
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/assets/Caddyfile +7 -0
- package/assets/compose.public.yaml +23 -0
- package/assets/compose.yaml +34 -0
- package/dist/cli.mjs +1690 -0
- package/package.json +32 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1690 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
6
|
+
import { createServer } from "node:net";
|
|
7
|
+
import { lookup } from "node:dns/promises";
|
|
8
|
+
import readline from "node:readline/promises";
|
|
9
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import {
|
|
12
|
+
chmod,
|
|
13
|
+
copyFile,
|
|
14
|
+
mkdir,
|
|
15
|
+
readFile,
|
|
16
|
+
readdir,
|
|
17
|
+
rename,
|
|
18
|
+
rm,
|
|
19
|
+
stat,
|
|
20
|
+
writeFile
|
|
21
|
+
} from "node:fs/promises";
|
|
22
|
+
import { promisify } from "node:util";
|
|
23
|
+
import { dirname, basename, isAbsolute, join, resolve } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
var execFile = promisify(execFileCallback);
|
|
26
|
+
var PACKAGE_VERSION = "0.1.12";
|
|
27
|
+
var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
|
|
28
|
+
var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
|
|
29
|
+
var STATE_SCHEMA_VERSION = 1;
|
|
30
|
+
var DEFAULT_PORT = 3000;
|
|
31
|
+
var DEEP_HEALTH_TIMEOUT_MS = 180000;
|
|
32
|
+
var SECRET_KEYS = [
|
|
33
|
+
"OR3_BASIC_AUTH_JWT_SECRET",
|
|
34
|
+
"OR3_BASIC_AUTH_REFRESH_SECRET",
|
|
35
|
+
"OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
|
|
36
|
+
"OR3_STORAGE_FS_TOKEN_SECRET",
|
|
37
|
+
"OR3_ADMIN_JWT_SECRET",
|
|
38
|
+
"OR3_ADMIN_PASSWORD"
|
|
39
|
+
];
|
|
40
|
+
var DEPLOYMENT_ENV_KEYS = [
|
|
41
|
+
"OR3_COMPOSE_PROJECT",
|
|
42
|
+
"OR3_VOLUME_NAME",
|
|
43
|
+
"OR3_CADDY_DATA_VOLUME",
|
|
44
|
+
"OR3_CADDY_CONFIG_VOLUME",
|
|
45
|
+
"OR3_PORT",
|
|
46
|
+
"OR3_PUBLIC_DOMAIN"
|
|
47
|
+
];
|
|
48
|
+
var ALLOWED_ENV_KEYS = new Set([
|
|
49
|
+
"SSR_AUTH_ENABLED",
|
|
50
|
+
"AUTH_PROVIDER",
|
|
51
|
+
"OR3_AUTH_PROVIDER",
|
|
52
|
+
"OR3_GUEST_ACCESS_ENABLED",
|
|
53
|
+
"OR3_BASIC_AUTH_JWT_SECRET",
|
|
54
|
+
"OR3_BASIC_AUTH_REFRESH_SECRET",
|
|
55
|
+
"OR3_BASIC_AUTH_ACCESS_TTL_SECONDS",
|
|
56
|
+
"OR3_BASIC_AUTH_REFRESH_TTL_SECONDS",
|
|
57
|
+
"OR3_BASIC_AUTH_DB_PATH",
|
|
58
|
+
"OR3_BASIC_AUTH_BOOTSTRAP_EMAIL",
|
|
59
|
+
"OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
|
|
60
|
+
"OR3_SYNC_ENABLED",
|
|
61
|
+
"OR3_CLOUD_SYNC_ENABLED",
|
|
62
|
+
"OR3_SYNC_PROVIDER",
|
|
63
|
+
"OR3_SQLITE_DB_PATH",
|
|
64
|
+
"OR3_SQLITE_PRAGMA_JOURNAL_MODE",
|
|
65
|
+
"OR3_SQLITE_PRAGMA_SYNCHRONOUS",
|
|
66
|
+
"OR3_SQLITE_ALLOW_IN_MEMORY",
|
|
67
|
+
"OR3_SQLITE_STRICT",
|
|
68
|
+
"OR3_STORAGE_ENABLED",
|
|
69
|
+
"OR3_CLOUD_STORAGE_ENABLED",
|
|
70
|
+
"NUXT_PUBLIC_STORAGE_PROVIDER",
|
|
71
|
+
"OR3_STORAGE_FS_ROOT",
|
|
72
|
+
"OR3_STORAGE_FS_TOKEN_SECRET",
|
|
73
|
+
"OR3_STORAGE_FS_URL_TTL_SECONDS",
|
|
74
|
+
"OR3_ADMIN_USERNAME",
|
|
75
|
+
"OR3_ADMIN_PASSWORD",
|
|
76
|
+
"OR3_ADMIN_JWT_SECRET",
|
|
77
|
+
"OR3_ADMIN_JWT_EXPIRY",
|
|
78
|
+
"OR3_PUBLIC_DOMAIN",
|
|
79
|
+
"OR3_ALLOWED_ORIGINS",
|
|
80
|
+
"OR3_FORCE_HTTPS",
|
|
81
|
+
"OR3_TRUST_PROXY",
|
|
82
|
+
"OR3_FORWARDED_FOR_HEADER"
|
|
83
|
+
]);
|
|
84
|
+
function now() {
|
|
85
|
+
return new Date().toISOString();
|
|
86
|
+
}
|
|
87
|
+
function id(prefix) {
|
|
88
|
+
return `${prefix}-${now().replaceAll(/[:.]/g, "-")}-${randomBytes(4).toString("hex")}`;
|
|
89
|
+
}
|
|
90
|
+
function randomSecret() {
|
|
91
|
+
return `or3-${randomBytes(32).toString("base64url")}`;
|
|
92
|
+
}
|
|
93
|
+
function randomPassword() {
|
|
94
|
+
return `A${randomBytes(20).toString("base64url")}a1`;
|
|
95
|
+
}
|
|
96
|
+
function validatePassword(password) {
|
|
97
|
+
if (password.length < 12 || !/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
|
|
98
|
+
throw new Error("The administrator password must be at least 12 characters and contain uppercase, lowercase, and numeric characters.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function quote(value) {
|
|
102
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
103
|
+
}
|
|
104
|
+
function redact(text, secrets = []) {
|
|
105
|
+
let output2 = text;
|
|
106
|
+
for (const secret of secrets.filter(Boolean))
|
|
107
|
+
output2 = output2.replaceAll(secret, "[REDACTED]");
|
|
108
|
+
output2 = output2.replace(/((?:PASSWORD|SECRET|TOKEN|JWT)\s*[=:]\s*)([^\s\n]+)/gi, "$1[REDACTED]");
|
|
109
|
+
return output2;
|
|
110
|
+
}
|
|
111
|
+
function parseFlags(argv) {
|
|
112
|
+
const positionals = [];
|
|
113
|
+
const flags = {};
|
|
114
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
115
|
+
const value = argv[index];
|
|
116
|
+
if (!value.startsWith("--")) {
|
|
117
|
+
positionals.push(value);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const [rawKey, inline] = value.slice(2).split("=", 2);
|
|
121
|
+
if (inline !== undefined) {
|
|
122
|
+
flags[rawKey] = inline;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const next = argv[index + 1];
|
|
126
|
+
if (next && !next.startsWith("--")) {
|
|
127
|
+
flags[rawKey] = next;
|
|
128
|
+
index += 1;
|
|
129
|
+
} else {
|
|
130
|
+
flags[rawKey] = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { positionals, flags };
|
|
134
|
+
}
|
|
135
|
+
function stringFlag(flags, key) {
|
|
136
|
+
const value = flags[key];
|
|
137
|
+
return typeof value === "string" ? value : undefined;
|
|
138
|
+
}
|
|
139
|
+
function boolFlag(flags, key) {
|
|
140
|
+
return flags[key] === true;
|
|
141
|
+
}
|
|
142
|
+
function requireStringFlag(flags, key) {
|
|
143
|
+
const value = stringFlag(flags, key)?.trim();
|
|
144
|
+
if (!value)
|
|
145
|
+
throw new Error(`--${key} requires a value.`);
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
function isVersion(value) {
|
|
149
|
+
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
|
|
150
|
+
}
|
|
151
|
+
function imageFor(version) {
|
|
152
|
+
if (!isVersion(version))
|
|
153
|
+
throw new Error(`Invalid release version "${version}".`);
|
|
154
|
+
const testImage = process.env.OR3_CLOUD_TEST_IMAGE?.trim();
|
|
155
|
+
if (testImage)
|
|
156
|
+
return testImage;
|
|
157
|
+
return `${IMAGE_REPOSITORY}:${version}`;
|
|
158
|
+
}
|
|
159
|
+
function sanitizeName(value, fallback = "or3-cloud") {
|
|
160
|
+
const name = value.toLowerCase().replaceAll(/[^a-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
|
|
161
|
+
return name || fallback;
|
|
162
|
+
}
|
|
163
|
+
function validateEmail(email) {
|
|
164
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
165
|
+
throw new Error("Use a real administrator email address, for example admin@example.com.");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function resolveAdminEmail(flags) {
|
|
169
|
+
const supplied = stringFlag(flags, "admin-email")?.trim();
|
|
170
|
+
if (supplied) {
|
|
171
|
+
validateEmail(supplied);
|
|
172
|
+
return supplied;
|
|
173
|
+
}
|
|
174
|
+
if (!input.isTTY || !output.isTTY) {
|
|
175
|
+
throw new Error("--admin-email is required in a non-interactive session so the first administrator identity is not a placeholder.");
|
|
176
|
+
}
|
|
177
|
+
const prompt = readline.createInterface({ input, output });
|
|
178
|
+
try {
|
|
179
|
+
const answer = (await prompt.question("Administrator email: ")).trim();
|
|
180
|
+
validateEmail(answer);
|
|
181
|
+
return answer;
|
|
182
|
+
} finally {
|
|
183
|
+
prompt.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function validateDomain(domain) {
|
|
187
|
+
if (domain.includes("://") || domain.includes("/") || domain.includes(" ") || !/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/.test(domain)) {
|
|
188
|
+
throw new Error(`"${domain}" is not a hostname. Use a name such as cloud.example.com.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function run(command, args, cwd) {
|
|
192
|
+
const printable = `${command} ${args.map(quote).join(" ")}`;
|
|
193
|
+
try {
|
|
194
|
+
const result = await execFile(command, args, {
|
|
195
|
+
cwd,
|
|
196
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
197
|
+
encoding: "utf8",
|
|
198
|
+
env: process.env
|
|
199
|
+
});
|
|
200
|
+
return { ok: true, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const failure = error;
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
command: printable,
|
|
206
|
+
exitCode: typeof failure.code === "number" ? failure.code : null,
|
|
207
|
+
stderr: redact(failure.stderr ?? failure.stdout ?? String(error))
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function requireCommand(command, args, label, cwd) {
|
|
212
|
+
const result = await run(command, args, cwd);
|
|
213
|
+
if (!result.ok)
|
|
214
|
+
throw new Error(`${label} is unavailable. Run: ${result.command}
|
|
215
|
+
${result.stderr}`);
|
|
216
|
+
return result.stdout.trim();
|
|
217
|
+
}
|
|
218
|
+
async function writeSecure(path, content) {
|
|
219
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
220
|
+
const temporary = `${path}.tmp-${randomBytes(4).toString("hex")}`;
|
|
221
|
+
await writeFile(temporary, content, { mode: 384 });
|
|
222
|
+
await chmod(temporary, 384);
|
|
223
|
+
await rename(temporary, path);
|
|
224
|
+
await chmod(path, 384);
|
|
225
|
+
}
|
|
226
|
+
async function copySecure(source, destination) {
|
|
227
|
+
await mkdir(dirname(destination), { recursive: true, mode: 448 });
|
|
228
|
+
await copyFile(source, destination);
|
|
229
|
+
await chmod(destination, 384);
|
|
230
|
+
}
|
|
231
|
+
async function fileExists(path) {
|
|
232
|
+
try {
|
|
233
|
+
await stat(path);
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function readText(path) {
|
|
240
|
+
return readFile(path, "utf8");
|
|
241
|
+
}
|
|
242
|
+
function parseEnv(text) {
|
|
243
|
+
const values = {};
|
|
244
|
+
for (const line of text.split(/\r?\n/)) {
|
|
245
|
+
const trimmed = line.trim();
|
|
246
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
247
|
+
continue;
|
|
248
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
249
|
+
if (!match)
|
|
250
|
+
continue;
|
|
251
|
+
let value = match[2];
|
|
252
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
253
|
+
value = value.slice(1, -1).replaceAll(/\\([\\'])/g, "$1");
|
|
254
|
+
} else if (value.startsWith('"') && value.endsWith('"')) {
|
|
255
|
+
value = value.slice(1, -1);
|
|
256
|
+
}
|
|
257
|
+
values[match[1]] = value;
|
|
258
|
+
}
|
|
259
|
+
return values;
|
|
260
|
+
}
|
|
261
|
+
function serializeEnvValue(value) {
|
|
262
|
+
if (value.includes("\x00") || /\r|\n/.test(value)) {
|
|
263
|
+
throw new Error("Environment values may not contain NUL or newline characters.");
|
|
264
|
+
}
|
|
265
|
+
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
266
|
+
}
|
|
267
|
+
function serializeEnv(values) {
|
|
268
|
+
return `${Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${serializeEnvValue(value)}`).join(`
|
|
269
|
+
`)}
|
|
270
|
+
`;
|
|
271
|
+
}
|
|
272
|
+
function deploymentPaths(directory) {
|
|
273
|
+
const cloud = join(directory, ".or3-cloud");
|
|
274
|
+
return {
|
|
275
|
+
env: join(directory, ".env"),
|
|
276
|
+
state: join(cloud, "state.json"),
|
|
277
|
+
operations: join(cloud, "operations"),
|
|
278
|
+
backups: join(cloud, "backups")
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
async function readState(directory) {
|
|
282
|
+
const paths = deploymentPaths(directory);
|
|
283
|
+
const parsed = JSON.parse(await readText(paths.state));
|
|
284
|
+
if (parsed.schemaVersion !== STATE_SCHEMA_VERSION || !parsed.appVersion || !parsed.image || !parsed.composeProject) {
|
|
285
|
+
throw new Error(`Invalid managed state at ${paths.state}. Run "npx @or3/cloud doctor" for diagnostics.`);
|
|
286
|
+
}
|
|
287
|
+
return parsed;
|
|
288
|
+
}
|
|
289
|
+
async function writeState(directory, state) {
|
|
290
|
+
await writeSecure(deploymentPaths(directory).state, `${JSON.stringify(state, null, 2)}
|
|
291
|
+
`);
|
|
292
|
+
}
|
|
293
|
+
async function readDirectoryEmpty(directory) {
|
|
294
|
+
try {
|
|
295
|
+
const entries = await readdir(directory);
|
|
296
|
+
if (entries.length)
|
|
297
|
+
throw new Error(`Refusing to use non-empty directory ${directory}. Choose a new directory or run adopt explicitly.`);
|
|
298
|
+
return false;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error.code === "ENOENT")
|
|
301
|
+
return true;
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function composeArgs(directory, mode, command = []) {
|
|
306
|
+
const files = ["-f", join(directory, "compose.yaml")];
|
|
307
|
+
if (mode === "public")
|
|
308
|
+
files.push("-f", join(directory, "compose.public.yaml"));
|
|
309
|
+
return [
|
|
310
|
+
"compose",
|
|
311
|
+
"--project-directory",
|
|
312
|
+
directory,
|
|
313
|
+
"--env-file",
|
|
314
|
+
join(directory, ".env"),
|
|
315
|
+
...files,
|
|
316
|
+
...command
|
|
317
|
+
];
|
|
318
|
+
}
|
|
319
|
+
function diagnostics(directory, mode) {
|
|
320
|
+
const files = `-f ${quote(join(directory, "compose.yaml"))}${mode === "public" ? ` -f ${quote(join(directory, "compose.public.yaml"))}` : ""}`;
|
|
321
|
+
return `cd ${quote(directory)} && docker compose --env-file ${quote(join(directory, ".env"))} ${files} ps && docker compose --env-file ${quote(join(directory, ".env"))} ${files} logs --tail=200`;
|
|
322
|
+
}
|
|
323
|
+
async function compose(directory, mode, command, secrets = []) {
|
|
324
|
+
const result = await run("docker", composeArgs(directory, mode, command), directory);
|
|
325
|
+
if (!result.ok) {
|
|
326
|
+
throw new Error(`${result.command}
|
|
327
|
+
${redact(result.stderr, secrets)}
|
|
328
|
+
Diagnostics: ${diagnostics(directory, mode)}`);
|
|
329
|
+
}
|
|
330
|
+
return result.stdout;
|
|
331
|
+
}
|
|
332
|
+
function parseComposeConfig(text) {
|
|
333
|
+
try {
|
|
334
|
+
const parsed = JSON.parse(text);
|
|
335
|
+
if (!parsed || typeof parsed !== "object" || !parsed.services)
|
|
336
|
+
throw new Error("missing services");
|
|
337
|
+
return parsed;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
throw new Error(`Docker Compose did not return a readable JSON configuration: ${error instanceof Error ? error.message : String(error)}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function composeEnvironmentValue(service, key) {
|
|
343
|
+
const environment = service.environment;
|
|
344
|
+
if (Array.isArray(environment)) {
|
|
345
|
+
const line = environment.find((value2) => value2.startsWith(`${key}=`));
|
|
346
|
+
return line?.slice(key.length + 1);
|
|
347
|
+
}
|
|
348
|
+
const value = environment?.[key];
|
|
349
|
+
return typeof value === "string" ? value : undefined;
|
|
350
|
+
}
|
|
351
|
+
function composePublishedPort(port) {
|
|
352
|
+
const target = Number(port.target);
|
|
353
|
+
const published = Number(port.published);
|
|
354
|
+
const hostIp = typeof port.host_ip === "string" ? port.host_ip : undefined;
|
|
355
|
+
return { target, published, hostIp, protocol: port.protocol };
|
|
356
|
+
}
|
|
357
|
+
function checkResolvedLoopbackBinding(config, expectedPort) {
|
|
358
|
+
try {
|
|
359
|
+
const parsed = parseComposeConfig(config);
|
|
360
|
+
const service = parsed.services?.or3;
|
|
361
|
+
if (!service)
|
|
362
|
+
return false;
|
|
363
|
+
const ports = (service.ports ?? []).map(composePublishedPort);
|
|
364
|
+
const appPorts = ports.filter(({ target }) => target === 3000);
|
|
365
|
+
if (!appPorts.length)
|
|
366
|
+
return false;
|
|
367
|
+
if (expectedPort !== undefined && !appPorts.some(({ published }) => published === expectedPort))
|
|
368
|
+
return false;
|
|
369
|
+
return appPorts.every(({ hostIp, protocol }) => hostIp === "127.0.0.1" && (protocol === undefined || protocol === "tcp"));
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function resolvedComposeConfig(directory, mode) {
|
|
375
|
+
const result = await run("docker", composeArgs(directory, mode, ["config", "--format", "json"]), directory);
|
|
376
|
+
if (!result.ok)
|
|
377
|
+
throw new Error(`${result.command}
|
|
378
|
+
${result.stderr}`);
|
|
379
|
+
return result.stdout;
|
|
380
|
+
}
|
|
381
|
+
async function assertSafeComposeBinding(directory, mode, env) {
|
|
382
|
+
const config = await resolvedComposeConfig(directory, mode);
|
|
383
|
+
if (!checkResolvedLoopbackBinding(config, Number(env.OR3_PORT))) {
|
|
384
|
+
throw new Error("Resolved Compose configuration must publish OR3 port 3000 only on 127.0.0.1. Refusing to expose the application directly.");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
var HEALTH_SCRIPT = "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
|
|
388
|
+
async function waitForDeepHealthWithArgs(composeCommand, directory, secrets = []) {
|
|
389
|
+
const deadline = Date.now() + DEEP_HEALTH_TIMEOUT_MS;
|
|
390
|
+
let lastError = "health check did not complete";
|
|
391
|
+
while (Date.now() < deadline) {
|
|
392
|
+
const result = await run("docker", [...composeCommand, "exec", "-T", "or3", "node", "-e", HEALTH_SCRIPT], directory);
|
|
393
|
+
if (result.ok)
|
|
394
|
+
return;
|
|
395
|
+
lastError = redact(result.stderr, secrets);
|
|
396
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
|
|
397
|
+
}
|
|
398
|
+
throw new Error(`OR3 deep health did not pass within ${DEEP_HEALTH_TIMEOUT_MS / 1000} seconds. Last error: ${lastError}`);
|
|
399
|
+
}
|
|
400
|
+
async function ensureDocker() {
|
|
401
|
+
await requireCommand("docker", ["info"], "Docker Engine");
|
|
402
|
+
await requireCommand("docker", ["compose", "version"], "Docker Compose v2");
|
|
403
|
+
}
|
|
404
|
+
async function portAvailable(port) {
|
|
405
|
+
return new Promise((resolvePromise) => {
|
|
406
|
+
const server = createServer();
|
|
407
|
+
server.once("error", () => resolvePromise(false));
|
|
408
|
+
server.once("listening", () => server.close(() => resolvePromise(true)));
|
|
409
|
+
server.listen(port, "127.0.0.1");
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async function waitForDeepHealth(directory, mode, secrets = []) {
|
|
413
|
+
try {
|
|
414
|
+
await waitForDeepHealthWithArgs(composeArgs(directory, mode), directory, secrets);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}
|
|
417
|
+
Diagnostics: ${diagnostics(directory, mode)}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async function pullImage(image) {
|
|
421
|
+
if (process.env.OR3_CLOUD_SKIP_PULL !== "true") {
|
|
422
|
+
const result = await run("docker", ["pull", image]);
|
|
423
|
+
if (!result.ok)
|
|
424
|
+
throw new Error(`${result.command}
|
|
425
|
+
${result.stderr}`);
|
|
426
|
+
}
|
|
427
|
+
return imageDigest(image);
|
|
428
|
+
}
|
|
429
|
+
function imageRepository(image) {
|
|
430
|
+
const reference = image.split("@", 1)[0];
|
|
431
|
+
const slash = reference.lastIndexOf("/");
|
|
432
|
+
const colon = reference.lastIndexOf(":");
|
|
433
|
+
return colon > slash ? reference.slice(0, colon) : reference;
|
|
434
|
+
}
|
|
435
|
+
async function requireImageDigest(image, expected, label) {
|
|
436
|
+
const actual = await imageDigest(image);
|
|
437
|
+
if (actual !== expected) {
|
|
438
|
+
throw new Error(`${label} image digest mismatch for ${image}. Expected ${expected}, found ${actual}. The registry tag may have moved; refusing to mutate the deployment.`);
|
|
439
|
+
}
|
|
440
|
+
return actual;
|
|
441
|
+
}
|
|
442
|
+
async function pullAndRequireImage(image, expected, label) {
|
|
443
|
+
const actual = await pullImage(image);
|
|
444
|
+
if (actual !== expected) {
|
|
445
|
+
throw new Error(`${label} image digest mismatch for ${image}. Expected ${expected}, found ${actual}. The registry tag may have moved; refusing to mutate the deployment.`);
|
|
446
|
+
}
|
|
447
|
+
return actual;
|
|
448
|
+
}
|
|
449
|
+
async function imageDigest(image) {
|
|
450
|
+
const result = await run("docker", ["image", "inspect", "--format", "{{json .RepoDigests}}", image]);
|
|
451
|
+
if (!result.ok)
|
|
452
|
+
throw new Error(`${result.command}
|
|
453
|
+
${result.stderr}`);
|
|
454
|
+
try {
|
|
455
|
+
const digests = JSON.parse(result.stdout.trim());
|
|
456
|
+
const repository = imageRepository(image);
|
|
457
|
+
const digest = digests.find((value) => value.startsWith(`${repository}@sha256:`))?.split("@").at(-1);
|
|
458
|
+
if (digest)
|
|
459
|
+
return digest;
|
|
460
|
+
} catch {}
|
|
461
|
+
const idResult = await run("docker", ["image", "inspect", "--format", "{{.Id}}", image]);
|
|
462
|
+
if (!idResult.ok || !idResult.stdout.trim())
|
|
463
|
+
throw new Error(`Could not resolve a digest for ${image}.`);
|
|
464
|
+
return idResult.stdout.trim();
|
|
465
|
+
}
|
|
466
|
+
function composeProjectNames(directory) {
|
|
467
|
+
const base = sanitizeName(basename(directory));
|
|
468
|
+
return {
|
|
469
|
+
project: base,
|
|
470
|
+
volume: `${base}-or3-data`,
|
|
471
|
+
caddyData: `${base}-caddy-data`,
|
|
472
|
+
caddyConfig: `${base}-caddy-config`
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function buildEnv(input2) {
|
|
476
|
+
const names = composeProjectNames(input2.directory);
|
|
477
|
+
const secrets = input2.secrets ?? {};
|
|
478
|
+
const publicOrigin = input2.mode === "public" ? `https://${input2.domain}` : `http://127.0.0.1:${input2.port}`;
|
|
479
|
+
const values = {
|
|
480
|
+
OR3_VERSION: input2.version,
|
|
481
|
+
OR3_IMAGE: imageFor(input2.version),
|
|
482
|
+
OR3_COMPOSE_PROJECT: names.project,
|
|
483
|
+
OR3_VOLUME_NAME: names.volume,
|
|
484
|
+
OR3_CADDY_DATA_VOLUME: names.caddyData,
|
|
485
|
+
OR3_CADDY_CONFIG_VOLUME: names.caddyConfig,
|
|
486
|
+
OR3_PORT: String(input2.port),
|
|
487
|
+
SSR_AUTH_ENABLED: "true",
|
|
488
|
+
AUTH_PROVIDER: "basic-auth",
|
|
489
|
+
OR3_AUTH_PROVIDER: "basic-auth",
|
|
490
|
+
OR3_GUEST_ACCESS_ENABLED: "false",
|
|
491
|
+
OR3_BASIC_AUTH_JWT_SECRET: secrets.OR3_BASIC_AUTH_JWT_SECRET ?? randomSecret(),
|
|
492
|
+
OR3_BASIC_AUTH_REFRESH_SECRET: secrets.OR3_BASIC_AUTH_REFRESH_SECRET ?? randomSecret(),
|
|
493
|
+
OR3_BASIC_AUTH_ACCESS_TTL_SECONDS: "900",
|
|
494
|
+
OR3_BASIC_AUTH_REFRESH_TTL_SECONDS: "2592000",
|
|
495
|
+
OR3_BASIC_AUTH_DB_PATH: "/data/auth.sqlite",
|
|
496
|
+
OR3_BASIC_AUTH_BOOTSTRAP_EMAIL: input2.email,
|
|
497
|
+
OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: input2.password,
|
|
498
|
+
OR3_SYNC_ENABLED: "true",
|
|
499
|
+
OR3_CLOUD_SYNC_ENABLED: "true",
|
|
500
|
+
OR3_SYNC_PROVIDER: "sqlite",
|
|
501
|
+
OR3_SQLITE_DB_PATH: "/data/sync.sqlite",
|
|
502
|
+
OR3_SQLITE_PRAGMA_JOURNAL_MODE: "WAL",
|
|
503
|
+
OR3_SQLITE_PRAGMA_SYNCHRONOUS: "NORMAL",
|
|
504
|
+
OR3_SQLITE_ALLOW_IN_MEMORY: "false",
|
|
505
|
+
OR3_SQLITE_STRICT: "false",
|
|
506
|
+
OR3_STORAGE_ENABLED: "true",
|
|
507
|
+
OR3_CLOUD_STORAGE_ENABLED: "true",
|
|
508
|
+
NUXT_PUBLIC_STORAGE_PROVIDER: "fs",
|
|
509
|
+
OR3_STORAGE_FS_ROOT: "/data/storage",
|
|
510
|
+
OR3_STORAGE_FS_TOKEN_SECRET: secrets.OR3_STORAGE_FS_TOKEN_SECRET ?? randomSecret(),
|
|
511
|
+
OR3_STORAGE_FS_URL_TTL_SECONDS: "900",
|
|
512
|
+
OR3_ADMIN_USERNAME: input2.email,
|
|
513
|
+
OR3_ADMIN_PASSWORD: input2.password,
|
|
514
|
+
OR3_ADMIN_JWT_SECRET: secrets.OR3_ADMIN_JWT_SECRET ?? randomSecret(),
|
|
515
|
+
OR3_ADMIN_JWT_EXPIRY: "24h",
|
|
516
|
+
OR3_PUBLIC_DOMAIN: input2.domain ?? "localhost",
|
|
517
|
+
OR3_ALLOWED_ORIGINS: publicOrigin,
|
|
518
|
+
OR3_FORCE_HTTPS: input2.mode === "public" ? "true" : "false",
|
|
519
|
+
OR3_TRUST_PROXY: input2.mode === "public" ? "true" : "false",
|
|
520
|
+
OR3_FORWARDED_FOR_HEADER: "x-forwarded-for"
|
|
521
|
+
};
|
|
522
|
+
return values;
|
|
523
|
+
}
|
|
524
|
+
function secretValues(env) {
|
|
525
|
+
return SECRET_KEYS.map((key) => env[key]).filter((value) => Boolean(value));
|
|
526
|
+
}
|
|
527
|
+
async function readPassword(flags) {
|
|
528
|
+
const passwordFlagValue = flags["admin-password"];
|
|
529
|
+
const passwordFileValue = flags["admin-password-file"];
|
|
530
|
+
if (passwordFlagValue !== undefined && typeof passwordFlagValue !== "string") {
|
|
531
|
+
throw new Error("--admin-password requires a value.");
|
|
532
|
+
}
|
|
533
|
+
if (passwordFileValue !== undefined && typeof passwordFileValue !== "string") {
|
|
534
|
+
throw new Error("--admin-password-file requires a path.");
|
|
535
|
+
}
|
|
536
|
+
const passwordFlag = passwordFlagValue;
|
|
537
|
+
const passwordFile = passwordFileValue;
|
|
538
|
+
if (passwordFileValue !== undefined && !passwordFile) {
|
|
539
|
+
throw new Error("--admin-password-file requires a path.");
|
|
540
|
+
}
|
|
541
|
+
if (passwordFlag !== undefined && passwordFile !== undefined) {
|
|
542
|
+
throw new Error("Use either --admin-password or --admin-password-file, not both.");
|
|
543
|
+
}
|
|
544
|
+
if (passwordFlag !== undefined) {
|
|
545
|
+
validatePassword(passwordFlag);
|
|
546
|
+
return passwordFlag;
|
|
547
|
+
}
|
|
548
|
+
if (passwordFile) {
|
|
549
|
+
const value = (await readText(resolve(passwordFile))).trim();
|
|
550
|
+
if (!value)
|
|
551
|
+
throw new Error("The administrator password file is empty.");
|
|
552
|
+
validatePassword(value);
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
const password = randomPassword();
|
|
556
|
+
validatePassword(password);
|
|
557
|
+
return password;
|
|
558
|
+
}
|
|
559
|
+
async function copyAssets(directory, mode) {
|
|
560
|
+
await copyFile(join(ASSET_ROOT, "compose.yaml"), join(directory, "compose.yaml"));
|
|
561
|
+
await chmod(join(directory, "compose.yaml"), 420);
|
|
562
|
+
if (mode === "public") {
|
|
563
|
+
await copyFile(join(ASSET_ROOT, "compose.public.yaml"), join(directory, "compose.public.yaml"));
|
|
564
|
+
await copyFile(join(ASSET_ROOT, "Caddyfile"), join(directory, "Caddyfile"));
|
|
565
|
+
await chmod(join(directory, "compose.public.yaml"), 420);
|
|
566
|
+
await chmod(join(directory, "Caddyfile"), 420);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async function checkPublicPrerequisites(domain) {
|
|
570
|
+
validateDomain(domain);
|
|
571
|
+
let address;
|
|
572
|
+
try {
|
|
573
|
+
address = (await lookup(domain)).address;
|
|
574
|
+
} catch {
|
|
575
|
+
throw new Error(`DNS for ${domain} does not resolve yet. Create the A/AAAA record before starting public mode.`);
|
|
576
|
+
}
|
|
577
|
+
return address;
|
|
578
|
+
}
|
|
579
|
+
async function markPending(directory, state, operation) {
|
|
580
|
+
state.incompleteOperation = operation;
|
|
581
|
+
await writeState(directory, state);
|
|
582
|
+
await writeSecure(join(deploymentPaths(directory).operations, `${operation.id}.json`), `${JSON.stringify(operation, null, 2)}
|
|
583
|
+
`);
|
|
584
|
+
}
|
|
585
|
+
async function updatePending(directory, state, patch) {
|
|
586
|
+
if (!state.incompleteOperation)
|
|
587
|
+
throw new Error("No incomplete operation is available to update.");
|
|
588
|
+
Object.assign(state.incompleteOperation, patch);
|
|
589
|
+
state.updatedAt = now();
|
|
590
|
+
await writeState(directory, state);
|
|
591
|
+
await writeSecure(join(deploymentPaths(directory).operations, `${state.incompleteOperation.id}.json`), `${JSON.stringify(state.incompleteOperation, null, 2)}
|
|
592
|
+
`);
|
|
593
|
+
}
|
|
594
|
+
async function removeOperationRecord(directory, operationId) {
|
|
595
|
+
if (!operationId)
|
|
596
|
+
return;
|
|
597
|
+
await rm(join(deploymentPaths(directory).operations, `${operationId}.json`), { force: true });
|
|
598
|
+
}
|
|
599
|
+
async function clearPending(directory, state) {
|
|
600
|
+
const operationId = state.incompleteOperation?.id;
|
|
601
|
+
delete state.incompleteOperation;
|
|
602
|
+
state.updatedAt = now();
|
|
603
|
+
await removeOperationRecord(directory, operationId);
|
|
604
|
+
await writeState(directory, state);
|
|
605
|
+
}
|
|
606
|
+
async function stopProject(directory, mode) {
|
|
607
|
+
await compose(directory, mode, ["stop", "or3"]);
|
|
608
|
+
}
|
|
609
|
+
async function startProject(directory, mode, env) {
|
|
610
|
+
await assertSafeComposeBinding(directory, mode, env);
|
|
611
|
+
await compose(directory, mode, ["up", "-d", "--wait", "--wait-timeout", "180"], secretValues(env));
|
|
612
|
+
await waitForDeepHealth(directory, mode, secretValues(env));
|
|
613
|
+
}
|
|
614
|
+
function backupDirectory(directory, backupId) {
|
|
615
|
+
return join(deploymentPaths(directory).backups, backupId);
|
|
616
|
+
}
|
|
617
|
+
function assertDeploymentIdentity(state, env) {
|
|
618
|
+
const expected = {
|
|
619
|
+
OR3_COMPOSE_PROJECT: state.composeProject,
|
|
620
|
+
OR3_VOLUME_NAME: state.volumeName,
|
|
621
|
+
OR3_CADDY_DATA_VOLUME: state.caddyDataVolume,
|
|
622
|
+
OR3_CADDY_CONFIG_VOLUME: state.caddyConfigVolume,
|
|
623
|
+
OR3_PORT: String(state.port),
|
|
624
|
+
OR3_PUBLIC_DOMAIN: state.domain ?? "localhost"
|
|
625
|
+
};
|
|
626
|
+
for (const key of DEPLOYMENT_ENV_KEYS) {
|
|
627
|
+
if (state.mode === "local" && (key === "OR3_CADDY_DATA_VOLUME" || key === "OR3_CADDY_CONFIG_VOLUME"))
|
|
628
|
+
continue;
|
|
629
|
+
if (env[key] !== expected[key]) {
|
|
630
|
+
throw new Error(`Managed state does not match ${key} in .env. Refusing to operate on an unexpected deployment identity.`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
|
|
635
|
+
if (manifest.mode !== state.mode || manifest.appVersion === undefined || manifest.image === undefined) {
|
|
636
|
+
throw new Error(`Backup ${manifest.backupId} does not match the managed deployment mode.`);
|
|
637
|
+
}
|
|
638
|
+
assertDeploymentIdentity(state, env);
|
|
639
|
+
const expected = {
|
|
640
|
+
OR3_COMPOSE_PROJECT: state.composeProject,
|
|
641
|
+
OR3_VOLUME_NAME: state.volumeName,
|
|
642
|
+
OR3_CADDY_DATA_VOLUME: state.caddyDataVolume,
|
|
643
|
+
OR3_CADDY_CONFIG_VOLUME: state.caddyConfigVolume,
|
|
644
|
+
OR3_PORT: String(state.port),
|
|
645
|
+
OR3_PUBLIC_DOMAIN: state.domain ?? "localhost"
|
|
646
|
+
};
|
|
647
|
+
for (const key of DEPLOYMENT_ENV_KEYS) {
|
|
648
|
+
if (state.mode === "local" && (key === "OR3_CADDY_DATA_VOLUME" || key === "OR3_CADDY_CONFIG_VOLUME"))
|
|
649
|
+
continue;
|
|
650
|
+
if (backupEnv[key] !== expected[key]) {
|
|
651
|
+
throw new Error(`Backup ${manifest.backupId} belongs to a different deployment identity (${key}). Refusing to replace this deployment's data.`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (manifest.composeProject !== undefined && manifest.composeProject !== state.composeProject) {
|
|
655
|
+
throw new Error(`Backup ${manifest.backupId} belongs to Compose project ${manifest.composeProject}, not ${state.composeProject}.`);
|
|
656
|
+
}
|
|
657
|
+
if (manifest.volumeName !== undefined && manifest.volumeName !== state.volumeName) {
|
|
658
|
+
throw new Error(`Backup ${manifest.backupId} belongs to volume ${manifest.volumeName}, not ${state.volumeName}.`);
|
|
659
|
+
}
|
|
660
|
+
if (manifest.domain !== undefined && manifest.domain !== state.domain) {
|
|
661
|
+
throw new Error(`Backup ${manifest.backupId} belongs to domain ${manifest.domain}, not ${state.domain ?? "localhost"}.`);
|
|
662
|
+
}
|
|
663
|
+
if (manifest.caddyDataVolume !== undefined && manifest.caddyDataVolume !== state.caddyDataVolume) {
|
|
664
|
+
throw new Error(`Backup ${manifest.backupId} belongs to a different Caddy data volume.`);
|
|
665
|
+
}
|
|
666
|
+
if (manifest.caddyConfigVolume !== undefined && manifest.caddyConfigVolume !== state.caddyConfigVolume) {
|
|
667
|
+
throw new Error(`Backup ${manifest.backupId} belongs to a different Caddy config volume.`);
|
|
668
|
+
}
|
|
669
|
+
if (manifest.port !== undefined && manifest.port !== state.port) {
|
|
670
|
+
throw new Error(`Backup ${manifest.backupId} belongs to port ${manifest.port}, not ${state.port}.`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async function sha256File(path) {
|
|
674
|
+
const digest = createHash("sha256");
|
|
675
|
+
digest.update(await readFile(path));
|
|
676
|
+
return digest.digest("hex");
|
|
677
|
+
}
|
|
678
|
+
async function volumeArchive(directory, mode, env, backupDir) {
|
|
679
|
+
const mount = `${backupDir}:/backup`;
|
|
680
|
+
await compose(directory, mode, [
|
|
681
|
+
"run",
|
|
682
|
+
"--rm",
|
|
683
|
+
"--no-deps",
|
|
684
|
+
"--user",
|
|
685
|
+
"0:0",
|
|
686
|
+
"-v",
|
|
687
|
+
mount,
|
|
688
|
+
"--entrypoint",
|
|
689
|
+
"sh",
|
|
690
|
+
"or3",
|
|
691
|
+
"-c",
|
|
692
|
+
"umask 077 && tar czf /backup/data.tgz -C /data . && tar tzf /backup/data.tgz >/dev/null"
|
|
693
|
+
], secretValues(env));
|
|
694
|
+
}
|
|
695
|
+
async function archiveExternalVolume(image, volume, backupDir) {
|
|
696
|
+
const result = await run("docker", [
|
|
697
|
+
"run",
|
|
698
|
+
"--rm",
|
|
699
|
+
"--user",
|
|
700
|
+
"0:0",
|
|
701
|
+
"-v",
|
|
702
|
+
`${volume}:/source:ro`,
|
|
703
|
+
"-v",
|
|
704
|
+
`${backupDir}:/backup`,
|
|
705
|
+
image,
|
|
706
|
+
"sh",
|
|
707
|
+
"-c",
|
|
708
|
+
"umask 077 && tar czf /backup/data.tgz -C /source . && tar tzf /backup/data.tgz >/dev/null"
|
|
709
|
+
]);
|
|
710
|
+
if (!result.ok)
|
|
711
|
+
throw new Error(`${result.command}
|
|
712
|
+
${result.stderr}`);
|
|
713
|
+
}
|
|
714
|
+
async function restoreAdoptionBackup(directory, mode, env, backupPath) {
|
|
715
|
+
await compose(directory, mode, [
|
|
716
|
+
"run",
|
|
717
|
+
"--rm",
|
|
718
|
+
"--no-deps",
|
|
719
|
+
"--user",
|
|
720
|
+
"0:0",
|
|
721
|
+
"-v",
|
|
722
|
+
`${backupPath}:/backup:ro`,
|
|
723
|
+
"--entrypoint",
|
|
724
|
+
"sh",
|
|
725
|
+
"or3",
|
|
726
|
+
"-c",
|
|
727
|
+
"find /data -mindepth 1 -delete && tar xzf /backup/data.tgz -C /data"
|
|
728
|
+
], secretValues(env));
|
|
729
|
+
}
|
|
730
|
+
async function createBackup(directory, state, env) {
|
|
731
|
+
await requireImageDigest(state.image, state.imageDigest, "Current deployment");
|
|
732
|
+
const backupId = id("backup");
|
|
733
|
+
const backupDir = backupDirectory(directory, backupId);
|
|
734
|
+
await mkdir(backupDir, { recursive: true, mode: 448 });
|
|
735
|
+
await chmod(backupDir, 448);
|
|
736
|
+
let stopAttempted = false;
|
|
737
|
+
try {
|
|
738
|
+
stopAttempted = true;
|
|
739
|
+
await stopProject(directory, state.mode);
|
|
740
|
+
await volumeArchive(directory, state.mode, env, backupDir);
|
|
741
|
+
await copySecure(deploymentPaths(directory).env, join(backupDir, "config.env"));
|
|
742
|
+
const manifest = {
|
|
743
|
+
schemaVersion: 1,
|
|
744
|
+
backupId,
|
|
745
|
+
createdAt: now(),
|
|
746
|
+
appVersion: state.appVersion,
|
|
747
|
+
image: state.image,
|
|
748
|
+
imageDigest: state.imageDigest,
|
|
749
|
+
dataSha256: await sha256File(join(backupDir, "data.tgz")),
|
|
750
|
+
configSha256: await sha256File(join(backupDir, "config.env")),
|
|
751
|
+
mode: state.mode,
|
|
752
|
+
domain: state.domain,
|
|
753
|
+
composeProject: state.composeProject,
|
|
754
|
+
volumeName: state.volumeName,
|
|
755
|
+
caddyDataVolume: state.caddyDataVolume,
|
|
756
|
+
caddyConfigVolume: state.caddyConfigVolume,
|
|
757
|
+
port: state.port
|
|
758
|
+
};
|
|
759
|
+
await writeSecure(join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
760
|
+
`);
|
|
761
|
+
return { backupId, backupDir, manifest };
|
|
762
|
+
} finally {
|
|
763
|
+
if (stopAttempted) {
|
|
764
|
+
try {
|
|
765
|
+
await startProject(directory, state.mode, env);
|
|
766
|
+
} catch (error) {
|
|
767
|
+
throw new Error(`Backup was created but OR3 could not restart: ${error instanceof Error ? error.message : String(error)}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
async function readManifest(backupPath) {
|
|
773
|
+
const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
|
|
774
|
+
if (manifest.schemaVersion !== 1 || !manifest.backupId || !isVersion(manifest.appVersion) || !manifest.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest.imageDigest)) {
|
|
775
|
+
throw new Error(`Invalid backup manifest at ${backupPath}.`);
|
|
776
|
+
}
|
|
777
|
+
const actual = await sha256File(join(backupPath, "data.tgz"));
|
|
778
|
+
if (actual !== manifest.dataSha256)
|
|
779
|
+
throw new Error(`Backup checksum mismatch for ${manifest.backupId}. Expected ${manifest.dataSha256}, got ${actual}.`);
|
|
780
|
+
if (manifest.configSha256) {
|
|
781
|
+
const configActual = await sha256File(join(backupPath, "config.env"));
|
|
782
|
+
if (configActual !== manifest.configSha256)
|
|
783
|
+
throw new Error(`Backup configuration checksum mismatch for ${manifest.backupId}.`);
|
|
784
|
+
}
|
|
785
|
+
return manifest;
|
|
786
|
+
}
|
|
787
|
+
async function restoreBackupData(directory, state, env, backupPath) {
|
|
788
|
+
const manifest = await readManifest(backupPath);
|
|
789
|
+
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
790
|
+
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
791
|
+
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
792
|
+
}
|
|
793
|
+
assertBackupMatchesDeployment(manifest, backupEnv, state, env);
|
|
794
|
+
if (manifest.imageDigest)
|
|
795
|
+
await requireImageDigest(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
796
|
+
let started = false;
|
|
797
|
+
let stopAttempted = false;
|
|
798
|
+
try {
|
|
799
|
+
stopAttempted = true;
|
|
800
|
+
await stopProject(directory, state.mode);
|
|
801
|
+
await copySecure(join(backupPath, "config.env"), deploymentPaths(directory).env);
|
|
802
|
+
await compose(directory, state.mode, [
|
|
803
|
+
"run",
|
|
804
|
+
"--rm",
|
|
805
|
+
"--no-deps",
|
|
806
|
+
"--user",
|
|
807
|
+
"0:0",
|
|
808
|
+
"-v",
|
|
809
|
+
`${backupPath}:/backup:ro`,
|
|
810
|
+
"--entrypoint",
|
|
811
|
+
"sh",
|
|
812
|
+
"or3",
|
|
813
|
+
"-c",
|
|
814
|
+
"find /data -mindepth 1 -delete && tar xzf /backup/data.tgz -C /data"
|
|
815
|
+
], secretValues(backupEnv));
|
|
816
|
+
const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
817
|
+
await startProject(directory, state.mode, restoredEnv);
|
|
818
|
+
started = true;
|
|
819
|
+
} catch (error) {
|
|
820
|
+
let restartError = "";
|
|
821
|
+
if (stopAttempted && !started) {
|
|
822
|
+
try {
|
|
823
|
+
const recoveryEnv = parseEnv(await readText(deploymentPaths(directory).env));
|
|
824
|
+
await startProject(directory, state.mode, recoveryEnv);
|
|
825
|
+
} catch (recovery) {
|
|
826
|
+
restartError = ` OR3 could not be restarted: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
throw new Error(`Restore failed for ${manifest.backupId}: ${error instanceof Error ? error.message : String(error)}${restartError}`);
|
|
830
|
+
}
|
|
831
|
+
return manifest;
|
|
832
|
+
}
|
|
833
|
+
function stateFromEnv(directory, env, mode, operation, digest) {
|
|
834
|
+
return {
|
|
835
|
+
schemaVersion: 1,
|
|
836
|
+
mode,
|
|
837
|
+
composeProject: env.OR3_COMPOSE_PROJECT,
|
|
838
|
+
volumeName: env.OR3_VOLUME_NAME,
|
|
839
|
+
caddyDataVolume: mode === "public" ? env.OR3_CADDY_DATA_VOLUME : undefined,
|
|
840
|
+
caddyConfigVolume: mode === "public" ? env.OR3_CADDY_CONFIG_VOLUME : undefined,
|
|
841
|
+
appVersion: env.OR3_VERSION,
|
|
842
|
+
image: env.OR3_IMAGE,
|
|
843
|
+
imageDigest: digest,
|
|
844
|
+
domain: mode === "public" ? env.OR3_PUBLIC_DOMAIN : undefined,
|
|
845
|
+
port: Number(env.OR3_PORT),
|
|
846
|
+
lastSuccessfulOperation: operation,
|
|
847
|
+
updatedAt: now()
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function help() {
|
|
851
|
+
console.log(`OR3 Cloud — managed container installer and operator
|
|
852
|
+
|
|
853
|
+
Usage:
|
|
854
|
+
npx @or3/cloud init [directory] --local
|
|
855
|
+
npx @or3/cloud init [directory] --public --domain <hostname>
|
|
856
|
+
npx @or3/cloud update [--to <exact-version>]
|
|
857
|
+
npx @or3/cloud backup
|
|
858
|
+
npx @or3/cloud restore <backup-id-or-path> --yes
|
|
859
|
+
npx @or3/cloud rollback --yes
|
|
860
|
+
npx @or3/cloud doctor
|
|
861
|
+
npx @or3/cloud recover
|
|
862
|
+
npx @or3/cloud adopt --from <v1-directory> [directory]
|
|
863
|
+
|
|
864
|
+
Options:
|
|
865
|
+
--admin-email <email> Administrator email for first login
|
|
866
|
+
--admin-password <password> Explicit password (prefer --admin-password-file)
|
|
867
|
+
--admin-password-file <path> Read the bootstrap password without shell history
|
|
868
|
+
--port <port> Local OR3 port (default: 3000)
|
|
869
|
+
--yes Confirm a destructive restore or rollback
|
|
870
|
+
--help Show this help
|
|
871
|
+
--version Show the Cloud package version
|
|
872
|
+
|
|
873
|
+
The supported profile is Basic Auth + SQLite + filesystem storage.
|
|
874
|
+
The installer never changes firewall, DNS, Cloudflare, or Tailscale settings.`);
|
|
875
|
+
}
|
|
876
|
+
async function initCommand(positionals, flags) {
|
|
877
|
+
const hasLocal = boolFlag(flags, "local");
|
|
878
|
+
const hasPublic = boolFlag(flags, "public");
|
|
879
|
+
if (hasLocal === hasPublic)
|
|
880
|
+
throw new Error("Choose exactly one of --local or --public.");
|
|
881
|
+
const mode = hasPublic ? "public" : "local";
|
|
882
|
+
const directory = resolve(process.cwd(), positionals[0] ?? "or3-cloud");
|
|
883
|
+
if (await fileExists(join(directory, ".or3-cloud", "state.json"))) {
|
|
884
|
+
throw new Error(`${directory} is already a managed deployment. Use update, doctor, or adopt.`);
|
|
885
|
+
}
|
|
886
|
+
await readDirectoryEmpty(directory);
|
|
887
|
+
const domain = mode === "public" ? requireStringFlag(flags, "domain") : undefined;
|
|
888
|
+
if (domain) {
|
|
889
|
+
const address = await checkPublicPrerequisites(domain);
|
|
890
|
+
console.log(`DNS: ${domain} → ${address}`);
|
|
891
|
+
}
|
|
892
|
+
const port = Number(stringFlag(flags, "port") ?? DEFAULT_PORT);
|
|
893
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
894
|
+
throw new Error("--port must be a valid TCP port.");
|
|
895
|
+
if (mode === "public" && [80, 443].includes(port))
|
|
896
|
+
throw new Error("Public mode reserves ports 80 and 443 for Caddy; choose another --port for OR3.");
|
|
897
|
+
if (!await portAvailable(port))
|
|
898
|
+
throw new Error(`Port ${port} is already in use. Choose another port with --port.`);
|
|
899
|
+
if (mode === "public") {
|
|
900
|
+
for (const publicPort of [80, 443]) {
|
|
901
|
+
if (!await portAvailable(publicPort))
|
|
902
|
+
throw new Error(`Public port ${publicPort} is already in use. Stop the conflicting service before starting Caddy.`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
await ensureDocker();
|
|
906
|
+
const names = composeProjectNames(directory);
|
|
907
|
+
for (const volume of [names.volume, ...mode === "public" ? [names.caddyData, names.caddyConfig] : []]) {
|
|
908
|
+
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
909
|
+
if (existing.ok)
|
|
910
|
+
throw new Error(`Docker volume ${volume} already exists. Choose a new directory or inspect it before initializing.`);
|
|
911
|
+
}
|
|
912
|
+
const email = await resolveAdminEmail(flags);
|
|
913
|
+
const password = await readPassword(flags);
|
|
914
|
+
const version = PACKAGE_VERSION;
|
|
915
|
+
const image = imageFor(version);
|
|
916
|
+
await pullImage(image);
|
|
917
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
918
|
+
await chmod(directory, 448);
|
|
919
|
+
await mkdir(deploymentPaths(directory).operations, { recursive: true, mode: 448 });
|
|
920
|
+
await mkdir(deploymentPaths(directory).backups, { recursive: true, mode: 448 });
|
|
921
|
+
await copyAssets(directory, mode);
|
|
922
|
+
const env = buildEnv({ mode, version, directory, email, password, domain, port });
|
|
923
|
+
await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
|
|
924
|
+
await writeSecure(join(directory, ".or3-initial-credentials"), `email=${email}
|
|
925
|
+
password=${password}
|
|
926
|
+
`);
|
|
927
|
+
const digest = await imageDigest(image);
|
|
928
|
+
const state = stateFromEnv(directory, env, mode, "init", digest);
|
|
929
|
+
await markPending(directory, state, {
|
|
930
|
+
id: id("init"),
|
|
931
|
+
operation: "init",
|
|
932
|
+
startedAt: now(),
|
|
933
|
+
message: "Initializing the managed deployment"
|
|
934
|
+
});
|
|
935
|
+
try {
|
|
936
|
+
await startProject(directory, mode, env);
|
|
937
|
+
await clearPending(directory, state);
|
|
938
|
+
console.log(`
|
|
939
|
+
OR3 Cloud ${version} is running at ${mode === "public" ? `https://${domain}` : `http://127.0.0.1:${port}`}`);
|
|
940
|
+
console.log(`Credentials were written to ${join(directory, ".or3-initial-credentials")} (mode 0600). Save them, then remove that file.`);
|
|
941
|
+
console.log(`
|
|
942
|
+
Check: cd ${quote(directory)} && npx @or3/cloud doctor`);
|
|
943
|
+
} catch (error) {
|
|
944
|
+
state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(env));
|
|
945
|
+
await writeState(directory, state);
|
|
946
|
+
await compose(directory, mode, ["down"]).catch(() => {
|
|
947
|
+
return;
|
|
948
|
+
});
|
|
949
|
+
throw new Error(`${state.lastError}
|
|
950
|
+
The new deployment files were preserved at ${directory}.`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async function loadManaged(directory = process.cwd()) {
|
|
954
|
+
const resolved = resolve(directory);
|
|
955
|
+
const state = await readState(resolved);
|
|
956
|
+
const env = parseEnv(await readText(deploymentPaths(resolved).env));
|
|
957
|
+
assertDeploymentIdentity(state, env);
|
|
958
|
+
return { directory: resolved, state, env };
|
|
959
|
+
}
|
|
960
|
+
function operationToStateOperation(operation, fallback) {
|
|
961
|
+
if (operation === "init" || operation === "update" || operation === "adopt")
|
|
962
|
+
return operation;
|
|
963
|
+
return fallback;
|
|
964
|
+
}
|
|
965
|
+
async function commitRecoveredState(directory, previous, next) {
|
|
966
|
+
await removeOperationRecord(directory, previous.incompleteOperation?.id);
|
|
967
|
+
await writeState(directory, next);
|
|
968
|
+
}
|
|
969
|
+
async function recoverCommand(directory) {
|
|
970
|
+
await ensureDocker();
|
|
971
|
+
const loaded = await loadManaged(directory);
|
|
972
|
+
const pending = loaded.state.incompleteOperation;
|
|
973
|
+
if (!pending) {
|
|
974
|
+
console.log("No incomplete OR3 Cloud operation is recorded.");
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
try {
|
|
978
|
+
if (pending.operation === "restore" || pending.operation === "rollback") {
|
|
979
|
+
if (!pending.backupId)
|
|
980
|
+
throw new Error(`The incomplete ${pending.operation} has no recorded backup ID.`);
|
|
981
|
+
const backupPath = await resolveBackup(loaded.directory, pending.backupId);
|
|
982
|
+
const manifest = await readManifest(backupPath);
|
|
983
|
+
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
984
|
+
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
985
|
+
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
986
|
+
}
|
|
987
|
+
if (pending.targetVersion && pending.targetVersion !== manifest.appVersion || pending.targetImage && pending.targetImage !== manifest.image || pending.targetImageDigest && pending.targetImageDigest !== manifest.imageDigest) {
|
|
988
|
+
throw new Error(`Backup ${manifest.backupId} does not match the recorded ${pending.operation} target.`);
|
|
989
|
+
}
|
|
990
|
+
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
991
|
+
await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
992
|
+
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
|
|
993
|
+
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
994
|
+
const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
|
|
995
|
+
restored.lastError = undefined;
|
|
996
|
+
await commitRecoveredState(loaded.directory, loaded.state, restored);
|
|
997
|
+
console.log(`Recovered the incomplete ${pending.operation} operation from backup ${manifest.backupId}. OR3 ${restored.appVersion} is deeply healthy.`);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (pending.operation === "update") {
|
|
1001
|
+
const oldDeployment = loaded.env.OR3_VERSION === loaded.state.appVersion && loaded.env.OR3_IMAGE === loaded.state.image;
|
|
1002
|
+
if (oldDeployment) {
|
|
1003
|
+
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
1004
|
+
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1005
|
+
const recovered2 = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(loaded.env.OR3_IMAGE));
|
|
1006
|
+
recovered2.rollback = loaded.state.rollback;
|
|
1007
|
+
recovered2.lastError = undefined;
|
|
1008
|
+
await commitRecoveredState(loaded.directory, loaded.state, recovered2);
|
|
1009
|
+
console.log(`Recovered the incomplete update before the replacement was applied. OR3 ${recovered2.appVersion} is deeply healthy.`);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (!pending.backupId || loaded.env.OR3_VERSION !== pending.targetVersion || loaded.env.OR3_IMAGE !== pending.targetImage) {
|
|
1013
|
+
throw new Error("The incomplete update has an unknown .env version/image. Refusing to guess which image or data should be live.");
|
|
1014
|
+
}
|
|
1015
|
+
const backupPath = await resolveBackup(loaded.directory, pending.backupId);
|
|
1016
|
+
const manifest = await readManifest(backupPath);
|
|
1017
|
+
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
1018
|
+
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1019
|
+
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1020
|
+
}
|
|
1021
|
+
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1022
|
+
try {
|
|
1023
|
+
if (pending.targetImageDigest)
|
|
1024
|
+
await pullAndRequireImage(pending.targetImage, pending.targetImageDigest, `OR3 ${pending.targetVersion}`);
|
|
1025
|
+
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1026
|
+
} catch (replacementError) {
|
|
1027
|
+
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
|
|
1028
|
+
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1029
|
+
const restored = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, loaded.state.lastSuccessfulOperation, await imageDigest(restoredEnv.OR3_IMAGE));
|
|
1030
|
+
restored.rollback = loaded.state.rollback;
|
|
1031
|
+
restored.lastError = `Interrupted update was restored: ${redact(replacementError instanceof Error ? replacementError.message : String(replacementError), secretValues(restoredEnv))}`;
|
|
1032
|
+
await commitRecoveredState(loaded.directory, loaded.state, restored);
|
|
1033
|
+
console.log(`Recovered the incomplete update by restoring backup ${manifest.backupId}. OR3 ${restored.appVersion} is deeply healthy.`);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const updated = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, "update", await imageDigest(loaded.env.OR3_IMAGE));
|
|
1037
|
+
updated.rollback = {
|
|
1038
|
+
appVersion: loaded.state.appVersion,
|
|
1039
|
+
image: loaded.state.image,
|
|
1040
|
+
imageDigest: loaded.state.imageDigest,
|
|
1041
|
+
backupId: pending.backupId,
|
|
1042
|
+
createdAt: now()
|
|
1043
|
+
};
|
|
1044
|
+
updated.lastError = undefined;
|
|
1045
|
+
await commitRecoveredState(loaded.directory, loaded.state, updated);
|
|
1046
|
+
console.log(`Recovered the incomplete update. OR3 ${updated.appVersion} is deeply healthy.`);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (pending.operation === "adopt") {
|
|
1050
|
+
if (!pending.backupId)
|
|
1051
|
+
throw new Error("The incomplete adoption has no source backup ID. Refusing to claim that data was transferred.");
|
|
1052
|
+
const sourceBackupPath = await resolveBackup(loaded.directory, pending.backupId);
|
|
1053
|
+
const sourceManifest = await readManifest(sourceBackupPath);
|
|
1054
|
+
if (sourceManifest.mode !== loaded.state.mode || sourceManifest.appVersion !== loaded.state.appVersion || sourceManifest.image !== loaded.state.image || sourceManifest.imageDigest !== loaded.state.imageDigest) {
|
|
1055
|
+
throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
|
|
1056
|
+
}
|
|
1057
|
+
await stopProject(loaded.directory, loaded.state.mode);
|
|
1058
|
+
await restoreAdoptionBackup(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
|
|
1059
|
+
}
|
|
1060
|
+
await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
|
|
1061
|
+
await startProject(loaded.directory, loaded.state.mode, loaded.env);
|
|
1062
|
+
const digest = await imageDigest(loaded.env.OR3_IMAGE);
|
|
1063
|
+
const recovered = stateFromEnv(loaded.directory, loaded.env, loaded.state.mode, operationToStateOperation(pending.operation, loaded.state.lastSuccessfulOperation), digest);
|
|
1064
|
+
recovered.rollback = loaded.state.rollback;
|
|
1065
|
+
recovered.lastError = undefined;
|
|
1066
|
+
await commitRecoveredState(loaded.directory, loaded.state, recovered);
|
|
1067
|
+
console.log(`Recovered the incomplete ${pending.operation} operation. OR3 ${recovered.appVersion} is deeply healthy.`);
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
|
|
1070
|
+
if (pending.operation === "adopt" && pending.sourceDirectory) {
|
|
1071
|
+
try {
|
|
1072
|
+
await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
|
|
1073
|
+
return;
|
|
1074
|
+
});
|
|
1075
|
+
const sourceEnv = parseEnv(await readText(join(pending.sourceDirectory, ".env")));
|
|
1076
|
+
const sourceFiles = ["-f", join(pending.sourceDirectory, "compose.yaml")];
|
|
1077
|
+
const sourceModeValue = sourceMode(pending.sourceDirectory, sourceEnv);
|
|
1078
|
+
if (sourceModeValue === "public")
|
|
1079
|
+
sourceFiles.push("-f", join(pending.sourceDirectory, "compose.public.yaml"));
|
|
1080
|
+
await restartSource(pending.sourceDirectory, sourceFiles, secretValues(sourceEnv));
|
|
1081
|
+
} catch (recovery) {
|
|
1082
|
+
loaded.state.lastError += ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
await writeState(loaded.directory, loaded.state);
|
|
1086
|
+
throw new Error(`Recovery could not safely complete ${pending.operation}. Keep the operation record and run doctor. ${loaded.state.lastError}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
function assertNoPending(state) {
|
|
1090
|
+
if (state.incompleteOperation) {
|
|
1091
|
+
throw new Error(`An incomplete ${state.incompleteOperation.operation} is recorded. Run "npx @or3/cloud recover" before starting another operation.`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
async function backupCommand(directory) {
|
|
1095
|
+
await ensureDocker();
|
|
1096
|
+
const loaded = await loadManaged(directory);
|
|
1097
|
+
assertNoPending(loaded.state);
|
|
1098
|
+
const pending = {
|
|
1099
|
+
id: id("backup"),
|
|
1100
|
+
operation: "backup",
|
|
1101
|
+
startedAt: now(),
|
|
1102
|
+
message: "Creating a stopped-volume backup"
|
|
1103
|
+
};
|
|
1104
|
+
await markPending(loaded.directory, loaded.state, pending);
|
|
1105
|
+
try {
|
|
1106
|
+
const result = await createBackup(loaded.directory, loaded.state, loaded.env);
|
|
1107
|
+
await clearPending(loaded.directory, loaded.state);
|
|
1108
|
+
console.log(`Backup ${result.backupId} created at ${result.backupDir}`);
|
|
1109
|
+
console.log(`SHA-256: ${result.manifest.dataSha256}`);
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
|
|
1112
|
+
await writeState(loaded.directory, loaded.state);
|
|
1113
|
+
throw error;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
async function updateCommand(directory, flags) {
|
|
1117
|
+
await ensureDocker();
|
|
1118
|
+
const loaded = await loadManaged(directory);
|
|
1119
|
+
const { state, env } = loaded;
|
|
1120
|
+
assertNoPending(state);
|
|
1121
|
+
await waitForDeepHealth(loaded.directory, state.mode, secretValues(env));
|
|
1122
|
+
const targetVersion = stringFlag(flags, "to")?.trim() ?? PACKAGE_VERSION;
|
|
1123
|
+
if (!isVersion(targetVersion))
|
|
1124
|
+
throw new Error("--to must be a complete semantic version such as 0.1.12.");
|
|
1125
|
+
if (targetVersion === state.appVersion)
|
|
1126
|
+
throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
|
|
1127
|
+
const targetImage = imageFor(targetVersion);
|
|
1128
|
+
const targetDigest = await pullImage(targetImage);
|
|
1129
|
+
const oldEnv = { ...env };
|
|
1130
|
+
const pending = {
|
|
1131
|
+
id: id("update"),
|
|
1132
|
+
operation: "update",
|
|
1133
|
+
startedAt: now(),
|
|
1134
|
+
message: `Updating from ${state.appVersion} to ${targetVersion}`,
|
|
1135
|
+
targetVersion,
|
|
1136
|
+
targetImage,
|
|
1137
|
+
targetImageDigest: targetDigest
|
|
1138
|
+
};
|
|
1139
|
+
await markPending(loaded.directory, state, pending);
|
|
1140
|
+
try {
|
|
1141
|
+
const backup = await createBackup(loaded.directory, state, env);
|
|
1142
|
+
await updatePending(loaded.directory, state, { backupId: backup.backupId });
|
|
1143
|
+
const nextEnv = { ...env, OR3_VERSION: targetVersion, OR3_IMAGE: targetImage };
|
|
1144
|
+
await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
|
|
1145
|
+
try {
|
|
1146
|
+
await startProject(loaded.directory, state.mode, nextEnv);
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(oldEnv));
|
|
1149
|
+
await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
|
|
1150
|
+
const oldDigest = await imageDigest(state.image);
|
|
1151
|
+
state.imageDigest = oldDigest;
|
|
1152
|
+
state.lastError = `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`;
|
|
1153
|
+
const operationId = pending.id;
|
|
1154
|
+
delete state.incompleteOperation;
|
|
1155
|
+
await removeOperationRecord(loaded.directory, operationId);
|
|
1156
|
+
await writeState(loaded.directory, state);
|
|
1157
|
+
throw new Error(state.lastError);
|
|
1158
|
+
}
|
|
1159
|
+
const digest = await requireImageDigest(targetImage, targetDigest, `OR3 ${targetVersion}`);
|
|
1160
|
+
state.rollback = {
|
|
1161
|
+
appVersion: state.appVersion,
|
|
1162
|
+
image: state.image,
|
|
1163
|
+
imageDigest: state.imageDigest,
|
|
1164
|
+
backupId: backup.backupId,
|
|
1165
|
+
createdAt: now()
|
|
1166
|
+
};
|
|
1167
|
+
state.appVersion = targetVersion;
|
|
1168
|
+
state.image = targetImage;
|
|
1169
|
+
state.imageDigest = digest;
|
|
1170
|
+
state.lastSuccessfulOperation = "update";
|
|
1171
|
+
state.lastError = undefined;
|
|
1172
|
+
await clearPending(loaded.directory, state);
|
|
1173
|
+
console.log(`OR3 updated to ${targetVersion}. Image digest: ${digest}`);
|
|
1174
|
+
console.log(`Rollback point: ${backup.backupId}. Keep it until login, chat, and file checks pass.`);
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
if (state.incompleteOperation) {
|
|
1177
|
+
state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(env));
|
|
1178
|
+
await writeState(loaded.directory, state);
|
|
1179
|
+
}
|
|
1180
|
+
throw error;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
async function resolveBackup(directory, value) {
|
|
1184
|
+
const candidate = isAbsolute(value) ? value : backupDirectory(directory, value);
|
|
1185
|
+
if (!await fileExists(join(candidate, "manifest.json")))
|
|
1186
|
+
throw new Error(`Backup ${value} was not found in ${deploymentPaths(directory).backups}.`);
|
|
1187
|
+
return resolve(candidate);
|
|
1188
|
+
}
|
|
1189
|
+
async function restoreCommand(directory, flags, positionals) {
|
|
1190
|
+
if (!boolFlag(flags, "yes"))
|
|
1191
|
+
throw new Error("Restore replaces live data. Re-run with --yes after confirming the backup and data-loss boundary.");
|
|
1192
|
+
await ensureDocker();
|
|
1193
|
+
const loaded = await loadManaged(directory);
|
|
1194
|
+
assertNoPending(loaded.state);
|
|
1195
|
+
const backupValue = positionals[0];
|
|
1196
|
+
if (!backupValue)
|
|
1197
|
+
throw new Error("restore requires a backup ID or path.");
|
|
1198
|
+
const backupPath = await resolveBackup(loaded.directory, backupValue);
|
|
1199
|
+
const manifest = await readManifest(backupPath);
|
|
1200
|
+
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
1201
|
+
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1202
|
+
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1203
|
+
}
|
|
1204
|
+
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1205
|
+
if (manifest.imageDigest)
|
|
1206
|
+
await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
|
|
1207
|
+
const pending = {
|
|
1208
|
+
id: id("restore"),
|
|
1209
|
+
operation: "restore",
|
|
1210
|
+
startedAt: now(),
|
|
1211
|
+
message: `Restoring backup ${manifest.backupId}`,
|
|
1212
|
+
backupId: manifest.backupId,
|
|
1213
|
+
targetVersion: manifest.appVersion,
|
|
1214
|
+
targetImage: manifest.image,
|
|
1215
|
+
targetImageDigest: manifest.imageDigest
|
|
1216
|
+
};
|
|
1217
|
+
await markPending(loaded.directory, loaded.state, pending);
|
|
1218
|
+
try {
|
|
1219
|
+
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
|
|
1220
|
+
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1221
|
+
const digest = await imageDigest(restoredEnv.OR3_IMAGE);
|
|
1222
|
+
const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", digest);
|
|
1223
|
+
nextState.lastError = undefined;
|
|
1224
|
+
await removeOperationRecord(loaded.directory, pending.id);
|
|
1225
|
+
await writeState(loaded.directory, nextState);
|
|
1226
|
+
console.log(`Restored ${manifest.backupId}. Verify sign-in, a conversation, and a previously uploaded file.`);
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
|
|
1229
|
+
await writeState(loaded.directory, loaded.state);
|
|
1230
|
+
throw error;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
async function rollbackCommand(directory, flags) {
|
|
1234
|
+
if (!boolFlag(flags, "yes"))
|
|
1235
|
+
throw new Error("Rollback restores the previous image and data snapshot. Re-run with --yes after confirming post-update data loss.");
|
|
1236
|
+
await ensureDocker();
|
|
1237
|
+
const loaded = await loadManaged(directory);
|
|
1238
|
+
assertNoPending(loaded.state);
|
|
1239
|
+
const point = loaded.state.rollback;
|
|
1240
|
+
if (!point)
|
|
1241
|
+
throw new Error("No immediate rollback point is recorded for this deployment.");
|
|
1242
|
+
const backupPath = await resolveBackup(loaded.directory, point.backupId);
|
|
1243
|
+
const manifest = await readManifest(backupPath);
|
|
1244
|
+
if (manifest.appVersion !== point.appVersion || manifest.image !== point.image || manifest.imageDigest !== point.imageDigest) {
|
|
1245
|
+
throw new Error(`Rollback point ${point.backupId} no longer matches its recorded image/version. Refusing to mutate the deployment.`);
|
|
1246
|
+
}
|
|
1247
|
+
const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
|
|
1248
|
+
if (backupEnv.OR3_VERSION !== manifest.appVersion || backupEnv.OR3_IMAGE !== manifest.image) {
|
|
1249
|
+
throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
|
|
1250
|
+
}
|
|
1251
|
+
assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
|
|
1252
|
+
await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
|
|
1253
|
+
const pending = {
|
|
1254
|
+
id: id("rollback"),
|
|
1255
|
+
operation: "rollback",
|
|
1256
|
+
startedAt: now(),
|
|
1257
|
+
message: `Rolling back to ${point.appVersion}`,
|
|
1258
|
+
backupId: point.backupId,
|
|
1259
|
+
targetVersion: point.appVersion,
|
|
1260
|
+
targetImage: point.image,
|
|
1261
|
+
targetImageDigest: point.imageDigest
|
|
1262
|
+
};
|
|
1263
|
+
await markPending(loaded.directory, loaded.state, pending);
|
|
1264
|
+
try {
|
|
1265
|
+
await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
|
|
1266
|
+
const restoredEnv = parseEnv(await readText(deploymentPaths(loaded.directory).env));
|
|
1267
|
+
const nextState = stateFromEnv(loaded.directory, restoredEnv, loaded.state.mode, "restore", await imageDigest(restoredEnv.OR3_IMAGE));
|
|
1268
|
+
nextState.rollback = undefined;
|
|
1269
|
+
await removeOperationRecord(loaded.directory, pending.id);
|
|
1270
|
+
await writeState(loaded.directory, nextState);
|
|
1271
|
+
console.log(`Rolled back to OR3 ${nextState.appVersion}. Verify sign-in, chat, and file access.`);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
|
|
1274
|
+
await writeState(loaded.directory, loaded.state);
|
|
1275
|
+
throw error;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
async function doctorCommand(directory) {
|
|
1279
|
+
const resolved = resolve(directory);
|
|
1280
|
+
const paths = deploymentPaths(resolved);
|
|
1281
|
+
const failures = [];
|
|
1282
|
+
let state;
|
|
1283
|
+
let env = {};
|
|
1284
|
+
let dockerReady = false;
|
|
1285
|
+
console.log(`OR3 Cloud doctor: ${resolved}`);
|
|
1286
|
+
try {
|
|
1287
|
+
await ensureDocker();
|
|
1288
|
+
dockerReady = true;
|
|
1289
|
+
console.log("✓ Docker Engine and Compose v2 are available");
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
1292
|
+
console.log(`✗ Docker preflight failed`);
|
|
1293
|
+
}
|
|
1294
|
+
try {
|
|
1295
|
+
state = await readState(resolved);
|
|
1296
|
+
env = parseEnv(await readText(paths.env));
|
|
1297
|
+
console.log(`✓ Managed state: OR3 ${state.appVersion} (${state.imageDigest})`);
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
1300
|
+
console.log("✗ Managed state or .env is missing/invalid");
|
|
1301
|
+
}
|
|
1302
|
+
for (const file of [paths.env, paths.state]) {
|
|
1303
|
+
if (!existsSync(file))
|
|
1304
|
+
continue;
|
|
1305
|
+
const mode = (await stat(file)).mode & 511;
|
|
1306
|
+
if (mode !== 384) {
|
|
1307
|
+
failures.push(`${file} must be mode 0600 (currently ${mode.toString(8)}).`);
|
|
1308
|
+
console.log(`✗ Permissions: ${file} is ${mode.toString(8)}`);
|
|
1309
|
+
} else {
|
|
1310
|
+
console.log(`✓ Permissions: ${basename(file)} is 0600`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (state) {
|
|
1314
|
+
try {
|
|
1315
|
+
if (env.OR3_VERSION !== state.appVersion || env.OR3_IMAGE !== state.image || env.OR3_COMPOSE_PROJECT !== state.composeProject || env.OR3_VOLUME_NAME !== state.volumeName || Number(env.OR3_PORT) !== state.port || env.OR3_PUBLIC_DOMAIN !== (state.domain ?? "localhost") || state.mode === "public" && (env.OR3_CADDY_DATA_VOLUME !== state.caddyDataVolume || env.OR3_CADDY_CONFIG_VOLUME !== state.caddyConfigVolume)) {
|
|
1316
|
+
throw new Error("Managed state does not match the deployment .env (version, image, or data volume).");
|
|
1317
|
+
}
|
|
1318
|
+
const actualDigest = await imageDigest(state.image);
|
|
1319
|
+
if (actualDigest !== state.imageDigest) {
|
|
1320
|
+
throw new Error(`Managed image digest differs from state. Expected ${state.imageDigest}, found ${actualDigest}.`);
|
|
1321
|
+
}
|
|
1322
|
+
const config = await resolvedComposeConfig(resolved, state.mode);
|
|
1323
|
+
if (!checkResolvedLoopbackBinding(config, state.port))
|
|
1324
|
+
throw new Error("Resolved Compose configuration does not bind OR3 only to 127.0.0.1.");
|
|
1325
|
+
console.log("✓ OR3 port binding is loopback-only");
|
|
1326
|
+
console.log("✓ Compose configuration is valid");
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
1329
|
+
console.log("✗ Compose configuration or port binding failed");
|
|
1330
|
+
}
|
|
1331
|
+
if (state.incompleteOperation) {
|
|
1332
|
+
failures.push(`Incomplete ${state.incompleteOperation.operation} ${state.incompleteOperation.id} is recorded.`);
|
|
1333
|
+
console.log(`✗ Incomplete operation: ${state.incompleteOperation.operation} (${state.incompleteOperation.id}). Run "npx @or3/cloud recover" after reviewing the diagnostics.`);
|
|
1334
|
+
}
|
|
1335
|
+
if (dockerReady) {
|
|
1336
|
+
try {
|
|
1337
|
+
await waitForDeepHealth(resolved, state.mode, secretValues(env));
|
|
1338
|
+
console.log("✓ Deep health is passing");
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
1341
|
+
console.log("✗ Deep health is not passing");
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (state.mode === "public" && state.domain) {
|
|
1345
|
+
try {
|
|
1346
|
+
const caddy = await run("docker", composeArgs(resolved, state.mode, ["ps", "--services", "--status", "running"]), resolved);
|
|
1347
|
+
if (!caddy.ok || !caddy.stdout.split(/\s+/).includes("caddy"))
|
|
1348
|
+
throw new Error("Caddy is not running.");
|
|
1349
|
+
console.log("✓ Caddy service is running");
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
1352
|
+
console.log("✗ Caddy service is not running");
|
|
1353
|
+
}
|
|
1354
|
+
try {
|
|
1355
|
+
const address = await checkPublicPrerequisites(state.domain);
|
|
1356
|
+
console.log(`✓ DNS: ${state.domain} → ${address}`);
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
1359
|
+
console.log(`✗ DNS: ${state.domain} does not resolve`);
|
|
1360
|
+
}
|
|
1361
|
+
for (const port of [80, 443]) {
|
|
1362
|
+
console.log(await portAvailable(port) ? `⚠ Port ${port} is not listening (Caddy may be stopped)` : `✓ Port ${port} is in use by a local service; verify it is Caddy`);
|
|
1363
|
+
}
|
|
1364
|
+
console.log("ℹ Firewall: allow TCP 80/443 (and optional UDP 443) in your existing nftables or UFW rules; OR3 did not modify them.");
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (failures.length) {
|
|
1368
|
+
throw new Error(`Doctor found ${failures.length} issue(s). Run: ${diagnostics(resolved, state?.mode ?? "local")}`);
|
|
1369
|
+
}
|
|
1370
|
+
console.log("OR3 Cloud doctor passed.");
|
|
1371
|
+
}
|
|
1372
|
+
async function readSourceVolume(sourceDirectory) {
|
|
1373
|
+
const composeResult = await run("docker", [
|
|
1374
|
+
"compose",
|
|
1375
|
+
"--project-directory",
|
|
1376
|
+
sourceDirectory,
|
|
1377
|
+
"--env-file",
|
|
1378
|
+
join(sourceDirectory, ".env"),
|
|
1379
|
+
"-f",
|
|
1380
|
+
join(sourceDirectory, "compose.yaml"),
|
|
1381
|
+
"ps",
|
|
1382
|
+
"-aq",
|
|
1383
|
+
"or3"
|
|
1384
|
+
], sourceDirectory);
|
|
1385
|
+
if (composeResult.ok && composeResult.stdout.trim()) {
|
|
1386
|
+
const container = composeResult.stdout.trim().split(/\s+/)[0];
|
|
1387
|
+
const inspect = await run("docker", ["inspect", "--format", '{{index .Config.Labels "com.docker.compose.project"}}', container]);
|
|
1388
|
+
const project = inspect.ok ? inspect.stdout.trim() : sanitizeName(basename(sourceDirectory));
|
|
1389
|
+
const volumes2 = await run("docker", ["volume", "ls", "-q", "--filter", `label=com.docker.compose.project=${project}`, "--filter", "label=com.docker.compose.volume=or3-data"]);
|
|
1390
|
+
if (volumes2.ok && volumes2.stdout.trim())
|
|
1391
|
+
return volumes2.stdout.trim().split(/\s+/)[0];
|
|
1392
|
+
}
|
|
1393
|
+
const fallbackProject = sanitizeName(basename(sourceDirectory));
|
|
1394
|
+
const volumes = await run("docker", ["volume", "ls", "-q", "--filter", `label=com.docker.compose.project=${fallbackProject}`, "--filter", "label=com.docker.compose.volume=or3-data"]);
|
|
1395
|
+
if (!volumes.ok || !volumes.stdout.trim())
|
|
1396
|
+
throw new Error(`Could not resolve the V1 or3-data volume for ${sourceDirectory}. Keep the source project unchanged and inspect Docker volumes manually.`);
|
|
1397
|
+
return volumes.stdout.trim().split(/\s+/)[0];
|
|
1398
|
+
}
|
|
1399
|
+
function sourceMode(sourceDirectory, sourceEnv) {
|
|
1400
|
+
const publicDeployment = sourceEnv.OR3_PUBLIC_DOMAIN && sourceEnv.OR3_PUBLIC_DOMAIN !== "localhost" && sourceEnv.OR3_FORCE_HTTPS !== "false";
|
|
1401
|
+
return publicDeployment && existsSync(join(sourceDirectory, "compose.public.yaml")) ? "public" : "local";
|
|
1402
|
+
}
|
|
1403
|
+
function sourceComposeArgs(sourceDirectory, sourceComposeFiles) {
|
|
1404
|
+
return [
|
|
1405
|
+
"compose",
|
|
1406
|
+
"--project-directory",
|
|
1407
|
+
sourceDirectory,
|
|
1408
|
+
"--env-file",
|
|
1409
|
+
join(sourceDirectory, ".env"),
|
|
1410
|
+
...sourceComposeFiles
|
|
1411
|
+
];
|
|
1412
|
+
}
|
|
1413
|
+
async function restartSource(sourceDirectory, sourceComposeFiles, secrets) {
|
|
1414
|
+
const args = sourceComposeArgs(sourceDirectory, sourceComposeFiles);
|
|
1415
|
+
const started = await run("docker", [...args, "start"], sourceDirectory);
|
|
1416
|
+
if (!started.ok)
|
|
1417
|
+
throw new Error(`The original V1 deployment could not be started. ${redact(started.stderr, secrets)}`);
|
|
1418
|
+
await waitForDeepHealthWithArgs(args, sourceDirectory, secrets);
|
|
1419
|
+
}
|
|
1420
|
+
function assertSupportedSource(sourceDirectory, sourceEnv, providers) {
|
|
1421
|
+
const values = [sourceEnv.AUTH_PROVIDER, sourceEnv.OR3_AUTH_PROVIDER].filter(Boolean);
|
|
1422
|
+
if (!values.includes("basic-auth"))
|
|
1423
|
+
throw new Error(`V1 project uses ${values.join(", ") || "an unknown auth provider"}, not Basic Auth.`);
|
|
1424
|
+
if (sourceEnv.SSR_AUTH_ENABLED === "false" || sourceEnv.OR3_GUEST_ACCESS_ENABLED === "true") {
|
|
1425
|
+
throw new Error("V1 project does not require authenticated access; adoption expects the supported authenticated profile.");
|
|
1426
|
+
}
|
|
1427
|
+
if (sourceEnv.OR3_SYNC_PROVIDER !== "sqlite" || sourceEnv.OR3_STORAGE_FS_ROOT === undefined || sourceEnv.NUXT_PUBLIC_STORAGE_PROVIDER !== "fs") {
|
|
1428
|
+
throw new Error("V1 project is not the supported Basic Auth + SQLite + filesystem profile.");
|
|
1429
|
+
}
|
|
1430
|
+
if (sourceEnv.OR3_SYNC_ENABLED === "false" || sourceEnv.OR3_CLOUD_SYNC_ENABLED === "false" || sourceEnv.OR3_STORAGE_ENABLED === "false" || sourceEnv.OR3_CLOUD_STORAGE_ENABLED === "false") {
|
|
1431
|
+
throw new Error("V1 project has sync or storage disabled; adoption expects the supported enabled profile.");
|
|
1432
|
+
}
|
|
1433
|
+
const moduleIds = [...providers.matchAll(/or3-provider-[a-z0-9-]+\/nuxt/g)].map((match) => match[0]);
|
|
1434
|
+
const supported = new Set(["or3-provider-basic-auth/nuxt", "or3-provider-sqlite/nuxt", "or3-provider-fs/nuxt"]);
|
|
1435
|
+
if (moduleIds.some((moduleId) => !supported.has(moduleId)) || moduleIds.length !== 3 || new Set(moduleIds).size !== 3) {
|
|
1436
|
+
throw new Error(`V1 provider modules are unsupported: ${moduleIds.join(", ") || "none"}.`);
|
|
1437
|
+
}
|
|
1438
|
+
if (!sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL || !sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD) {
|
|
1439
|
+
throw new Error("V1 project has no Basic Auth bootstrap credentials; adoption cannot safely preserve first login.");
|
|
1440
|
+
}
|
|
1441
|
+
if (!sourceEnv.OR3_VERSION && !sourceEnv.OR3_IMAGE) {
|
|
1442
|
+
throw new Error("V1 project is missing OR3 version metadata.");
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
function assertSupportedSourceCompose(configText, sourceVolume, expectedPort) {
|
|
1446
|
+
const config = parseComposeConfig(configText);
|
|
1447
|
+
const service = config.services?.or3;
|
|
1448
|
+
if (!service)
|
|
1449
|
+
throw new Error("V1 Compose configuration does not define an or3 service.");
|
|
1450
|
+
for (const [key, expected] of [
|
|
1451
|
+
["OR3_BASIC_AUTH_DB_PATH", "/data/auth.sqlite"],
|
|
1452
|
+
["OR3_SQLITE_DB_PATH", "/data/sync.sqlite"],
|
|
1453
|
+
["OR3_STORAGE_FS_ROOT", "/data/storage"]
|
|
1454
|
+
]) {
|
|
1455
|
+
if (composeEnvironmentValue(service, key) !== expected) {
|
|
1456
|
+
throw new Error(`V1 Compose configuration does not resolve ${key} to ${expected}. Adoption refuses custom data layouts.`);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
const mounts = service.volumes ?? [];
|
|
1460
|
+
const dataMount = mounts.find((mount) => mount.target === "/data");
|
|
1461
|
+
if (!dataMount || dataMount.type !== "volume") {
|
|
1462
|
+
throw new Error("V1 Compose configuration must mount one named volume at /data.");
|
|
1463
|
+
}
|
|
1464
|
+
const mountSource = typeof dataMount.source === "string" ? dataMount.source : "";
|
|
1465
|
+
const volumeDefinition = config.volumes?.[mountSource]?.name;
|
|
1466
|
+
if (mountSource !== sourceVolume && volumeDefinition !== sourceVolume) {
|
|
1467
|
+
throw new Error(`V1 Compose /data volume does not resolve to the detected Docker volume ${sourceVolume}.`);
|
|
1468
|
+
}
|
|
1469
|
+
if (mounts.some((mount) => typeof mount.target === "string" && mount.target.startsWith("/data/"))) {
|
|
1470
|
+
throw new Error("V1 Compose configuration has an additional mount inside /data. Adoption refuses ambiguous storage layouts.");
|
|
1471
|
+
}
|
|
1472
|
+
if (!checkResolvedLoopbackBinding(configText, expectedPort)) {
|
|
1473
|
+
throw new Error("V1 Compose configuration must publish OR3 only on 127.0.0.1.");
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
async function adoptCommand(positionals, flags) {
|
|
1477
|
+
const sourceDirectory = resolve(requireStringFlag(flags, "from"));
|
|
1478
|
+
const targetDirectory = resolve(process.cwd(), positionals[0] ?? `${basename(sourceDirectory)}-managed`);
|
|
1479
|
+
if (targetDirectory === sourceDirectory)
|
|
1480
|
+
throw new Error("Choose a separate target directory for adoption.");
|
|
1481
|
+
await readDirectoryEmpty(targetDirectory);
|
|
1482
|
+
if (!await fileExists(join(sourceDirectory, "or3-release.json")))
|
|
1483
|
+
throw new Error(`V1 release metadata is missing at ${sourceDirectory}/or3-release.json.`);
|
|
1484
|
+
const release = JSON.parse(await readText(join(sourceDirectory, "or3-release.json")));
|
|
1485
|
+
if (!release.or3Version || !isVersion(release.or3Version))
|
|
1486
|
+
throw new Error("V1 release metadata does not contain a usable OR3 version.");
|
|
1487
|
+
const sourceEnv = parseEnv(await readText(join(sourceDirectory, ".env")));
|
|
1488
|
+
const providers = await readText(join(sourceDirectory, "or3.providers.generated.ts"));
|
|
1489
|
+
assertSupportedSource(sourceDirectory, sourceEnv, providers);
|
|
1490
|
+
const mode = sourceMode(sourceDirectory, sourceEnv);
|
|
1491
|
+
const domain = mode === "public" ? sourceEnv.OR3_PUBLIC_DOMAIN : undefined;
|
|
1492
|
+
if (domain)
|
|
1493
|
+
await checkPublicPrerequisites(domain);
|
|
1494
|
+
const sourceComposeFiles = ["-f", join(sourceDirectory, "compose.yaml")];
|
|
1495
|
+
if (mode === "public")
|
|
1496
|
+
sourceComposeFiles.push("-f", join(sourceDirectory, "compose.public.yaml"));
|
|
1497
|
+
await ensureDocker();
|
|
1498
|
+
const sourceConfig = await run("docker", [
|
|
1499
|
+
"compose",
|
|
1500
|
+
"--project-directory",
|
|
1501
|
+
sourceDirectory,
|
|
1502
|
+
"--env-file",
|
|
1503
|
+
join(sourceDirectory, ".env"),
|
|
1504
|
+
...sourceComposeFiles,
|
|
1505
|
+
"config",
|
|
1506
|
+
"--format",
|
|
1507
|
+
"json"
|
|
1508
|
+
], sourceDirectory);
|
|
1509
|
+
if (!sourceConfig.ok)
|
|
1510
|
+
throw new Error(`The V1 Compose configuration is invalid. ${redact(sourceConfig.stderr, secretValues(sourceEnv))}`);
|
|
1511
|
+
const image = imageFor(release.or3Version);
|
|
1512
|
+
if (sourceEnv.OR3_VERSION && sourceEnv.OR3_VERSION !== release.or3Version) {
|
|
1513
|
+
throw new Error(`V1 OR3_VERSION ${sourceEnv.OR3_VERSION} does not match or3-release.json ${release.or3Version}.`);
|
|
1514
|
+
}
|
|
1515
|
+
if (sourceEnv.OR3_IMAGE && sourceEnv.OR3_IMAGE !== image) {
|
|
1516
|
+
throw new Error(`V1 OR3_IMAGE ${sourceEnv.OR3_IMAGE} does not match the published image for ${release.or3Version}.`);
|
|
1517
|
+
}
|
|
1518
|
+
await pullImage(image);
|
|
1519
|
+
const sourceVolume = await readSourceVolume(sourceDirectory);
|
|
1520
|
+
const email = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
|
|
1521
|
+
const password = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
|
|
1522
|
+
validateEmail(email);
|
|
1523
|
+
validatePassword(password);
|
|
1524
|
+
const sourcePort = Number(sourceEnv.OR3_PORT ?? DEFAULT_PORT);
|
|
1525
|
+
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535)
|
|
1526
|
+
throw new Error("V1 project has an invalid OR3 port.");
|
|
1527
|
+
if (mode === "public" && [80, 443].includes(sourcePort))
|
|
1528
|
+
throw new Error("V1 public deployment uses a Caddy port for OR3; adoption requires a separate OR3 port.");
|
|
1529
|
+
assertSupportedSourceCompose(sourceConfig.stdout, sourceVolume, sourcePort);
|
|
1530
|
+
await mkdir(targetDirectory, { recursive: true, mode: 448 });
|
|
1531
|
+
await mkdir(deploymentPaths(targetDirectory).operations, { recursive: true, mode: 448 });
|
|
1532
|
+
await mkdir(deploymentPaths(targetDirectory).backups, { recursive: true, mode: 448 });
|
|
1533
|
+
await copyAssets(targetDirectory, mode);
|
|
1534
|
+
const copiedSecrets = {};
|
|
1535
|
+
for (const key of SECRET_KEYS)
|
|
1536
|
+
if (sourceEnv[key])
|
|
1537
|
+
copiedSecrets[key] = sourceEnv[key];
|
|
1538
|
+
const targetEnv = buildEnv({ mode, version: release.or3Version, directory: targetDirectory, email, password, domain, port: sourcePort, secrets: copiedSecrets });
|
|
1539
|
+
for (const key of ALLOWED_ENV_KEYS)
|
|
1540
|
+
if (sourceEnv[key] !== undefined)
|
|
1541
|
+
targetEnv[key] = sourceEnv[key];
|
|
1542
|
+
Object.assign(targetEnv, {
|
|
1543
|
+
OR3_VERSION: release.or3Version,
|
|
1544
|
+
OR3_IMAGE: image,
|
|
1545
|
+
OR3_COMPOSE_PROJECT: composeProjectNames(targetDirectory).project,
|
|
1546
|
+
OR3_VOLUME_NAME: composeProjectNames(targetDirectory).volume,
|
|
1547
|
+
OR3_CADDY_DATA_VOLUME: composeProjectNames(targetDirectory).caddyData,
|
|
1548
|
+
OR3_CADDY_CONFIG_VOLUME: composeProjectNames(targetDirectory).caddyConfig,
|
|
1549
|
+
OR3_PORT: String(sourcePort),
|
|
1550
|
+
OR3_BASIC_AUTH_DB_PATH: "/data/auth.sqlite",
|
|
1551
|
+
OR3_SQLITE_DB_PATH: "/data/sync.sqlite",
|
|
1552
|
+
OR3_STORAGE_FS_ROOT: "/data/storage",
|
|
1553
|
+
OR3_PUBLIC_DOMAIN: domain ?? "localhost",
|
|
1554
|
+
OR3_ALLOWED_ORIGINS: mode === "public" ? `https://${domain}` : `http://127.0.0.1:${sourcePort}`,
|
|
1555
|
+
OR3_FORCE_HTTPS: mode === "public" ? "true" : "false",
|
|
1556
|
+
OR3_TRUST_PROXY: mode === "public" ? "true" : "false"
|
|
1557
|
+
});
|
|
1558
|
+
const targetNames = composeProjectNames(targetDirectory);
|
|
1559
|
+
for (const volume of [targetNames.volume, ...mode === "public" ? [targetNames.caddyData, targetNames.caddyConfig] : []]) {
|
|
1560
|
+
const existing = await run("docker", ["volume", "inspect", volume]);
|
|
1561
|
+
if (existing.ok)
|
|
1562
|
+
throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
|
|
1563
|
+
}
|
|
1564
|
+
await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
|
|
1565
|
+
await writeSecure(join(targetDirectory, ".or3-initial-credentials"), `email=${email}
|
|
1566
|
+
password=${password}
|
|
1567
|
+
`);
|
|
1568
|
+
const digest = await imageDigest(image);
|
|
1569
|
+
const state = stateFromEnv(targetDirectory, targetEnv, mode, "adopt", digest);
|
|
1570
|
+
await markPending(targetDirectory, state, {
|
|
1571
|
+
id: id("adopt"),
|
|
1572
|
+
operation: "adopt",
|
|
1573
|
+
startedAt: now(),
|
|
1574
|
+
message: `Adopting ${sourceDirectory}`,
|
|
1575
|
+
sourceDirectory
|
|
1576
|
+
});
|
|
1577
|
+
let sourceStopAttempted = false;
|
|
1578
|
+
let sourceBackupDir;
|
|
1579
|
+
try {
|
|
1580
|
+
sourceStopAttempted = true;
|
|
1581
|
+
const sourceStop = await run("docker", [...sourceComposeArgs(sourceDirectory, sourceComposeFiles), "stop"], sourceDirectory);
|
|
1582
|
+
if (!sourceStop.ok)
|
|
1583
|
+
throw new Error(`Could not stop the V1 deployment. ${redact(sourceStop.stderr, secretValues(sourceEnv))}`);
|
|
1584
|
+
if (!await portAvailable(Number(targetEnv.OR3_PORT)))
|
|
1585
|
+
throw new Error(`Port ${targetEnv.OR3_PORT} is still in use after stopping the V1 deployment.`);
|
|
1586
|
+
if (mode === "public") {
|
|
1587
|
+
for (const publicPort of [80, 443]) {
|
|
1588
|
+
if (!await portAvailable(publicPort))
|
|
1589
|
+
throw new Error(`Public port ${publicPort} is still in use after stopping the V1 deployment.`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
const sourceBackupId = id("adopt-source");
|
|
1593
|
+
sourceBackupDir = backupDirectory(targetDirectory, sourceBackupId);
|
|
1594
|
+
await mkdir(sourceBackupDir, { recursive: true, mode: 448 });
|
|
1595
|
+
await copySecure(join(sourceDirectory, ".env"), join(sourceBackupDir, "config.env"));
|
|
1596
|
+
await archiveExternalVolume(image, sourceVolume, sourceBackupDir);
|
|
1597
|
+
const sourceManifest = {
|
|
1598
|
+
schemaVersion: 1,
|
|
1599
|
+
backupId: sourceBackupId,
|
|
1600
|
+
createdAt: now(),
|
|
1601
|
+
appVersion: release.or3Version,
|
|
1602
|
+
image,
|
|
1603
|
+
imageDigest: digest,
|
|
1604
|
+
dataSha256: await sha256File(join(sourceBackupDir, "data.tgz")),
|
|
1605
|
+
configSha256: await sha256File(join(sourceBackupDir, "config.env")),
|
|
1606
|
+
mode,
|
|
1607
|
+
domain,
|
|
1608
|
+
composeProject: sourceEnv.OR3_COMPOSE_PROJECT ?? sanitizeName(basename(sourceDirectory)),
|
|
1609
|
+
volumeName: sourceVolume,
|
|
1610
|
+
caddyDataVolume: sourceEnv.OR3_CADDY_DATA_VOLUME,
|
|
1611
|
+
caddyConfigVolume: sourceEnv.OR3_CADDY_CONFIG_VOLUME,
|
|
1612
|
+
port: sourcePort
|
|
1613
|
+
};
|
|
1614
|
+
await writeSecure(join(sourceBackupDir, "manifest.json"), `${JSON.stringify(sourceManifest, null, 2)}
|
|
1615
|
+
`);
|
|
1616
|
+
await updatePending(targetDirectory, state, { backupId: sourceBackupId });
|
|
1617
|
+
await compose(targetDirectory, mode, ["run", "--rm", "--no-deps", "--user", "0:0", "-v", `${sourceVolume}:/source:ro`, "--entrypoint", "sh", "or3", "-c", "mkdir -p /data && cp -a /source/. /data/"], secretValues(targetEnv));
|
|
1618
|
+
await startProject(targetDirectory, mode, targetEnv);
|
|
1619
|
+
await clearPending(targetDirectory, state);
|
|
1620
|
+
console.log(`Adopted ${sourceDirectory} into ${targetDirectory} at OR3 ${release.or3Version}.`);
|
|
1621
|
+
console.log(`Source backup: ${sourceBackupDir}`);
|
|
1622
|
+
console.log(`The original deployment is preserved and stopped. Verify sign-in, chat, and file access before removing anything.`);
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
await compose(targetDirectory, mode, ["down"]).catch(() => {
|
|
1625
|
+
return;
|
|
1626
|
+
});
|
|
1627
|
+
let sourceRecoveryError = "";
|
|
1628
|
+
if (sourceStopAttempted) {
|
|
1629
|
+
try {
|
|
1630
|
+
await restartSource(sourceDirectory, sourceComposeFiles, secretValues(sourceEnv));
|
|
1631
|
+
} catch (recovery) {
|
|
1632
|
+
sourceRecoveryError = ` Original deployment recovery failed: ${recovery instanceof Error ? recovery.message : String(recovery)}`;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
state.lastError = redact(`${error instanceof Error ? error.message : String(error)}${sourceRecoveryError}`, secretValues(targetEnv));
|
|
1636
|
+
await writeState(targetDirectory, state);
|
|
1637
|
+
throw new Error(`${state.lastError}
|
|
1638
|
+
${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "The original V1 deployment was restarted and deeply healthy. "}Managed files remain at ${targetDirectory}.${sourceBackupDir ? ` Source backup: ${sourceBackupDir}.` : ""}`);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
async function main(argv = process.argv.slice(2)) {
|
|
1642
|
+
const [command = "help", ...rest] = argv;
|
|
1643
|
+
if (command === "--help" || command === "help")
|
|
1644
|
+
return help();
|
|
1645
|
+
if (command === "--version" || command === "version")
|
|
1646
|
+
return console.log(PACKAGE_VERSION);
|
|
1647
|
+
const parsed = parseFlags(rest);
|
|
1648
|
+
try {
|
|
1649
|
+
if (command === "init")
|
|
1650
|
+
return await initCommand(parsed.positionals, parsed.flags);
|
|
1651
|
+
if (command === "update")
|
|
1652
|
+
return await updateCommand(process.cwd(), parsed.flags);
|
|
1653
|
+
if (command === "backup")
|
|
1654
|
+
return await backupCommand(process.cwd());
|
|
1655
|
+
if (command === "restore")
|
|
1656
|
+
return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
|
|
1657
|
+
if (command === "rollback")
|
|
1658
|
+
return await rollbackCommand(process.cwd(), parsed.flags);
|
|
1659
|
+
if (command === "doctor")
|
|
1660
|
+
return await doctorCommand(process.cwd());
|
|
1661
|
+
if (command === "recover")
|
|
1662
|
+
return await recoverCommand(process.cwd());
|
|
1663
|
+
if (command === "adopt")
|
|
1664
|
+
return await adoptCommand(parsed.positionals, parsed.flags);
|
|
1665
|
+
throw new Error(`Unknown command "${command}". Run npx @or3/cloud --help.`);
|
|
1666
|
+
} catch (error) {
|
|
1667
|
+
console.error(`
|
|
1668
|
+
OR3 Cloud failed: ${redact(error instanceof Error ? error.message : String(error))}`);
|
|
1669
|
+
process.exitCode = 1;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
var invokedAsCli = process.argv[1] ? ["or3", "cli.mjs"].includes(basename(process.argv[1])) : false;
|
|
1673
|
+
if (invokedAsCli || process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
|
1674
|
+
await main();
|
|
1675
|
+
}
|
|
1676
|
+
export {
|
|
1677
|
+
stateFromEnv,
|
|
1678
|
+
serializeEnv,
|
|
1679
|
+
redact,
|
|
1680
|
+
parseFlags,
|
|
1681
|
+
parseEnv,
|
|
1682
|
+
isVersion,
|
|
1683
|
+
checkResolvedLoopbackBinding,
|
|
1684
|
+
buildEnv,
|
|
1685
|
+
assertSupportedSourceCompose,
|
|
1686
|
+
assertSupportedSource,
|
|
1687
|
+
assertBackupMatchesDeployment,
|
|
1688
|
+
PACKAGE_VERSION,
|
|
1689
|
+
IMAGE_REPOSITORY
|
|
1690
|
+
};
|