openship 0.6.5 → 0.6.7
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/dist/bare-7QD623QV.js +29 -0
- package/dist/chunk-2ANV5ZQ5.js +88 -0
- package/dist/{chunk-LEYSL46B.js → chunk-2VSW6Q6C.js} +3911 -898
- package/dist/{chunk-5OUWIPOB.js → chunk-5T4UVOM3.js} +16 -86
- package/dist/{chunk-EZPPTWJQ.js → chunk-BLEXTYRK.js} +1 -1
- package/dist/{chunk-3ZAGC5NT.js → chunk-CIJV43LH.js} +83 -33
- package/dist/{chunk-7S4USQYF.js → chunk-DDFUESPJ.js} +67 -124
- package/dist/{chunk-6FH4AXVY.js → chunk-E2X26PXR.js} +1 -1
- package/dist/{chunk-XVOBL7BC.js → chunk-FYPFEDPZ.js} +1 -1
- package/dist/{chunk-WFRXYWK2.js → chunk-FZ6QCF6B.js} +8 -6
- package/dist/{chunk-VHMOQL5Y.js → chunk-GGIAIGUL.js} +125 -80
- package/dist/{chunk-WE7BXW6L.js → chunk-GIPJNNI5.js} +38 -44
- package/dist/{chunk-ZFJXPCQP.js → chunk-H3WT3A6R.js} +4 -3
- package/dist/chunk-H4FS5BED.js +936 -0
- package/dist/{chunk-HPXP6LXZ.js → chunk-J2XM3QSP.js} +8 -5
- package/dist/{chunk-6JELW75A.js → chunk-J3K6XABC.js} +5 -5
- package/dist/{chunk-VDESJSG6.js → chunk-LHFXSC36.js} +558 -109
- package/dist/{chunk-MSZUCNPV.js → chunk-MEE6OZFP.js} +3 -3
- package/dist/chunk-N5YZMO33.js +111 -0
- package/dist/{chunk-VLQ3UIVO.js → chunk-NRTJRNAE.js} +105 -37
- package/dist/chunk-PPEBIY5F.js +2331 -0
- package/dist/{chunk-RFWJE7ZN.js → chunk-PVP4RGWT.js} +3 -3
- package/dist/{chunk-PZRRL5PC.js → chunk-QHMDCYZ3.js} +15 -4
- package/dist/chunk-R4VDPFLS.js +106 -0
- package/dist/chunk-RDXT4BQB.js +310 -0
- package/dist/{chunk-GRTXMUHA.js → chunk-S6B2Z3OO.js} +145 -60
- package/dist/chunk-UWEV6XTS.js +249 -0
- package/dist/{chunk-QLX7NBEA.js → chunk-VQM6JHJB.js} +1004 -2046
- package/dist/{chunk-A7EWM2KA.js → chunk-VSTFHUCV.js} +44 -50
- package/dist/chunk-WU54BXFE.js +70 -0
- package/dist/{chunk-LFCSC4IU.js → chunk-XWHWTHEL.js} +313 -469
- package/dist/{chunk-7334NSJL.js → chunk-ZPJ74LXE.js} +76 -38
- package/dist/cloud-KNNABE3O.js +40 -0
- package/dist/{detect-P4CIQBRH.js → detect-G4DR3U2N.js} +9 -6
- package/dist/{docker-3K2NEBD6.js → docker-YOVPCIB4.js} +11 -7
- package/dist/{docker-edge-executor-YZZ6P7IC.js → docker-edge-executor-QFXD3337.js} +8 -6
- package/dist/edge-import-CRFVSR6P.js +56 -0
- package/dist/{edge-preflight-ES6FH4JK.js → edge-preflight-MXERVAMG.js} +15 -12
- package/dist/{ensure-container-edge-YPITNH45.js → ensure-container-edge-F4JUNQIK.js} +13 -10
- package/dist/{executor-DZGX55K2.js → executor-CVPAYYHR.js} +10 -5
- package/dist/host-channel-preflight-QTDNGLCK.js +54 -0
- package/dist/index.js +139 -92
- package/dist/{local-executor-WOFZD72X.js → local-executor-ODC6RANU.js} +3 -2
- package/dist/{nginx-NSAHWGJV.js → nginx-KLFF43TC.js} +13 -8
- package/dist/{openresty-lua-DR4D3EZ6.js → openresty-lua-VKWUV4GB.js} +8 -5
- package/dist/{privilege-YTVI4SKF.js → privilege-7D3SSOL3.js} +6 -5
- package/dist/server/engine/samples/amavisd/amavisd.conf +25 -0
- package/dist/server/engine/samples/postfix/master.cf +14 -0
- package/dist/server/index.js +44526 -33476
- package/dist/server/migrations/0105_mail_inbound_rules.sql +80 -0
- package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
- package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
- package/dist/server/migrations/0108_credential.sql +68 -0
- package/dist/server/migrations/meta/_journal.json +28 -0
- package/dist/setup-I5AJC6NJ.js +25 -0
- package/dist/ssh-client-625VWGKP.js +29 -0
- package/dist/up-QXTOOVXC.js +61 -0
- package/package.json +1 -1
- package/dist/bare-XQIPDO4R.js +0 -24
- package/dist/chunk-6HOA5JT7.js +0 -124
- package/dist/chunk-TM2IQPYU.js +0 -434
- package/dist/chunk-XRNRT37W.js +0 -316
- package/dist/cloud-3DODOWTR.js +0 -36
- package/dist/edge-import-MCJNVTZ2.js +0 -51
- package/dist/host-channel-preflight-MUXE3KZU.js +0 -49
- package/dist/setup-WUUZURHS.js +0 -22
- package/dist/up-XEO3SR6Z.js +0 -58
- /package/dist/{dist-es-ZP5QDSJ3.js → dist-es-AXFRS7PS.js} +0 -0
|
@@ -134,231 +134,50 @@ import {
|
|
|
134
134
|
} from "./chunk-ZWLDLTJP.js";
|
|
135
135
|
import {
|
|
136
136
|
CloudRuntime
|
|
137
|
-
} from "./chunk-
|
|
137
|
+
} from "./chunk-CIJV43LH.js";
|
|
138
138
|
import {
|
|
139
|
-
|
|
140
|
-
} from "./chunk-
|
|
139
|
+
OPENRESTY_DEFAULT_PATHS
|
|
140
|
+
} from "./chunk-QHMDCYZ3.js";
|
|
141
141
|
import {
|
|
142
|
-
DEFAULT_DOCKER_SOCKET_PATH,
|
|
143
142
|
DockerRuntime,
|
|
144
143
|
daemonConnectionFrom,
|
|
144
|
+
ensureScopedVolumeName,
|
|
145
145
|
isHostPathSource,
|
|
146
146
|
resolveExecExitCode,
|
|
147
|
-
resolveLocalDockerSocketPath,
|
|
148
|
-
scopedVolumeName,
|
|
149
147
|
startAttachStream,
|
|
150
148
|
startExecStream
|
|
151
|
-
} from "./chunk-
|
|
149
|
+
} from "./chunk-LHFXSC36.js";
|
|
152
150
|
import {
|
|
153
151
|
BareRuntime
|
|
154
|
-
} from "./chunk-
|
|
152
|
+
} from "./chunk-S6B2Z3OO.js";
|
|
155
153
|
import {
|
|
156
154
|
createExecutor
|
|
157
|
-
} from "./chunk-
|
|
158
|
-
import {
|
|
159
|
-
LocalExecutor
|
|
160
|
-
} from "./chunk-6FH4AXVY.js";
|
|
161
|
-
import {
|
|
162
|
-
invalidateEdgeContainer,
|
|
163
|
-
sanitizeEdgeVhosts
|
|
164
|
-
} from "./chunk-WE7BXW6L.js";
|
|
165
|
-
import {
|
|
166
|
-
EDGE_CONTAINER_MOUNTS,
|
|
167
|
-
EDGE_HOST_STATE_DIR,
|
|
168
|
-
OPENRESTY_DEFAULT_PATHS
|
|
169
|
-
} from "./chunk-PZRRL5PC.js";
|
|
155
|
+
} from "./chunk-GGIAIGUL.js";
|
|
170
156
|
import {
|
|
171
157
|
envOps
|
|
172
|
-
} from "./chunk-
|
|
158
|
+
} from "./chunk-BLEXTYRK.js";
|
|
173
159
|
import {
|
|
174
160
|
ENVIRONMENT_PROBE_SCRIPT,
|
|
175
161
|
ENVIRONMENT_PROBE_TIMEOUT_MS,
|
|
176
162
|
cacheEnvironment,
|
|
177
163
|
parseEnvironmentProbe,
|
|
178
164
|
profileIsFresh
|
|
179
|
-
} from "./chunk-
|
|
165
|
+
} from "./chunk-FZ6QCF6B.js";
|
|
166
|
+
import {
|
|
167
|
+
MAIL_HOST_STATE_DIR
|
|
168
|
+
} from "./chunk-UWEV6XTS.js";
|
|
180
169
|
import {
|
|
181
|
-
DEFAULT_IMAGE_REGISTRY,
|
|
182
170
|
FAMILY_BY_DISTRO,
|
|
183
|
-
HOST_CHANNEL_BLOCKED,
|
|
184
|
-
HOST_CHANNEL_RECHECK,
|
|
185
|
-
HOST_CHANNEL_UNAFFECTED,
|
|
186
|
-
explainHostChannelCause,
|
|
187
171
|
isAwsS3Endpoint,
|
|
172
|
+
isDbImage,
|
|
173
|
+
normalizeBackupPath,
|
|
174
|
+
payloadSpec,
|
|
188
175
|
safeErrorMessage,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
195
|
-
import { join } from "path";
|
|
196
|
-
|
|
197
|
-
// ../../packages/core/src/ports.ts
|
|
198
|
-
import { createServer } from "net";
|
|
199
|
-
var DEFAULT_API_PORT = 4e3;
|
|
200
|
-
var DEFAULT_DASHBOARD_PORT = 3001;
|
|
201
|
-
var LOOPBACK = "127.0.0.1";
|
|
202
|
-
var SCAN_SPAN = 20;
|
|
203
|
-
var MAX_PORT = 65535;
|
|
204
|
-
function isPortFree(port, host = LOOPBACK) {
|
|
205
|
-
return new Promise((resolve3) => {
|
|
206
|
-
const srv = createServer();
|
|
207
|
-
srv.once("error", () => resolve3(false));
|
|
208
|
-
srv.listen(port, host, () => srv.close(() => resolve3(true)));
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
async function waitPortFree(port, opts = {}) {
|
|
212
|
-
const timeoutMs = opts.timeoutMs ?? 6e3;
|
|
213
|
-
const intervalMs = opts.intervalMs ?? 250;
|
|
214
|
-
const host = opts.host ?? LOOPBACK;
|
|
215
|
-
const deadline = Date.now() + timeoutMs;
|
|
216
|
-
for (; ; ) {
|
|
217
|
-
if (await isPortFree(port, host)) return true;
|
|
218
|
-
if (Date.now() >= deadline) return false;
|
|
219
|
-
await new Promise((r2) => setTimeout(r2, intervalMs));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
function getFreePort(host = LOOPBACK) {
|
|
223
|
-
return new Promise((resolve3, reject) => {
|
|
224
|
-
const srv = createServer();
|
|
225
|
-
srv.once("error", reject);
|
|
226
|
-
srv.listen(0, host, () => {
|
|
227
|
-
const addr = srv.address();
|
|
228
|
-
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
229
|
-
srv.close(() => port ? resolve3(port) : reject(new Error("no free port")));
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
async function findPortNear(from, opts = {}) {
|
|
234
|
-
const host = opts.host ?? LOOPBACK;
|
|
235
|
-
const span = opts.span ?? SCAN_SPAN;
|
|
236
|
-
const skip = opts.skip ?? (() => false);
|
|
237
|
-
for (let port2 = from; port2 <= Math.min(from + span, MAX_PORT); port2++) {
|
|
238
|
-
if (!skip(port2) && await isPortFree(port2, host)) return port2;
|
|
239
|
-
}
|
|
240
|
-
let port = await getFreePort(host);
|
|
241
|
-
for (let tries = 0; tries < 10 && skip(port); tries++) port = await getFreePort(host);
|
|
242
|
-
return port;
|
|
243
|
-
}
|
|
244
|
-
async function resolvePortPair(prefs = {}) {
|
|
245
|
-
const host = prefs.bindAddr?.trim() || LOOPBACK;
|
|
246
|
-
const ours = new Set(prefs.reclaimable ?? []);
|
|
247
|
-
const prefFor = (kind) => prefs[kind] ?? prefs.previous?.[kind] ?? prefs.stored?.[kind] ?? prefs.defaults?.[kind];
|
|
248
|
-
const remembered = (kind, pref) => prefs[kind] === void 0 && (prefs.previous?.[kind] === pref || prefs.stored?.[kind] === pref);
|
|
249
|
-
const availableForPref = async (port) => ours.has(port) || await isPortFree(port, host);
|
|
250
|
-
const apiPref = prefFor("api");
|
|
251
|
-
const dashPref = prefFor("dashboard");
|
|
252
|
-
let api;
|
|
253
|
-
if (apiPref === void 0) api = await getFreePort(host);
|
|
254
|
-
else if (await availableForPref(apiPref)) api = apiPref;
|
|
255
|
-
else if (remembered("api", apiPref) && await waitPortFree(apiPref, { host })) api = apiPref;
|
|
256
|
-
else {
|
|
257
|
-
api = await findPortNear(apiPref + 1, {
|
|
258
|
-
host,
|
|
259
|
-
skip: (p2) => ours.has(p2) || p2 === dashPref
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
const skipForDash = (p2) => ours.has(p2) || p2 === api;
|
|
263
|
-
let dashboard;
|
|
264
|
-
if (dashPref === void 0) {
|
|
265
|
-
dashboard = await getFreePort(host);
|
|
266
|
-
for (let tries = 0; tries < 10 && dashboard === api; tries++) {
|
|
267
|
-
dashboard = await getFreePort(host);
|
|
268
|
-
}
|
|
269
|
-
} else if (dashPref !== api && await availableForPref(dashPref)) {
|
|
270
|
-
dashboard = dashPref;
|
|
271
|
-
} else if (dashPref !== api && remembered("dashboard", dashPref) && await waitPortFree(dashPref, { host })) {
|
|
272
|
-
dashboard = dashPref;
|
|
273
|
-
} else {
|
|
274
|
-
dashboard = await findPortNear(dashPref + 1, { host, skip: skipForDash });
|
|
275
|
-
}
|
|
276
|
-
return {
|
|
277
|
-
api,
|
|
278
|
-
dashboard,
|
|
279
|
-
switched: {
|
|
280
|
-
api: apiPref !== void 0 && api !== apiPref,
|
|
281
|
-
dashboard: dashPref !== void 0 && dashboard !== dashPref
|
|
282
|
-
},
|
|
283
|
-
preferred: { api: apiPref ?? api, dashboard: dashPref ?? dashboard }
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// src/lib/ports.ts
|
|
288
|
-
var PORTS_FILE = join(OS_DIR, "ports.json");
|
|
289
|
-
var INSTANCE_FILE = join(OS_DIR, "instance.json");
|
|
290
|
-
function saveInstanceUrl(publicUrl) {
|
|
291
|
-
try {
|
|
292
|
-
if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
|
|
293
|
-
writeFileSync(INSTANCE_FILE, JSON.stringify({ publicUrl: publicUrl ?? null }));
|
|
294
|
-
} catch {
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
function readInstanceUrl() {
|
|
298
|
-
try {
|
|
299
|
-
return JSON.parse(readFileSync(INSTANCE_FILE, "utf8")).publicUrl ?? null;
|
|
300
|
-
} catch {
|
|
301
|
-
return null;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
function readStoredPorts() {
|
|
305
|
-
try {
|
|
306
|
-
return JSON.parse(readFileSync(PORTS_FILE, "utf-8"));
|
|
307
|
-
} catch {
|
|
308
|
-
return {};
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
function storedApiPort() {
|
|
312
|
-
return readStoredPorts().api ?? DEFAULT_API_PORT;
|
|
313
|
-
}
|
|
314
|
-
function storedDashboardPort() {
|
|
315
|
-
return readStoredPorts().dashboard ?? DEFAULT_DASHBOARD_PORT;
|
|
316
|
-
}
|
|
317
|
-
function saveStoredPorts(api, dashboard) {
|
|
318
|
-
try {
|
|
319
|
-
if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
|
|
320
|
-
writeFileSync(PORTS_FILE, JSON.stringify({ api, dashboard }));
|
|
321
|
-
} catch {
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
function portMoveNotice(resolved, trustedOriginUrls = []) {
|
|
325
|
-
if (!resolved.switched.api && !resolved.switched.dashboard) return [];
|
|
326
|
-
const lines = [
|
|
327
|
-
`A preferred port was busy \u2014 using API ${resolved.api}, dashboard ${resolved.dashboard}.`
|
|
328
|
-
];
|
|
329
|
-
const stale = [...new Set(stalePortOrigins(resolved.preferred.dashboard, trustedOriginUrls))];
|
|
330
|
-
if (stale.length) {
|
|
331
|
-
lines.push(
|
|
332
|
-
`Heads up: ${stale.join(", ")} still names port ${resolved.preferred.dashboard}, so logins`,
|
|
333
|
-
`from that origin will be rejected. Point it at :${resolved.dashboard} (or free port`,
|
|
334
|
-
`${resolved.preferred.dashboard} and re-run to move back).`
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
return lines;
|
|
338
|
-
}
|
|
339
|
-
function stalePortOrigins(movedFrom, urls) {
|
|
340
|
-
const stale = [];
|
|
341
|
-
for (const raw of urls) {
|
|
342
|
-
for (const candidate of (raw ?? "").split(",")) {
|
|
343
|
-
const value = candidate.trim();
|
|
344
|
-
if (!value) continue;
|
|
345
|
-
try {
|
|
346
|
-
if (new URL(value).port === String(movedFrom)) stale.push(value);
|
|
347
|
-
} catch {
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return stale;
|
|
352
|
-
}
|
|
353
|
-
async function resolvePorts(prefs, opts = {}) {
|
|
354
|
-
const resolved = await resolvePortPair({
|
|
355
|
-
...prefs,
|
|
356
|
-
stored: readStoredPorts(),
|
|
357
|
-
defaults: { api: DEFAULT_API_PORT, dashboard: DEFAULT_DASHBOARD_PORT }
|
|
358
|
-
});
|
|
359
|
-
if (opts.persist !== false) saveStoredPorts(resolved.api, resolved.dashboard);
|
|
360
|
-
return resolved;
|
|
361
|
-
}
|
|
176
|
+
shellQuote,
|
|
177
|
+
validateBackupPath,
|
|
178
|
+
validateClearPath,
|
|
179
|
+
withTimeout
|
|
180
|
+
} from "./chunk-2VSW6Q6C.js";
|
|
362
181
|
|
|
363
182
|
// ../../packages/adapters/src/runtime/image-transfer.ts
|
|
364
183
|
import { Transform } from "stream";
|
|
@@ -366,40 +185,6 @@ import { Transform } from "stream";
|
|
|
366
185
|
// ../../packages/adapters/src/infra/edge-baked-conf.ts
|
|
367
186
|
var LOGS_DIR = OPENRESTY_DEFAULT_PATHS.pidPath.replace(/\/[^/]+$/, "");
|
|
368
187
|
|
|
369
|
-
// ../../packages/adapters/src/infra/mail-container.ts
|
|
370
|
-
var MAIL_HOST_STATE_DIR = "/var/lib/openship/mail";
|
|
371
|
-
var MAIL_CONTAINER_MOUNTS = [
|
|
372
|
-
{ host: `${MAIL_HOST_STATE_DIR}/vmail`, container: "/var/vmail" },
|
|
373
|
-
// The Postfix queue. A recreate on an ephemeral layer would drop in-flight and
|
|
374
|
-
// deferred mail without this — keep it on the host.
|
|
375
|
-
{ host: `${MAIL_HOST_STATE_DIR}/spool`, container: "/var/spool/postfix" },
|
|
376
|
-
// DKIM private keys. NEVER baked, never regenerated on pull — a fresh selector
|
|
377
|
-
// breaks DMARC until DNS repropagates.
|
|
378
|
-
{ host: `${MAIL_HOST_STATE_DIR}/dkim`, container: "/var/lib/dkim" },
|
|
379
|
-
// Mutable daemon config the admin layer edits (sasl_passwd, relayhost maps,
|
|
380
|
-
// amavis 50-user). Seeded from baked defaults on first boot.
|
|
381
|
-
{ host: `${MAIL_HOST_STATE_DIR}/config/postfix`, container: "/etc/postfix", seed: true },
|
|
382
|
-
{ host: `${MAIL_HOST_STATE_DIR}/config/dovecot`, container: "/etc/dovecot", seed: true },
|
|
383
|
-
{ host: `${MAIL_HOST_STATE_DIR}/config/amavis`, container: "/etc/amavis/conf.d", seed: true },
|
|
384
|
-
// ClamAV signatures — bind-mounted so a pull doesn't force a multi-hundred-MB
|
|
385
|
-
// freshclam re-download and delay readiness.
|
|
386
|
-
{ host: `${MAIL_HOST_STATE_DIR}/clamav`, container: "/var/lib/clamav" },
|
|
387
|
-
{ host: "/etc/letsencrypt", container: "/etc/letsencrypt", readonly: true }
|
|
388
|
-
];
|
|
389
|
-
var MAIL_DB_HOST_DATA_DIR = `${MAIL_HOST_STATE_DIR}/pgdata`;
|
|
390
|
-
var MAIL_DB_CONTAINER_DATA_DIR = "/var/lib/postgresql/data";
|
|
391
|
-
var MAIL_DB_PGDATA = `${MAIL_DB_CONTAINER_DATA_DIR}/pgdata`;
|
|
392
|
-
var MAIL_HOST_PATHS = {
|
|
393
|
-
saslPasswd: `${MAIL_HOST_STATE_DIR}/config/postfix/sasl_passwd`,
|
|
394
|
-
senderRelayhost: `${MAIL_HOST_STATE_DIR}/config/postfix/sender_relayhost`,
|
|
395
|
-
// Per-nexthop TLS policy. Only split ("selected"-scope) relaying writes it: the
|
|
396
|
-
// relay hop must be `encrypt` so SASL credentials never cross a plaintext
|
|
397
|
-
// connection, while direct-to-MX delivery for the un-relayed domains stays
|
|
398
|
-
// opportunistic. A GLOBAL smtp_tls_security_level can't express both.
|
|
399
|
-
relayTlsPolicy: `${MAIL_HOST_STATE_DIR}/config/postfix/openship_tls_policy`,
|
|
400
|
-
amavisUserConf: `${MAIL_HOST_STATE_DIR}/config/amavis/50-user`
|
|
401
|
-
};
|
|
402
|
-
|
|
403
188
|
// ../../packages/adapters/src/system/mail/ensure-container-mail.ts
|
|
404
189
|
var MAIL_DB_IMAGE = process.env.OPENSHIP_MAIL_DB_IMAGE?.trim() || "postgres:16-alpine";
|
|
405
190
|
var ENGINE_ENV_FILE = `${MAIL_HOST_STATE_DIR}/engine.env`;
|
|
@@ -530,6 +315,102 @@ function decryptCredential(stored) {
|
|
|
530
315
|
import { createHash as createHash3 } from "crypto";
|
|
531
316
|
import { Transform as Transform2 } from "stream";
|
|
532
317
|
|
|
318
|
+
// ../../packages/adapters/src/backup/common/dump-pipeline.ts
|
|
319
|
+
function codecSuffix(codec) {
|
|
320
|
+
return codec === "zstd" ? ".zst" : codec === "gzip" ? ".gz" : "";
|
|
321
|
+
}
|
|
322
|
+
function preludeBlock(prelude) {
|
|
323
|
+
return `{
|
|
324
|
+
${prelude}
|
|
325
|
+
} >&2`;
|
|
326
|
+
}
|
|
327
|
+
function compressor(codec) {
|
|
328
|
+
if (codec === "zstd") return "zstd -c -3";
|
|
329
|
+
if (codec === "gzip") return "gzip -c";
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
function decompressor(codec) {
|
|
333
|
+
if (codec === "zstd") return "zstd -d -c";
|
|
334
|
+
if (codec === "gzip") return "gzip -d -c";
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
function recordedCodec(value) {
|
|
338
|
+
return value === "gzip" || value === "none" || value === "zstd" ? value : "zstd";
|
|
339
|
+
}
|
|
340
|
+
async function detectDumpCodec(service, executor) {
|
|
341
|
+
try {
|
|
342
|
+
const { stdout, awaitExit } = await executor.execStream(service, [
|
|
343
|
+
"sh",
|
|
344
|
+
"-c",
|
|
345
|
+
"if command -v zstd >/dev/null 2>&1; then echo zstd; elif command -v gzip >/dev/null 2>&1; then echo gzip; else echo none; fi"
|
|
346
|
+
]);
|
|
347
|
+
const chunks = [];
|
|
348
|
+
stdout.on("data", (c2) => {
|
|
349
|
+
if (chunks.length < 8) chunks.push(c2);
|
|
350
|
+
});
|
|
351
|
+
stdout.on("error", () => {
|
|
352
|
+
});
|
|
353
|
+
const exit = await awaitExit;
|
|
354
|
+
await new Promise((resolve2) => setImmediate(resolve2));
|
|
355
|
+
if (exit.code !== 0) return "none";
|
|
356
|
+
const answer = Buffer.concat(chunks).toString("utf8").trim();
|
|
357
|
+
return answer === "zstd" || answer === "gzip" ? answer : "none";
|
|
358
|
+
} catch {
|
|
359
|
+
return "none";
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function safePipelineFragment(left, right, labels) {
|
|
363
|
+
return [
|
|
364
|
+
// Duplicating stdout is harmless to repeat, so a caller may use more than one of
|
|
365
|
+
// these in a script.
|
|
366
|
+
"exec 4>&1",
|
|
367
|
+
// `{ right; } >&4` and not `right >&4`: a right-hand side containing `&&` (the redis
|
|
368
|
+
// loader is `cat > file && chmod …`) would otherwise attach the redirect to its LAST
|
|
369
|
+
// command only. Grouping covers the whole side whatever the caller passes.
|
|
370
|
+
`res=$( { { if ${left}; then echo "d=0" >&3; else echo "d=$?" >&3; fi; } | { if { ${right}; } >&4; then echo "c=0" >&3; else echo "c=$?" >&3; fi; } } 3>&1 )`,
|
|
371
|
+
// Both default to FAILURE, so a report that never arrived cannot read as success.
|
|
372
|
+
"d=1; c=1",
|
|
373
|
+
// Unquoted `$res` word-splits on IFS, which includes the newline separating the two
|
|
374
|
+
// reports — no `sed`/`awk` (a minimal image may have neither), and order-independent.
|
|
375
|
+
// The two writes are single sub-PIPE_BUF lines on one pipe, so they cannot interleave.
|
|
376
|
+
'for kv in $res; do case "$kv" in d=*) d=${kv#d=} ;; c=*) c=${kv#c=} ;; esac; done',
|
|
377
|
+
// A status we could not parse is a failure, never an implicit success.
|
|
378
|
+
`case "$d" in ''|*[!0-9]*) d=1 ;; esac`,
|
|
379
|
+
`case "$c" in ''|*[!0-9]*) c=1 ;; esac`,
|
|
380
|
+
`[ "$c" = 0 ] || { echo "openship: ${labels.right} failed with status $c" >&2; exit "$c"; }`,
|
|
381
|
+
`[ "$d" = 0 ] || { echo "openship: ${labels.left} failed with status $d" >&2; exit "$d"; }`
|
|
382
|
+
].join("\n");
|
|
383
|
+
}
|
|
384
|
+
function safePipelineCommand(left, right, labels, prelude) {
|
|
385
|
+
const script = [
|
|
386
|
+
...prelude ? [preludeBlock(prelude)] : [],
|
|
387
|
+
safePipelineFragment(left, right, labels)
|
|
388
|
+
].join("\n");
|
|
389
|
+
return ["sh", "-c", script];
|
|
390
|
+
}
|
|
391
|
+
function safeDumpCommand(dump, codec, prelude) {
|
|
392
|
+
const comp = compressor(codec);
|
|
393
|
+
if (!comp) return ["sh", "-c", prelude ? `${preludeBlock(prelude)}
|
|
394
|
+
${dump}` : dump];
|
|
395
|
+
return safePipelineCommand(
|
|
396
|
+
dump,
|
|
397
|
+
comp,
|
|
398
|
+
{ left: "the dump command", right: `the compressor '${comp}'` },
|
|
399
|
+
prelude
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
function safeRestoreCommand(codec, load, prelude) {
|
|
403
|
+
const decomp = decompressor(codec);
|
|
404
|
+
if (!decomp) return ["sh", "-c", prelude ? `${preludeBlock(prelude)}
|
|
405
|
+
${load}` : load];
|
|
406
|
+
return safePipelineCommand(
|
|
407
|
+
decomp,
|
|
408
|
+
load,
|
|
409
|
+
{ left: `the decompressor '${decomp}'`, right: "the restore command" },
|
|
410
|
+
prelude
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
533
414
|
// ../../packages/adapters/src/backup/common/source-match.ts
|
|
534
415
|
function matchBackupSource(sources, targetSourceId) {
|
|
535
416
|
const wanted = targetSourceId.trim();
|
|
@@ -549,40 +430,53 @@ function matchBackupSource(sources, targetSourceId) {
|
|
|
549
430
|
return null;
|
|
550
431
|
}
|
|
551
432
|
|
|
433
|
+
// ../../packages/adapters/src/backup/common/artifact-metadata.ts
|
|
434
|
+
var REDACTED_USERINFO_MARKER = "://***@";
|
|
435
|
+
function isRedactedCommand(command) {
|
|
436
|
+
return command.includes(REDACTED_USERINFO_MARKER);
|
|
437
|
+
}
|
|
438
|
+
|
|
552
439
|
// ../../packages/adapters/src/backup/volume-transfer.ts
|
|
553
440
|
import { Transform as Transform3 } from "stream";
|
|
554
441
|
|
|
442
|
+
// ../../packages/adapters/src/backup/common/exec-target.ts
|
|
443
|
+
function canExecInService(service) {
|
|
444
|
+
return !!service.containerId && service.containerRunning !== false;
|
|
445
|
+
}
|
|
446
|
+
|
|
555
447
|
// ../../packages/adapters/src/backup/producers/pg-dump.ts
|
|
556
|
-
var POSTGRES_IMAGE_RE = /^(postgres|postgis\/postgis):/i;
|
|
557
448
|
function envOr(service, key, fallback) {
|
|
558
449
|
return service.env[key] ?? fallback;
|
|
559
450
|
}
|
|
560
451
|
var PgDumpProducerImpl = class {
|
|
561
452
|
kind = "pg_dump";
|
|
562
453
|
detects(service) {
|
|
563
|
-
if (!service.image
|
|
564
|
-
|
|
565
|
-
return !!db;
|
|
454
|
+
if (!isDbImage(service.image, "pg_dump")) return false;
|
|
455
|
+
return canExecInService(service);
|
|
566
456
|
}
|
|
567
457
|
async *produce(service, executor, _opts) {
|
|
568
458
|
const user = envOr(service, "POSTGRES_USER", "postgres");
|
|
569
|
-
const db = service.env.POSTGRES_DB ?? service.env.PGDATABASE ?? "postgres";
|
|
459
|
+
const db = service.env.POSTGRES_DB ?? service.env.PGDATABASE ?? service.env.POSTGRES_USER ?? "postgres";
|
|
570
460
|
const password = service.env.POSTGRES_PASSWORD ?? service.env.PGPASSWORD ?? "";
|
|
571
|
-
const cmd =
|
|
572
|
-
|
|
573
|
-
"
|
|
574
|
-
`export PGPASSWORD=${
|
|
575
|
-
|
|
461
|
+
const cmd = safeDumpCommand(
|
|
462
|
+
`pg_dump -Fc -U ${shellQuote(user)} ${shellQuote(db)}`,
|
|
463
|
+
"none",
|
|
464
|
+
`export PGPASSWORD=${shellQuote(password)}`
|
|
465
|
+
);
|
|
576
466
|
const { stdout, awaitExit } = await executor.execStream(service, cmd);
|
|
577
467
|
yield {
|
|
578
|
-
|
|
468
|
+
// These bytes are pg_dump's own container format, which pg_restore reads
|
|
469
|
+
// directly. `.dump` is what the Postgres docs call it.
|
|
470
|
+
name: "pg-dump.dump",
|
|
579
471
|
stream: stdout,
|
|
580
472
|
payloadKind: "pg_dump",
|
|
581
473
|
metadata: {
|
|
582
474
|
postgresUser: user,
|
|
583
475
|
postgresDb: db,
|
|
584
476
|
format: "custom",
|
|
585
|
-
|
|
477
|
+
// The EXTERNAL codec only. The custom format's internal zlib is pg_restore's
|
|
478
|
+
// own business, so a `-Fc` artifact records "none".
|
|
479
|
+
compression: "none"
|
|
586
480
|
}
|
|
587
481
|
};
|
|
588
482
|
const exit = await awaitExit;
|
|
@@ -594,17 +488,18 @@ var PgDumpProducerImpl = class {
|
|
|
594
488
|
}
|
|
595
489
|
async restore(service, executor, artifact, _opts) {
|
|
596
490
|
const user = envOr(service, "POSTGRES_USER", "postgres");
|
|
597
|
-
const db = artifact.metadata.postgresDb ?? service.env.POSTGRES_DB ?? service.env.PGDATABASE ?? "postgres";
|
|
598
491
|
const password = service.env.POSTGRES_PASSWORD ?? service.env.PGPASSWORD ?? "";
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
492
|
+
const codec = recordedCodec(artifact.metadata.compression);
|
|
493
|
+
const db = artifact.metadata.postgresDb ?? service.env.POSTGRES_DB ?? service.env.PGDATABASE ?? service.env.POSTGRES_USER ?? "postgres";
|
|
494
|
+
const cmd = safeRestoreCommand(
|
|
495
|
+
codec,
|
|
496
|
+
`pg_restore --clean --if-exists --no-owner --no-acl --single-transaction -U ${shellQuote(user)} -d ${shellQuote(db)}`,
|
|
497
|
+
`export PGPASSWORD=${shellQuote(password)}`
|
|
498
|
+
);
|
|
604
499
|
const body = await artifact.open();
|
|
605
500
|
const exit = await executor.pipeIntoCommand(service, cmd, body, {
|
|
606
|
-
|
|
607
|
-
|
|
501
|
+
// Ceiling from the catalog, so the number is not a per-producer literal.
|
|
502
|
+
timeoutMs: payloadSpec("pg_dump").restoreTimeoutMs
|
|
608
503
|
});
|
|
609
504
|
if (exit.code !== 0) {
|
|
610
505
|
throw new Error(
|
|
@@ -613,17 +508,10 @@ var PgDumpProducerImpl = class {
|
|
|
613
508
|
}
|
|
614
509
|
}
|
|
615
510
|
};
|
|
616
|
-
function shellEscape(s2) {
|
|
617
|
-
return `'${s2.replace(/'/g, "'\\''")}'`;
|
|
618
|
-
}
|
|
619
511
|
var PgDumpProducer = new PgDumpProducerImpl();
|
|
620
512
|
registerProducer(PgDumpProducer);
|
|
621
513
|
|
|
622
514
|
// ../../packages/adapters/src/backup/producers/mysql-dump.ts
|
|
623
|
-
var MYSQL_IMAGE_RE = /^(mysql|mariadb|percona\/percona-server):/i;
|
|
624
|
-
function shellEscape2(s2) {
|
|
625
|
-
return `'${s2.replace(/'/g, "'\\''")}'`;
|
|
626
|
-
}
|
|
627
515
|
function mysqlCredentials(env) {
|
|
628
516
|
if (env.MYSQL_ROOT_PASSWORD) {
|
|
629
517
|
return { user: "root", password: env.MYSQL_ROOT_PASSWORD };
|
|
@@ -633,27 +521,32 @@ function mysqlCredentials(env) {
|
|
|
633
521
|
var MysqlDumpProducerImpl = class {
|
|
634
522
|
kind = "mysql_dump";
|
|
635
523
|
detects(service) {
|
|
636
|
-
if (!service.image
|
|
524
|
+
if (!isDbImage(service.image, "mysql_dump")) return false;
|
|
525
|
+
if (!canExecInService(service)) return false;
|
|
637
526
|
return !!(service.env.MYSQL_ROOT_PASSWORD ?? service.env.MYSQL_PASSWORD);
|
|
638
527
|
}
|
|
639
528
|
async *produce(service, executor, _opts) {
|
|
640
529
|
const { user, password } = mysqlCredentials(service.env);
|
|
641
530
|
const db = service.env.MYSQL_DATABASE ?? "";
|
|
642
|
-
const dbArg = db ? `--databases ${
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
531
|
+
const dbArg = db ? `--databases ${shellQuote(db)}` : "--all-databases";
|
|
532
|
+
const codec = await detectDumpCodec(service, executor);
|
|
533
|
+
const cmd = safeDumpCommand(
|
|
534
|
+
`mysqldump --single-transaction --routines --triggers -u ${shellQuote(user)} ${dbArg}`,
|
|
535
|
+
codec,
|
|
536
|
+
// `export`, not the `VAR=x cmd` prefix: with a pipeline the prefix form sets
|
|
537
|
+
// the variable on mysqldump only, and the safe-pipeline wrapper puts the
|
|
538
|
+
// compressor in the same shell.
|
|
539
|
+
`export MYSQL_PWD=${shellQuote(password)}`
|
|
540
|
+
);
|
|
648
541
|
const { stdout, awaitExit } = await executor.execStream(service, cmd);
|
|
649
542
|
yield {
|
|
650
|
-
name:
|
|
543
|
+
name: `mysql-dump.sql${codecSuffix(codec)}`,
|
|
651
544
|
stream: stdout,
|
|
652
545
|
payloadKind: "mysql_dump",
|
|
653
546
|
metadata: {
|
|
654
547
|
mysqlUser: user,
|
|
655
548
|
mysqlDatabase: db || "(all)",
|
|
656
|
-
compression:
|
|
549
|
+
compression: codec
|
|
657
550
|
}
|
|
658
551
|
};
|
|
659
552
|
const exit = await awaitExit;
|
|
@@ -663,14 +556,16 @@ var MysqlDumpProducerImpl = class {
|
|
|
663
556
|
}
|
|
664
557
|
async restore(service, executor, artifact, _opts) {
|
|
665
558
|
const { user, password } = mysqlCredentials(service.env);
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
`
|
|
670
|
-
|
|
559
|
+
const codec = recordedCodec(artifact.metadata.compression);
|
|
560
|
+
const cmd = safeRestoreCommand(
|
|
561
|
+
codec,
|
|
562
|
+
`mysql -u ${shellQuote(user)}`,
|
|
563
|
+
`export MYSQL_PWD=${shellQuote(password)}`
|
|
564
|
+
);
|
|
671
565
|
const body = await artifact.open();
|
|
672
566
|
const exit = await executor.pipeIntoCommand(service, cmd, body, {
|
|
673
|
-
|
|
567
|
+
// Ceiling from the catalog, so the number is not a per-producer literal.
|
|
568
|
+
timeoutMs: payloadSpec("mysql_dump").restoreTimeoutMs
|
|
674
569
|
});
|
|
675
570
|
if (exit.code !== 0) {
|
|
676
571
|
throw new Error(`mysql restore exited ${exit.code}: ${exit.stderr.slice(0, 500)}`);
|
|
@@ -681,36 +576,110 @@ var MysqlDumpProducer = new MysqlDumpProducerImpl();
|
|
|
681
576
|
registerProducer(MysqlDumpProducer);
|
|
682
577
|
|
|
683
578
|
// ../../packages/adapters/src/backup/producers/redis.ts
|
|
684
|
-
|
|
579
|
+
async function probeOutput(res) {
|
|
580
|
+
const chunks = [];
|
|
581
|
+
let size = 0;
|
|
582
|
+
const drained = new Promise((resolve2) => {
|
|
583
|
+
res.stdout.on("data", (c2) => {
|
|
584
|
+
if (size < 4096) {
|
|
585
|
+
chunks.push(Buffer.from(c2));
|
|
586
|
+
size += c2.byteLength;
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
res.stdout.once("end", () => resolve2());
|
|
590
|
+
res.stdout.once("error", () => resolve2());
|
|
591
|
+
res.stdout.once("close", () => resolve2());
|
|
592
|
+
});
|
|
593
|
+
await withTimeout(drained, PROBE_TIMEOUT_MS, "redis config probe timed out").catch(
|
|
594
|
+
() => void 0
|
|
595
|
+
);
|
|
596
|
+
await res.awaitExit.catch(() => void 0);
|
|
597
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
598
|
+
}
|
|
599
|
+
var PROBE_TIMEOUT_MS = 3e4;
|
|
600
|
+
var BGSAVE_WAIT_SECONDS = 300;
|
|
685
601
|
var RedisRdbProducerImpl = class {
|
|
686
602
|
kind = "redis_rdb";
|
|
687
603
|
detects(service) {
|
|
688
|
-
|
|
604
|
+
if (!isDbImage(service.image, "redis_rdb")) return false;
|
|
605
|
+
return canExecInService(service);
|
|
689
606
|
}
|
|
690
607
|
async *produce(service, executor, _opts) {
|
|
691
|
-
const
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
for
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
608
|
+
const cli = this.cli(service);
|
|
609
|
+
const waitSeconds = BGSAVE_WAIT_SECONDS;
|
|
610
|
+
const codec = await detectDumpCodec(service, executor);
|
|
611
|
+
const prelude = [
|
|
612
|
+
// One reader for both LASTSAVE calls, because reading it correctly is not a
|
|
613
|
+
// one-liner: `redis-cli -a` has printed an auth warning on EVERY invocation since
|
|
614
|
+
// 4.0, so the capture is two lines and folding it into the timestamp with a bare
|
|
615
|
+
// `2>&1` would make every password-protected Redis look unreachable. Take the last
|
|
616
|
+
// all-digits LINE as the reply, and keep the rest as the diagnostic.
|
|
617
|
+
`lastsave() {`,
|
|
618
|
+
` RAW=$(${cli} LASTSAVE 2>&1 | tr -d '\\r')`,
|
|
619
|
+
` TS=$(printf '%s\\n' "$RAW" | grep -E '^[0-9]+$' | tail -n 1)`,
|
|
620
|
+
`}`,
|
|
621
|
+
`lastsave`,
|
|
622
|
+
`[ -n "$TS" ] || {`,
|
|
623
|
+
` echo "openship: could not read Redis LASTSAVE, so a captured dump.rdb could not be proven fresh \u2014 refusing to archive a possibly stale one. redis-cli said: $RAW" >&2`,
|
|
624
|
+
` exit 91`,
|
|
625
|
+
`}`,
|
|
626
|
+
`LAST=$TS`,
|
|
627
|
+
// Both halves of "did BGSAVE start", because which one answers depends on the
|
|
628
|
+
// version: Redis 6+ exits non-zero on an error reply, older builds exit 0 and only
|
|
629
|
+
// print it. An already-RUNNING save is not a failure — it will advance LASTSAVE, so
|
|
630
|
+
// the wait below covers it — and its reply contains ERR, which is why that arm comes
|
|
631
|
+
// first.
|
|
632
|
+
//
|
|
633
|
+
// `already in progress` and not `in progress`: Redis has a SECOND error with that
|
|
634
|
+
// phrase — "Can't BGSAVE while AOF log rewriting is in progress" — and it means the
|
|
635
|
+
// opposite, that no save was started at all. Matching the loose phrase waved it
|
|
636
|
+
// through and left the wait polling for a timestamp nothing was going to move.
|
|
637
|
+
`BG=$(${cli} BGSAVE 2>&1 | tr -d '\\r') || BGFAIL=1`,
|
|
638
|
+
`case "$BG" in`,
|
|
639
|
+
` *"already in progress"*) ;;`,
|
|
640
|
+
` *ERR*) BGFAIL=1 ;;`,
|
|
641
|
+
`esac`,
|
|
642
|
+
`[ -z "$BGFAIL" ] || {`,
|
|
643
|
+
` echo "openship: Redis would not start a background save, so /data/dump.rdb is whatever an earlier save left there \u2014 refusing to archive it as this backup. redis-cli said: $BG" >&2`,
|
|
644
|
+
` exit 92`,
|
|
645
|
+
`}`,
|
|
646
|
+
`i=0; SAVED=0`,
|
|
647
|
+
`while [ "$i" -lt ${waitSeconds} ]; do`,
|
|
648
|
+
` lastsave`,
|
|
649
|
+
` [ -n "$TS" ] || {`,
|
|
650
|
+
` echo "openship: lost contact with Redis while waiting for BGSAVE to finish. redis-cli said: $RAW" >&2`,
|
|
651
|
+
` exit 91`,
|
|
652
|
+
` }`,
|
|
653
|
+
` if [ "$TS" != "$LAST" ]; then SAVED=1; break; fi`,
|
|
654
|
+
// Fail on a save Redis has already given up on rather than waiting out the whole
|
|
655
|
+
// window for a timestamp that is never going to move. Gated on
|
|
656
|
+
// `rdb_bgsave_in_progress:0` because the status field still reports the PREVIOUS
|
|
657
|
+
// save's result while a new one runs. A missing `tr`/`grep`/`INFO` leaves this
|
|
658
|
+
// empty, which matches nothing and falls through to the timeout below — the checks
|
|
659
|
+
// here only ever ADD a reason to fail, never a reason to pass.
|
|
660
|
+
` INFO=$(${cli} INFO persistence 2>/dev/null | tr -d '\\r')`,
|
|
661
|
+
` case "$INFO" in *rdb_bgsave_in_progress:0*)`,
|
|
662
|
+
` case "$INFO" in *rdb_last_bgsave_status:err*)`,
|
|
663
|
+
` echo "openship: Redis reported its background save FAILED (rdb_last_bgsave_status:err) \u2014 usually no free space where /data lives. Nothing fresh was written, so refusing to archive the previous save." >&2`,
|
|
664
|
+
` exit 93 ;;`,
|
|
665
|
+
` esac ;;`,
|
|
666
|
+
` esac`,
|
|
667
|
+
` i=$((i+1)); sleep 1`,
|
|
668
|
+
`done`,
|
|
669
|
+
`[ "$SAVED" = 1 ] || {`,
|
|
670
|
+
` echo "openship: Redis BGSAVE did not finish within ${waitSeconds}s \u2014 LASTSAVE never advanced, so /data/dump.rdb is still the PREVIOUS save. Refusing to archive it as this backup; use a volume payload for a dataset this large, or retry." >&2`,
|
|
671
|
+
` exit 94`,
|
|
672
|
+
`}`
|
|
673
|
+
].join("\n");
|
|
674
|
+
const cmd = safeDumpCommand("cat /data/dump.rdb", codec, prelude);
|
|
675
|
+
const { stdout, awaitExit } = await executor.execStream(service, cmd);
|
|
707
676
|
yield {
|
|
708
|
-
name:
|
|
677
|
+
name: `redis-dump.rdb${codecSuffix(codec)}`,
|
|
709
678
|
stream: stdout,
|
|
710
679
|
payloadKind: "redis_rdb",
|
|
711
680
|
metadata: {
|
|
712
681
|
rdbPath: "/data/dump.rdb",
|
|
713
|
-
compression:
|
|
682
|
+
compression: codec
|
|
714
683
|
}
|
|
715
684
|
};
|
|
716
685
|
const exit = await awaitExit;
|
|
@@ -718,15 +687,31 @@ var RedisRdbProducerImpl = class {
|
|
|
718
687
|
throw new Error(`redis BGSAVE/capture exited ${exit.code}: ${exit.stderr.slice(0, 500)}`);
|
|
719
688
|
}
|
|
720
689
|
}
|
|
690
|
+
/** `redis-cli` with auth, when a password is discoverable. */
|
|
691
|
+
cli(service) {
|
|
692
|
+
const pass = service.env.REDIS_PASSWORD;
|
|
693
|
+
return pass ? `redis-cli -a ${shellQuote(pass)}` : "redis-cli";
|
|
694
|
+
}
|
|
721
695
|
async restore(service, executor, artifact, _opts) {
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
696
|
+
const aofValue = await probeOutput(
|
|
697
|
+
await executor.execStream(service, [
|
|
698
|
+
"sh",
|
|
699
|
+
"-c",
|
|
700
|
+
`${this.cli(service)} CONFIG GET appendonly 2>/dev/null | tail -n 1`
|
|
701
|
+
])
|
|
702
|
+
);
|
|
703
|
+
if (aofValue.trim().toLowerCase() === "yes") {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`This Redis has AOF persistence enabled (appendonly yes), so at startup it loads its append-only file INSTEAD of dump.rdb \u2014 restoring this RDB artifact would change nothing. Disable appendonly on the service and redeploy before restoring an RDB, or restore from a volume backup, which replaces /data wholesale.`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
await executor.execStream(service, ["sh", "-c", `${this.cli(service)} CONFIG SET save '' >/dev/null 2>&1`]).then((r2) => r2.awaitExit).catch(() => void 0);
|
|
709
|
+
const codec = recordedCodec(artifact.metadata.compression);
|
|
710
|
+
const cmd = safeRestoreCommand(codec, "cat > /data/dump.rdb && chmod 644 /data/dump.rdb");
|
|
727
711
|
const body = await artifact.open();
|
|
728
712
|
const exit = await executor.pipeIntoCommand(service, cmd, body, {
|
|
729
|
-
|
|
713
|
+
// Ceiling from the catalog, so the number is not a per-producer literal.
|
|
714
|
+
timeoutMs: payloadSpec("redis_rdb").restoreTimeoutMs
|
|
730
715
|
});
|
|
731
716
|
if (exit.code !== 0) {
|
|
732
717
|
throw new Error(`redis restore exited ${exit.code}: ${exit.stderr.slice(0, 500)}`);
|
|
@@ -737,20 +722,17 @@ var RedisRdbProducer = new RedisRdbProducerImpl();
|
|
|
737
722
|
registerProducer(RedisRdbProducer);
|
|
738
723
|
|
|
739
724
|
// ../../packages/adapters/src/backup/producers/mongo.ts
|
|
740
|
-
var MONGO_IMAGE_RE = /^(mongo|percona\/percona-server-mongodb):/i;
|
|
741
|
-
function shellEscape3(s2) {
|
|
742
|
-
return `'${s2.replace(/'/g, "'\\''")}'`;
|
|
743
|
-
}
|
|
744
725
|
var MongoDumpProducerImpl = class {
|
|
745
726
|
kind = "mongo_dump";
|
|
746
727
|
detects(service) {
|
|
747
|
-
|
|
728
|
+
if (!isDbImage(service.image, "mongo_dump")) return false;
|
|
729
|
+
return canExecInService(service);
|
|
748
730
|
}
|
|
749
731
|
authArgs(service) {
|
|
750
732
|
const user = service.env.MONGO_INITDB_ROOT_USERNAME ?? service.env.MONGODB_ROOT_USERNAME ?? "";
|
|
751
733
|
const pass = service.env.MONGO_INITDB_ROOT_PASSWORD ?? service.env.MONGODB_ROOT_PASSWORD ?? "";
|
|
752
734
|
if (!user || !pass) return "";
|
|
753
|
-
return `-u ${
|
|
735
|
+
return `-u ${shellQuote(user)} -p ${shellQuote(pass)} --authenticationDatabase admin`;
|
|
754
736
|
}
|
|
755
737
|
async *produce(service, executor, _opts) {
|
|
756
738
|
const auth = this.authArgs(service);
|
|
@@ -776,7 +758,8 @@ var MongoDumpProducerImpl = class {
|
|
|
776
758
|
];
|
|
777
759
|
const body = await artifact.open();
|
|
778
760
|
const exit = await executor.pipeIntoCommand(service, cmd, body, {
|
|
779
|
-
|
|
761
|
+
// Ceiling from the catalog, so the number is not a per-producer literal.
|
|
762
|
+
timeoutMs: payloadSpec("mongo_dump").restoreTimeoutMs
|
|
780
763
|
});
|
|
781
764
|
if (exit.code !== 0) {
|
|
782
765
|
throw new Error(`mongorestore exited ${exit.code}: ${exit.stderr.slice(0, 500)}`);
|
|
@@ -826,12 +809,18 @@ var CustomCommandProducerImpl = class {
|
|
|
826
809
|
"Backup has no restoreCommand recorded \u2014 restore is not possible for this artifact. Re-run the backup policy to capture a restorable one."
|
|
827
810
|
);
|
|
828
811
|
}
|
|
812
|
+
if (isRedactedCommand(restoreCommand)) {
|
|
813
|
+
throw new Error(
|
|
814
|
+
"Backup's recorded restoreCommand had its credentials redacted when it was captured, so running it would fail authentication partway through the restore. Re-run the backup policy to capture a restorable artifact (or restore by hand using the command on the policy)."
|
|
815
|
+
);
|
|
816
|
+
}
|
|
829
817
|
const body = await artifact.open();
|
|
830
818
|
const exit = await executor.pipeIntoCommand(
|
|
831
819
|
service,
|
|
832
820
|
["sh", "-c", restoreCommand],
|
|
833
821
|
body,
|
|
834
|
-
|
|
822
|
+
// Ceiling from the catalog, so the number is not a per-producer literal.
|
|
823
|
+
{ timeoutMs: payloadSpec("custom_command").restoreTimeoutMs }
|
|
835
824
|
);
|
|
836
825
|
if (exit.code !== 0) {
|
|
837
826
|
throw new Error(
|
|
@@ -843,6 +832,130 @@ var CustomCommandProducerImpl = class {
|
|
|
843
832
|
var CustomCommandProducer = new CustomCommandProducerImpl();
|
|
844
833
|
registerProducer(CustomCommandProducer);
|
|
845
834
|
|
|
835
|
+
// ../../packages/adapters/src/backup/producers/path.ts
|
|
836
|
+
function guardSourcePath(path, serviceName) {
|
|
837
|
+
const q2 = shellQuote(path);
|
|
838
|
+
return [
|
|
839
|
+
`if [ ! -d ${q2} ]; then`,
|
|
840
|
+
` echo "openship: ${path} does not exist inside service ${serviceName} (or is not a directory), so there is nothing to archive. Check the path, or point the policy at a volume instead." >&2`,
|
|
841
|
+
` exit 66`,
|
|
842
|
+
`fi`,
|
|
843
|
+
`if [ -z "$(find ${q2} -mindepth 1 -print -quit 2>/dev/null)" ]; then`,
|
|
844
|
+
` echo "openship: ${path} exists inside service ${serviceName} but is empty. Refusing to record an empty archive as a backup \u2014 a directory that should hold data and does not usually means the data lives somewhere else (a volume mounted one level off, or an app that moved its data directory). Fix the path, or remove it from the policy if it is expected to be empty." >&2`,
|
|
845
|
+
` exit 67`,
|
|
846
|
+
`fi`
|
|
847
|
+
].join("\n");
|
|
848
|
+
}
|
|
849
|
+
function guardRestorePath(path, codec, clear) {
|
|
850
|
+
const q2 = shellQuote(path);
|
|
851
|
+
const tool = codec === "zstd" ? "zstd" : codec === "gzip" ? "gzip" : null;
|
|
852
|
+
const lines = [];
|
|
853
|
+
if (tool && clear) {
|
|
854
|
+
lines.push(
|
|
855
|
+
`command -v ${tool} >/dev/null 2>&1 || { echo "openship: ${tool} is not available inside this service, so the archive cannot be decompressed. Refusing to clear ${path}: nothing was written and your data is untouched." >&2; exit 90; }`
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
lines.push(`mkdir -p ${q2} || exit 91`);
|
|
859
|
+
if (clear) {
|
|
860
|
+
lines.push(`find ${q2} -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true`);
|
|
861
|
+
}
|
|
862
|
+
return lines.join("\n");
|
|
863
|
+
}
|
|
864
|
+
function pathSlug(path) {
|
|
865
|
+
return path.replace(/^\/+/, "").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80) || "root";
|
|
866
|
+
}
|
|
867
|
+
var PathArchiveProducerImpl = class {
|
|
868
|
+
kind = "path";
|
|
869
|
+
// No detects() — explicit selection only. See the module doc.
|
|
870
|
+
async *produce(service, executor, opts) {
|
|
871
|
+
const requested = opts.paths ?? [];
|
|
872
|
+
if (requested.length === 0) {
|
|
873
|
+
throw new Error(
|
|
874
|
+
`Nothing to back up for service "${service.name}": this policy archives files and folders but names none. Set \`payloadConfig.paths\` to one or more absolute paths inside the service.`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
const paths = [];
|
|
878
|
+
for (const entry of requested) {
|
|
879
|
+
const problem = validateBackupPath(entry);
|
|
880
|
+
if (problem) throw new Error(problem);
|
|
881
|
+
const normalized = normalizeBackupPath(entry);
|
|
882
|
+
if (!paths.includes(normalized)) paths.push(normalized);
|
|
883
|
+
}
|
|
884
|
+
const codec = opts.compression ?? await detectDumpCodec(service, executor);
|
|
885
|
+
for (const path of paths) {
|
|
886
|
+
const { stdout, awaitExit } = await executor.execStream(
|
|
887
|
+
service,
|
|
888
|
+
safeDumpCommand(
|
|
889
|
+
// `-C <dir> .` rather than `tar -c <dir>`: the archive holds the directory's
|
|
890
|
+
// CONTENTS at relative paths, so it can be extracted into a different
|
|
891
|
+
// location — which is what makes an artifact captured from /data/app
|
|
892
|
+
// restorable after the app moved to /srv/app.
|
|
893
|
+
`tar -c -C ${shellQuote(path)} ${(opts.exclude ?? []).map((p2) => `--exclude=${shellQuote(p2)}`).join(" ")} .`.replace(/\s+/g, " "),
|
|
894
|
+
codec,
|
|
895
|
+
guardSourcePath(path, service.name)
|
|
896
|
+
)
|
|
897
|
+
);
|
|
898
|
+
yield {
|
|
899
|
+
name: `path-${pathSlug(path)}.tar${codecSuffix(codec)}`,
|
|
900
|
+
stream: stdout,
|
|
901
|
+
payloadKind: "path",
|
|
902
|
+
metadata: {
|
|
903
|
+
// The restore target. Recorded per-artifact rather than read back off the
|
|
904
|
+
// policy, because a policy is editable and soft-deletable: an artifact has to
|
|
905
|
+
// carry everything needed to put it back, or it stops being a restore point
|
|
906
|
+
// the moment somebody edits the schedule.
|
|
907
|
+
path,
|
|
908
|
+
compression: codec,
|
|
909
|
+
// See the module doc: always crash-consistent, and the artifact says so
|
|
910
|
+
// rather than leaving an operator to assume otherwise.
|
|
911
|
+
consistency: "crash"
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
const exit = await awaitExit;
|
|
915
|
+
if (exit.code !== 0) {
|
|
916
|
+
throw new Error(
|
|
917
|
+
`Archiving ${path} from service "${service.name}" failed (exit ${exit.code}): ${exit.stderr.slice(0, 500)}`
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async restore(service, executor, artifact, opts) {
|
|
923
|
+
const recorded = artifact.metadata.path;
|
|
924
|
+
if (typeof recorded !== "string" || recorded.trim() === "") {
|
|
925
|
+
throw new Error(
|
|
926
|
+
`Artifact ${artifact.key} cannot restore: metadata.path missing. This artifact may have been produced by a different producer.`
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
const path = normalizeBackupPath(recorded);
|
|
930
|
+
const problem = validateBackupPath(path);
|
|
931
|
+
if (problem) throw new Error(`Artifact ${artifact.key} records an unusable path. ${problem}`);
|
|
932
|
+
const codec = recordedCodec(artifact.metadata.compression);
|
|
933
|
+
const wantsClear = opts.clearTarget === true;
|
|
934
|
+
if (wantsClear) {
|
|
935
|
+
const unsafe = validateClearPath(path);
|
|
936
|
+
if (unsafe) throw new Error(unsafe);
|
|
937
|
+
}
|
|
938
|
+
const body = await artifact.open();
|
|
939
|
+
const exit = await executor.pipeIntoCommand(
|
|
940
|
+
service,
|
|
941
|
+
safeRestoreCommand(
|
|
942
|
+
codec,
|
|
943
|
+
`tar -x -C ${shellQuote(path)}`,
|
|
944
|
+
guardRestorePath(path, codec, wantsClear)
|
|
945
|
+
),
|
|
946
|
+
body,
|
|
947
|
+
{ timeoutMs: payloadSpec("path").restoreTimeoutMs }
|
|
948
|
+
);
|
|
949
|
+
if (exit.code !== 0) {
|
|
950
|
+
throw new Error(
|
|
951
|
+
`Restoring ${path} into service "${service.name}" failed (exit ${exit.code}): ${exit.stderr.slice(0, 500)}`
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
var PathArchiveProducer = new PathArchiveProducerImpl();
|
|
957
|
+
registerProducer(PathArchiveProducer);
|
|
958
|
+
|
|
846
959
|
// ../../packages/adapters/src/backup/producers/volume.ts
|
|
847
960
|
var VolumeCopyProducerImpl = class {
|
|
848
961
|
kind = "volume";
|
|
@@ -851,16 +964,26 @@ var VolumeCopyProducerImpl = class {
|
|
|
851
964
|
async *produce(service, executor, opts) {
|
|
852
965
|
const sources = await executor.listSources(service);
|
|
853
966
|
if (sources.length === 0) {
|
|
854
|
-
|
|
967
|
+
throw new Error(
|
|
968
|
+
`Nothing to back up: no volumes or bind mounts found for service "${service.name}". ` + (service.containerId ? `Its live container (${service.containerId.slice(0, 12)}) reports no volume or bind mounts, so there is no persistent data here to capture.` : `No container was resolved for it, so only the service's recorded volumes could be checked and it has none. If this service does hold data, redeploy it so Openship records its volumes, or point the policy at a database payload instead of a volume snapshot.`)
|
|
969
|
+
);
|
|
855
970
|
}
|
|
856
971
|
const selected = opts.sourceIds && opts.sourceIds.length > 0 ? sources.filter((s2) => opts.sourceIds.includes(s2.id)) : sources.filter((s2) => s2.type !== "tmpfs");
|
|
972
|
+
if (selected.length === 0) {
|
|
973
|
+
const available = sources.map((s2) => `${s2.id} (${s2.type})`).join(", ");
|
|
974
|
+
throw new Error(
|
|
975
|
+
`Nothing to back up for service "${service.name}": ` + (opts.sourceIds && opts.sourceIds.length > 0 ? `the policy selects ${opts.sourceIds.join(", ")}, none of which is a source on this service. Available: ${available}.` : `its only mounts are tmpfs, which hold no data across a restart. Found: ${available}.`)
|
|
976
|
+
);
|
|
977
|
+
}
|
|
857
978
|
for (const source of selected) {
|
|
979
|
+
const compression = opts.compression ?? "zstd";
|
|
858
980
|
const { stdout, awaitExit } = await executor.streamPath(service, source.id, {
|
|
859
|
-
compression
|
|
860
|
-
exclude: opts.exclude
|
|
981
|
+
compression,
|
|
982
|
+
exclude: opts.exclude,
|
|
983
|
+
quiesce: opts.quiesce
|
|
861
984
|
});
|
|
862
985
|
yield {
|
|
863
|
-
name: `volume-${sanitizeArtifactName(source.id)}.tar.zst`,
|
|
986
|
+
name: `volume-${sanitizeArtifactName(source.id)}.tar${compression === "zstd" ? ".zst" : compression === "gzip" ? ".gz" : ""}`,
|
|
864
987
|
stream: stdout,
|
|
865
988
|
payloadKind: "volume",
|
|
866
989
|
sizeHint: source.sizeHint,
|
|
@@ -869,7 +992,12 @@ var VolumeCopyProducerImpl = class {
|
|
|
869
992
|
volumeSource: source.source,
|
|
870
993
|
volumeTarget: source.target,
|
|
871
994
|
volumeType: source.type,
|
|
872
|
-
compression
|
|
995
|
+
compression,
|
|
996
|
+
// Recorded so the artifact says what it IS, rather than leaving an operator to
|
|
997
|
+
// assume. `crash` means the service kept writing while tar walked the tree —
|
|
998
|
+
// usable for most payloads, and the reason a database deserves a logical dump
|
|
999
|
+
// instead. `quiesced` means the container was frozen for the copy.
|
|
1000
|
+
consistency: opts.quiesce ? "quiesced" : "crash"
|
|
873
1001
|
}
|
|
874
1002
|
};
|
|
875
1003
|
const exit = await awaitExit;
|
|
@@ -889,7 +1017,12 @@ var VolumeCopyProducerImpl = class {
|
|
|
889
1017
|
}
|
|
890
1018
|
const stream = await artifact.open();
|
|
891
1019
|
await executor.receiveStream(service, volumeId, stream, {
|
|
892
|
-
|
|
1020
|
+
// Through the recorded-codec reader rather than a cast: the cast trusted jsonb to
|
|
1021
|
+
// hold one of three strings, and anything else (a typo in a hand-written policy,
|
|
1022
|
+
// a value from a newer build) reached the restore helper as a codec it cannot
|
|
1023
|
+
// build a pipeline for. `recordedCodec` maps the unknown to `zstd` — the only
|
|
1024
|
+
// safe guess, and for the same documented reason.
|
|
1025
|
+
compression: recordedCodec(artifact.metadata.compression),
|
|
893
1026
|
clearTarget: opts.clearTarget ?? true,
|
|
894
1027
|
signal: opts.signal
|
|
895
1028
|
});
|
|
@@ -903,10 +1036,116 @@ registerProducer(VolumeCopyProducer);
|
|
|
903
1036
|
|
|
904
1037
|
// ../../packages/adapters/src/backup/executors/docker.ts
|
|
905
1038
|
import { PassThrough } from "stream";
|
|
1039
|
+
|
|
1040
|
+
// ../../packages/adapters/src/runtime/docker-demux.ts
|
|
1041
|
+
var HEADER_BYTES = 8;
|
|
1042
|
+
var MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
1043
|
+
var STREAM_STDIN = 0;
|
|
1044
|
+
var STREAM_STDOUT = 1;
|
|
1045
|
+
var STREAM_STDERR = 2;
|
|
1046
|
+
function gateSink(sink, onBlock, onUnblock, onWriteAfterEnd) {
|
|
1047
|
+
let blocked = false;
|
|
1048
|
+
return {
|
|
1049
|
+
write(chunk) {
|
|
1050
|
+
if (sink.destroyed || sink.writableEnded) return onWriteAfterEnd();
|
|
1051
|
+
if (sink.write(chunk) !== false || blocked) return;
|
|
1052
|
+
blocked = true;
|
|
1053
|
+
onBlock();
|
|
1054
|
+
sink.once("drain", () => {
|
|
1055
|
+
blocked = false;
|
|
1056
|
+
onUnblock();
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
function demuxDockerStream(source, stdout, stderr, onError) {
|
|
1062
|
+
const src = source;
|
|
1063
|
+
let blockedSinks = 0;
|
|
1064
|
+
let pausedByUs = false;
|
|
1065
|
+
const onBlock = () => {
|
|
1066
|
+
blockedSinks += 1;
|
|
1067
|
+
if (pausedByUs) return;
|
|
1068
|
+
pausedByUs = true;
|
|
1069
|
+
src.pause();
|
|
1070
|
+
};
|
|
1071
|
+
const onUnblock = () => {
|
|
1072
|
+
blockedSinks -= 1;
|
|
1073
|
+
if (blockedSinks > 0 || !pausedByUs) return;
|
|
1074
|
+
pausedByUs = false;
|
|
1075
|
+
src.resume();
|
|
1076
|
+
};
|
|
1077
|
+
const onWriteAfterEnd = () => fail(
|
|
1078
|
+
new Error(
|
|
1079
|
+
"Docker stream was ended while payload was still arriving: the sink had already closed, so those bytes were dropped. What was written is not a complete artifact."
|
|
1080
|
+
)
|
|
1081
|
+
);
|
|
1082
|
+
const out = gateSink(stdout, onBlock, onUnblock, onWriteAfterEnd);
|
|
1083
|
+
const err = gateSink(stderr, onBlock, onUnblock, onWriteAfterEnd);
|
|
1084
|
+
let carry = null;
|
|
1085
|
+
let failed = false;
|
|
1086
|
+
const fail = (cause) => {
|
|
1087
|
+
if (failed) return;
|
|
1088
|
+
failed = true;
|
|
1089
|
+
carry = null;
|
|
1090
|
+
src.pause();
|
|
1091
|
+
onError?.(cause);
|
|
1092
|
+
stdout.destroy(cause);
|
|
1093
|
+
};
|
|
1094
|
+
src.on("data", (chunk) => {
|
|
1095
|
+
if (failed) return;
|
|
1096
|
+
let buf = carry === null ? chunk : Buffer.concat([carry, chunk]);
|
|
1097
|
+
carry = null;
|
|
1098
|
+
let offset = 0;
|
|
1099
|
+
while (buf.length - offset >= HEADER_BYTES) {
|
|
1100
|
+
const type = buf[offset];
|
|
1101
|
+
const length = buf.readUInt32BE(offset + 4);
|
|
1102
|
+
if (type !== STREAM_STDIN && type !== STREAM_STDOUT && type !== STREAM_STDERR) {
|
|
1103
|
+
fail(
|
|
1104
|
+
new Error(
|
|
1105
|
+
`Docker stream desynchronized: frame type ${type} at offset ${offset} is not stdin/stdout/stderr. Refusing to guess where the payload resumes \u2014 the bytes already read cannot be trusted as a complete artifact.`
|
|
1106
|
+
)
|
|
1107
|
+
);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (length > MAX_FRAME_BYTES) {
|
|
1111
|
+
fail(
|
|
1112
|
+
new Error(
|
|
1113
|
+
`Docker stream desynchronized: frame claims ${length} bytes, over the ${MAX_FRAME_BYTES}-byte sanity ceiling. Treating as a lost frame boundary rather than waiting for input that will never arrive.`
|
|
1114
|
+
)
|
|
1115
|
+
);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (buf.length - offset - HEADER_BYTES < length) break;
|
|
1119
|
+
const payload = buf.subarray(offset + HEADER_BYTES, offset + HEADER_BYTES + length);
|
|
1120
|
+
if (type === STREAM_STDOUT) out.write(payload);
|
|
1121
|
+
else if (type === STREAM_STDERR) err.write(payload);
|
|
1122
|
+
offset += HEADER_BYTES + length;
|
|
1123
|
+
}
|
|
1124
|
+
if (offset < buf.length) {
|
|
1125
|
+
carry = Buffer.from(buf.subarray(offset));
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
src.on("error", (cause) => fail(cause));
|
|
1129
|
+
src.on("end", () => {
|
|
1130
|
+
if (failed || carry === null) return;
|
|
1131
|
+
fail(
|
|
1132
|
+
new Error(
|
|
1133
|
+
`Docker stream ended mid-frame with ${carry.length} bytes of an incomplete frame buffered. The payload was cut short, so what was read is not a complete artifact.`
|
|
1134
|
+
)
|
|
1135
|
+
);
|
|
1136
|
+
});
|
|
1137
|
+
return {
|
|
1138
|
+
pending: () => carry !== null || src.readableLength > 0 || blockedSinks > 0
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// ../../packages/adapters/src/backup/executors/docker.ts
|
|
906
1143
|
var HELPER_IMAGE = "alpine:3";
|
|
1144
|
+
var ARTIFACT_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
907
1145
|
var DEFAULT_HELPER_IDLE_MS = 10 * 60 * 1e3;
|
|
908
1146
|
var DEFAULT_HELPER_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
|
|
909
1147
|
var EXIT_POLL_INTERVAL_MS = 2e3;
|
|
1148
|
+
var ATTACH_DRAIN_QUIET_MS = 3e3;
|
|
910
1149
|
function createIdleWatchdog(ms, message) {
|
|
911
1150
|
let timer;
|
|
912
1151
|
let done = false;
|
|
@@ -956,9 +1195,6 @@ function destroyQuietly(stream) {
|
|
|
956
1195
|
} catch {
|
|
957
1196
|
}
|
|
958
1197
|
}
|
|
959
|
-
function shellEscape4(s2) {
|
|
960
|
-
return `'${s2.replace(/'/g, "'\\''")}'`;
|
|
961
|
-
}
|
|
962
1198
|
function compressionFlag(compression) {
|
|
963
1199
|
switch (compression) {
|
|
964
1200
|
case "gzip":
|
|
@@ -988,6 +1224,26 @@ var DockerBackupExecutor = class {
|
|
|
988
1224
|
get dockerode() {
|
|
989
1225
|
return this.runtime.docker;
|
|
990
1226
|
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Freeze / thaw the target container around a copy — Docker's native cgroup freezer.
|
|
1229
|
+
*
|
|
1230
|
+
* `unpause` is best-effort and deliberately swallows "not paused": the thaw runs from a
|
|
1231
|
+
* `finally`, and a container the daemon already restarted (or that a human unpaused)
|
|
1232
|
+
* must not turn a completed backup into a failed one. `pause` is NOT best-effort — see
|
|
1233
|
+
* streamPath.
|
|
1234
|
+
*/
|
|
1235
|
+
async freeze(containerId) {
|
|
1236
|
+
await this.dockerode.getContainer(containerId).pause();
|
|
1237
|
+
}
|
|
1238
|
+
async thaw(containerId, label) {
|
|
1239
|
+
try {
|
|
1240
|
+
await this.dockerode.getContainer(containerId).unpause();
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
console.error(
|
|
1243
|
+
`[backup] ${label}: could not unpause container ${containerId.slice(0, 12)} after the copy \u2014 the service may still be FROZEN and needs \`docker unpause\`: ${err?.message ?? String(err)}`
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
991
1247
|
async listSources(service) {
|
|
992
1248
|
if (service.containerId) {
|
|
993
1249
|
try {
|
|
@@ -1007,7 +1263,7 @@ var DockerBackupExecutor = class {
|
|
|
1007
1263
|
return service.volumes.map((spec, i2) => {
|
|
1008
1264
|
const parsed = parseVolumeSpec(spec);
|
|
1009
1265
|
if (!parsed || !parsed.source) return null;
|
|
1010
|
-
const source = parsed.type === "volume" && service.namespaceVolumes ?
|
|
1266
|
+
const source = parsed.type === "volume" && service.namespaceVolumes ? ensureScopedVolumeName(service.projectSlug, parsed.source) : parsed.source;
|
|
1011
1267
|
return {
|
|
1012
1268
|
id: `${source}-${i2}`,
|
|
1013
1269
|
source,
|
|
@@ -1016,6 +1272,31 @@ var DockerBackupExecutor = class {
|
|
|
1016
1272
|
};
|
|
1017
1273
|
}).filter((x2) => x2 !== null);
|
|
1018
1274
|
}
|
|
1275
|
+
/**
|
|
1276
|
+
* The container's own `Config.Env`, as `KEY=value` pairs already split.
|
|
1277
|
+
*
|
|
1278
|
+
* This is the credentials source for every service Openship did not deploy
|
|
1279
|
+
* itself — see `BackupExecutor.readContainerEnv`. Returns `{}` rather than
|
|
1280
|
+
* throwing on any failure: it is an ENRICHMENT of the handle's env, so a
|
|
1281
|
+
* container that has gone away, or a daemon that refuses the inspect, must leave
|
|
1282
|
+
* the caller exactly as well off as before it asked.
|
|
1283
|
+
*/
|
|
1284
|
+
async readContainerEnv(service) {
|
|
1285
|
+
if (!service.containerId) return {};
|
|
1286
|
+
try {
|
|
1287
|
+
const data = await this.dockerode.getContainer(service.containerId).inspect();
|
|
1288
|
+
const pairs = data.Config?.Env ?? [] ?? [];
|
|
1289
|
+
const env = {};
|
|
1290
|
+
for (const pair of pairs) {
|
|
1291
|
+
const eq = pair.indexOf("=");
|
|
1292
|
+
if (eq <= 0) continue;
|
|
1293
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
1294
|
+
}
|
|
1295
|
+
return env;
|
|
1296
|
+
} catch {
|
|
1297
|
+
return {};
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1019
1300
|
async execStream(service, cmd, opts) {
|
|
1020
1301
|
if (!service.containerId) {
|
|
1021
1302
|
throw new Error(
|
|
@@ -1031,7 +1312,10 @@ var DockerBackupExecutor = class {
|
|
|
1031
1312
|
WorkingDir: opts?.cwd,
|
|
1032
1313
|
Env: opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${v2}`) : void 0
|
|
1033
1314
|
});
|
|
1034
|
-
const stream = await exec.
|
|
1315
|
+
const stream = await startExecStream(daemonConnectionFrom(this.dockerode), exec.id, {
|
|
1316
|
+
tty: false,
|
|
1317
|
+
stdin: false
|
|
1318
|
+
});
|
|
1035
1319
|
return this.attachDemuxed(this.dockerode, exec.id, stream, opts, {
|
|
1036
1320
|
label: `exec in service ${service.name}`
|
|
1037
1321
|
});
|
|
@@ -1046,9 +1330,9 @@ var DockerBackupExecutor = class {
|
|
|
1046
1330
|
throw new Error(`Backup source "${sourceId}" is tmpfs \u2014 not backupable`);
|
|
1047
1331
|
}
|
|
1048
1332
|
const compression = opts?.compression ?? "zstd";
|
|
1049
|
-
const excludeArgs = (opts?.exclude ?? []).flatMap((p2) => ["--exclude",
|
|
1333
|
+
const excludeArgs = (opts?.exclude ?? []).flatMap((p2) => ["--exclude", shellQuote(p2)]);
|
|
1050
1334
|
const tarFlags = compressionFlag(compression);
|
|
1051
|
-
const tarCmd =
|
|
1335
|
+
const tarCmd = `tar -c${tarFlags} -C /mnt ${excludeArgs.join(" ")} .`;
|
|
1052
1336
|
const helperImage = compression === "zstd" ? "alpine:3" : HELPER_IMAGE;
|
|
1053
1337
|
await this.ensureImage(helperImage);
|
|
1054
1338
|
const hostConfig = {
|
|
@@ -1068,14 +1352,52 @@ var DockerBackupExecutor = class {
|
|
|
1068
1352
|
// the helper exits. Nothing ever reads these logs.
|
|
1069
1353
|
LogConfig: { Type: "none", Config: {} }
|
|
1070
1354
|
};
|
|
1071
|
-
|
|
1355
|
+
const quiesceId = opts?.quiesce ? service.containerId : null;
|
|
1356
|
+
if (opts?.quiesce && !quiesceId) {
|
|
1357
|
+
throw new Error(
|
|
1358
|
+
`Cannot quiesce service "${service.name}" for a consistent copy: it has no running container to freeze. Either deploy it, or take the copy without quiesce and accept a crash-consistent archive.`
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
if (quiesceId) await this.freeze(quiesceId);
|
|
1362
|
+
const handed = await this.handOffHelper(
|
|
1072
1363
|
{
|
|
1073
1364
|
Image: helperImage,
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1365
|
+
// Built by the SAME helper the DB producers use, for the same reason: a shell
|
|
1366
|
+
// pipeline exits with its LAST command's status, so `tar -c … | zstd` reported
|
|
1367
|
+
// ZSTD's success and a tar that failed mid-archive — a permissions error, a
|
|
1368
|
+
// vanishing file, ENOSPC in the pipe — came back as exit 0 over a truncated
|
|
1369
|
+
// archive that the run then recorded as a complete backup. Closing that for
|
|
1370
|
+
// `pg_dump | zstd` and leaving it here would have been the same bug with a
|
|
1371
|
+
// different payload, and the volume producer is the DEFAULT one.
|
|
1372
|
+
//
|
|
1373
|
+
// gzip and none need no pipeline at all (busybox tar compresses in-process via
|
|
1374
|
+
// `tarFlags`), so those pass `"none"` and keep tar's own status directly.
|
|
1375
|
+
Cmd: safeDumpCommand(
|
|
1376
|
+
tarCmd,
|
|
1377
|
+
compression === "zstd" ? "zstd" : "none",
|
|
1378
|
+
[
|
|
1379
|
+
// REFUSE an empty mount before doing anything else.
|
|
1380
|
+
//
|
|
1381
|
+
// The daemon CREATES a missing bind directory and a missing named volume
|
|
1382
|
+
// when it honors HostConfig.Binds, so a source that is not on this host
|
|
1383
|
+
// produces a helper that starts fine and a `tar -c` of nothing that exits
|
|
1384
|
+
// 0. That archive is ~350 bytes compressed, so the orchestrator's
|
|
1385
|
+
// zero-byte guard passes and the run is recorded as a successful backup —
|
|
1386
|
+
// a nightly green tick over an external disk that failed to mount, a data
|
|
1387
|
+
// directory that was renamed under a still-running container, or a volume
|
|
1388
|
+
// that `docker system prune --volumes` took.
|
|
1389
|
+
//
|
|
1390
|
+
// An intentionally-empty source fails too, which is the deliberate half of
|
|
1391
|
+
// the trade: an archive of nothing is not a restore point either way, and
|
|
1392
|
+
// the operator has `payloadConfig.sourceIds` to narrow the policy. Silence
|
|
1393
|
+
// is the only outcome that cannot be recovered from.
|
|
1394
|
+
`SRC=${shellQuote(sourceId)}`,
|
|
1395
|
+
`[ -n "$(ls -A /mnt 2>/dev/null)" ] || { echo "openship: backup source $SRC is empty or missing on this host, so this archive would contain nothing. Refusing to record it as a backup. If it is a mount, check that it is mounted; if it is empty on purpose, narrow the policy's sourceIds." >&2; exit 91; }`,
|
|
1396
|
+
// zstd is not in alpine:3, so the helper installs it. Second, so a doomed
|
|
1397
|
+
// capture does not pay the ~20s fetch first.
|
|
1398
|
+
compression === "zstd" ? "apk add --no-cache zstd >/dev/null 2>&1" : null
|
|
1399
|
+
].filter(Boolean).join("\n")
|
|
1400
|
+
),
|
|
1079
1401
|
HostConfig: hostConfig,
|
|
1080
1402
|
AttachStdout: true,
|
|
1081
1403
|
AttachStderr: true,
|
|
@@ -1085,8 +1407,8 @@ var DockerBackupExecutor = class {
|
|
|
1085
1407
|
NetworkDisabled: compression !== "zstd"
|
|
1086
1408
|
},
|
|
1087
1409
|
async (helper) => {
|
|
1088
|
-
const stream = await helper.
|
|
1089
|
-
|
|
1410
|
+
const stream = await startAttachStream(daemonConnectionFrom(this.dockerode), helper.id, {
|
|
1411
|
+
stdin: false,
|
|
1090
1412
|
stdout: true,
|
|
1091
1413
|
stderr: true
|
|
1092
1414
|
});
|
|
@@ -1097,7 +1419,22 @@ var DockerBackupExecutor = class {
|
|
|
1097
1419
|
label: `Backup of "${sourceId}" on service ${service.name}`
|
|
1098
1420
|
});
|
|
1099
1421
|
}
|
|
1100
|
-
)
|
|
1422
|
+
).catch(async (err) => {
|
|
1423
|
+
if (quiesceId) await this.thaw(quiesceId, `backup of "${sourceId}"`);
|
|
1424
|
+
throw err;
|
|
1425
|
+
});
|
|
1426
|
+
if (!quiesceId) return handed;
|
|
1427
|
+
return {
|
|
1428
|
+
stdout: handed.stdout,
|
|
1429
|
+
// Thaw when the copy is DONE, however it ends. `awaitExit` settles once the helper
|
|
1430
|
+
// has exited and its output has drained, which is precisely the window the freeze has
|
|
1431
|
+
// to cover; unfreezing when `streamPath` returned would thaw before a single byte had
|
|
1432
|
+
// been read. The original promise is handed back so a caller still sees its value or
|
|
1433
|
+
// its rejection.
|
|
1434
|
+
awaitExit: handed.awaitExit.finally(
|
|
1435
|
+
() => this.thaw(quiesceId, `backup of "${sourceId}"`)
|
|
1436
|
+
)
|
|
1437
|
+
};
|
|
1101
1438
|
}
|
|
1102
1439
|
async receiveStream(service, targetSourceId, body, opts) {
|
|
1103
1440
|
opts?.signal?.throwIfAborted();
|
|
@@ -1112,16 +1449,18 @@ var DockerBackupExecutor = class {
|
|
|
1112
1449
|
const compression = opts?.compression ?? "zstd";
|
|
1113
1450
|
const helperImage = compression === "zstd" ? "alpine:3" : HELPER_IMAGE;
|
|
1114
1451
|
await this.ensureImage(helperImage);
|
|
1115
|
-
const
|
|
1116
|
-
const
|
|
1452
|
+
const restoreCodec = compression === "none" ? "none" : compression;
|
|
1453
|
+
const prelude = [
|
|
1454
|
+
compression === "zstd" ? "apk add --no-cache zstd >/dev/null 2>&1 || true" : null,
|
|
1455
|
+
compression === "zstd" ? 'command -v zstd >/dev/null 2>&1 || { echo "openship: zstd is not available in the restore helper and could not be installed (the host may have no egress). Refusing to clear the target: nothing was written and your data is untouched." >&2; exit 90; }' : null,
|
|
1456
|
+
compression === "gzip" ? 'command -v gzip >/dev/null 2>&1 || { echo "openship: gzip is not available in the restore helper. Refusing to clear the target; your data is untouched." >&2; exit 90; }' : null,
|
|
1457
|
+
// Only now, with every tool proven present.
|
|
1458
|
+
opts?.clearTarget ? "find /mnt -mindepth 1 -delete 2>/dev/null || true" : null
|
|
1459
|
+
].filter(Boolean).join("\n");
|
|
1117
1460
|
return this.withHelper(
|
|
1118
1461
|
{
|
|
1119
1462
|
Image: helperImage,
|
|
1120
|
-
Cmd:
|
|
1121
|
-
"sh",
|
|
1122
|
-
"-c",
|
|
1123
|
-
compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${untarCmd}` : untarCmd
|
|
1124
|
-
],
|
|
1463
|
+
Cmd: safeRestoreCommand(restoreCodec, "tar -x -C /mnt", prelude),
|
|
1125
1464
|
// AutoRemove is OFF deliberately, and this is the fix for the `404 no such
|
|
1126
1465
|
// container` in #434: with it on, the daemon could reap the helper before
|
|
1127
1466
|
// /containers/{id}/wait answered, and the exit status was then unknowable.
|
|
@@ -1406,47 +1745,69 @@ var DockerBackupExecutor = class {
|
|
|
1406
1745
|
const { PassThrough: PassThrough3 } = await import("stream");
|
|
1407
1746
|
const stdoutSink = new PassThrough3();
|
|
1408
1747
|
stdoutSink.resume();
|
|
1748
|
+
stdoutSink.on("error", () => {
|
|
1749
|
+
});
|
|
1409
1750
|
const stderrSink = new PassThrough3();
|
|
1410
1751
|
stderrSink.on("data", (chunk) => {
|
|
1411
1752
|
if (stderrChunks.length < 16) stderrChunks.push(chunk);
|
|
1412
1753
|
});
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
const
|
|
1419
|
-
try {
|
|
1420
|
-
stream.end?.();
|
|
1421
|
-
} catch {
|
|
1422
|
-
}
|
|
1423
|
-
}, opts.timeoutMs) : null;
|
|
1424
|
-
return new Promise((resolve3, reject) => {
|
|
1754
|
+
demuxDockerStream(stream, stdoutSink, stderrSink);
|
|
1755
|
+
const stderrTail = () => Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024);
|
|
1756
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
|
|
1757
|
+
let bodyEnded = false;
|
|
1758
|
+
let polling = true;
|
|
1759
|
+
const attachEnded = new Promise((resolve2, reject) => {
|
|
1425
1760
|
body.on("error", (err) => {
|
|
1426
|
-
if (timer) clearTimeout(timer);
|
|
1427
1761
|
try {
|
|
1428
1762
|
stream.end?.();
|
|
1429
1763
|
} catch {
|
|
1430
1764
|
}
|
|
1431
1765
|
reject(err);
|
|
1432
1766
|
});
|
|
1767
|
+
body.on("end", () => {
|
|
1768
|
+
bodyEnded = true;
|
|
1769
|
+
});
|
|
1433
1770
|
stream.on("end", async () => {
|
|
1434
|
-
if (timer) clearTimeout(timer);
|
|
1435
1771
|
try {
|
|
1436
|
-
|
|
1437
|
-
code: await resolveExecExitCode(exec, exec.id),
|
|
1438
|
-
stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
|
|
1439
|
-
});
|
|
1772
|
+
resolve2({ code: await resolveExecExitCode(exec, exec.id), stderr: stderrTail() });
|
|
1440
1773
|
} catch (err) {
|
|
1441
1774
|
reject(err);
|
|
1442
1775
|
}
|
|
1443
1776
|
});
|
|
1444
|
-
stream.on("error", (err) =>
|
|
1445
|
-
if (timer) clearTimeout(timer);
|
|
1446
|
-
reject(err);
|
|
1447
|
-
});
|
|
1777
|
+
stream.on("error", (err) => reject(err));
|
|
1448
1778
|
body.pipe(stream);
|
|
1449
1779
|
});
|
|
1780
|
+
const polledExit = async () => {
|
|
1781
|
+
while (polling) {
|
|
1782
|
+
await new Promise((r2) => {
|
|
1783
|
+
const t8 = setTimeout(r2, EXIT_POLL_INTERVAL_MS);
|
|
1784
|
+
t8.unref?.();
|
|
1785
|
+
});
|
|
1786
|
+
if (!polling || !bodyEnded) continue;
|
|
1787
|
+
let info;
|
|
1788
|
+
try {
|
|
1789
|
+
info = await exec.inspect();
|
|
1790
|
+
} catch {
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
if (info.Running === false && typeof info.ExitCode === "number") {
|
|
1794
|
+
return { code: info.ExitCode, stderr: stderrTail() };
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return new Promise(() => {
|
|
1798
|
+
});
|
|
1799
|
+
};
|
|
1800
|
+
try {
|
|
1801
|
+
return await withTimeout(
|
|
1802
|
+
Promise.race([attachEnded, polledExit()]),
|
|
1803
|
+
timeoutMs,
|
|
1804
|
+
`Restore into service ${service.name} exceeded its ${Math.round(timeoutMs / 1e3)}s ceiling and was abandoned. The target may hold partial data.`
|
|
1805
|
+
);
|
|
1806
|
+
} finally {
|
|
1807
|
+
polling = false;
|
|
1808
|
+
destroyQuietly(stream);
|
|
1809
|
+
destroyQuietly(body);
|
|
1810
|
+
}
|
|
1450
1811
|
}
|
|
1451
1812
|
async stopService(service) {
|
|
1452
1813
|
if (!service.containerId) return;
|
|
@@ -1485,13 +1846,18 @@ var DockerBackupExecutor = class {
|
|
|
1485
1846
|
const label = meta?.label ?? "exec";
|
|
1486
1847
|
const idleMs = opts?.idleTimeoutMs ?? DEFAULT_HELPER_IDLE_MS;
|
|
1487
1848
|
const timeoutMs = opts?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
|
|
1488
|
-
const stdout = new PassThrough();
|
|
1849
|
+
const stdout = new PassThrough({ highWaterMark: ARTIFACT_BUFFER_BYTES });
|
|
1489
1850
|
const stderrChunks = [];
|
|
1490
1851
|
const stderrSink = new PassThrough();
|
|
1491
1852
|
stderrSink.on("data", (chunk) => {
|
|
1492
1853
|
if (stderrChunks.length < 16) stderrChunks.push(chunk);
|
|
1493
1854
|
});
|
|
1494
|
-
|
|
1855
|
+
let demuxError = null;
|
|
1856
|
+
let rejectExit = null;
|
|
1857
|
+
demuxDockerStream(stream, stdout, stderrSink, (err) => {
|
|
1858
|
+
demuxError ??= err;
|
|
1859
|
+
rejectExit?.(err);
|
|
1860
|
+
});
|
|
1495
1861
|
let sinksEnded = false;
|
|
1496
1862
|
const endSinks = () => {
|
|
1497
1863
|
if (sinksEnded) return;
|
|
@@ -1506,11 +1872,15 @@ var DockerBackupExecutor = class {
|
|
|
1506
1872
|
`${label} produced no data for ${Math.round(idleMs / 1e3)}s and was abandoned.`
|
|
1507
1873
|
);
|
|
1508
1874
|
stream.on("data", () => watchdog.touch());
|
|
1875
|
+
stdout.on("drain", () => watchdog.touch());
|
|
1509
1876
|
const awaitExit = (async () => {
|
|
1510
|
-
const onEnd = new Promise((
|
|
1877
|
+
const onEnd = new Promise((resolve2, reject) => {
|
|
1878
|
+
rejectExit = reject;
|
|
1879
|
+
if (demuxError) reject(demuxError);
|
|
1511
1880
|
stream.on("end", async () => {
|
|
1881
|
+
if (demuxError) return reject(demuxError);
|
|
1512
1882
|
try {
|
|
1513
|
-
|
|
1883
|
+
resolve2({
|
|
1514
1884
|
code: await resolveExecExitCode(docker.getExec(execId), execId),
|
|
1515
1885
|
stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
|
|
1516
1886
|
});
|
|
@@ -1528,21 +1898,33 @@ var DockerBackupExecutor = class {
|
|
|
1528
1898
|
);
|
|
1529
1899
|
} catch (err) {
|
|
1530
1900
|
stdout.destroy(err);
|
|
1901
|
+
destroyQuietly(stream);
|
|
1531
1902
|
throw err;
|
|
1532
1903
|
} finally {
|
|
1533
1904
|
watchdog.dispose();
|
|
1534
1905
|
}
|
|
1535
1906
|
})();
|
|
1907
|
+
void awaitExit.catch(() => {
|
|
1908
|
+
});
|
|
1536
1909
|
return { stdout, awaitExit };
|
|
1537
1910
|
}
|
|
1538
1911
|
demuxContainerStream(container, stream, opts) {
|
|
1539
|
-
const stdout = new PassThrough();
|
|
1912
|
+
const stdout = new PassThrough({ highWaterMark: ARTIFACT_BUFFER_BYTES });
|
|
1540
1913
|
const stderrChunks = [];
|
|
1541
1914
|
const stderrSink = new PassThrough();
|
|
1542
1915
|
stderrSink.on("data", (chunk) => {
|
|
1543
1916
|
if (stderrChunks.length < 16) stderrChunks.push(chunk);
|
|
1544
1917
|
});
|
|
1545
|
-
|
|
1918
|
+
let demuxError = null;
|
|
1919
|
+
const demux = demuxDockerStream(
|
|
1920
|
+
stream,
|
|
1921
|
+
stdout,
|
|
1922
|
+
stderrSink,
|
|
1923
|
+
(err) => {
|
|
1924
|
+
demuxError = err;
|
|
1925
|
+
}
|
|
1926
|
+
);
|
|
1927
|
+
let lastProgressAt = Date.now();
|
|
1546
1928
|
let sinksEnded = false;
|
|
1547
1929
|
let exited = false;
|
|
1548
1930
|
const reapIfDone = () => {
|
|
@@ -1562,7 +1944,14 @@ var DockerBackupExecutor = class {
|
|
|
1562
1944
|
opts.idleTimeoutMs,
|
|
1563
1945
|
`${opts.label} produced no data for ${Math.round(opts.idleTimeoutMs / 1e3)}s and was abandoned. Nothing was written; the archive is incomplete and was not kept.`
|
|
1564
1946
|
);
|
|
1565
|
-
stream.on("data", () =>
|
|
1947
|
+
stream.on("data", () => {
|
|
1948
|
+
lastProgressAt = Date.now();
|
|
1949
|
+
watchdog.touch();
|
|
1950
|
+
});
|
|
1951
|
+
stdout.on("drain", () => {
|
|
1952
|
+
lastProgressAt = Date.now();
|
|
1953
|
+
watchdog.touch();
|
|
1954
|
+
});
|
|
1566
1955
|
const awaitExit = (async () => {
|
|
1567
1956
|
try {
|
|
1568
1957
|
const res = await this.awaitHelperExit(container, watchdog, () => sinksEnded, {
|
|
@@ -1571,8 +1960,19 @@ var DockerBackupExecutor = class {
|
|
|
1571
1960
|
note: "Nothing was written; the archive is incomplete and was not kept."
|
|
1572
1961
|
});
|
|
1573
1962
|
exited = true;
|
|
1574
|
-
|
|
1963
|
+
const armEndBackstop = () => {
|
|
1964
|
+
const timer = setTimeout(() => {
|
|
1965
|
+
if (sinksEnded) return;
|
|
1966
|
+
if (Date.now() - lastProgressAt < ATTACH_DRAIN_QUIET_MS || demux.pending()) {
|
|
1967
|
+
return armEndBackstop();
|
|
1968
|
+
}
|
|
1969
|
+
endSinks();
|
|
1970
|
+
}, ATTACH_DRAIN_QUIET_MS);
|
|
1971
|
+
timer.unref?.();
|
|
1972
|
+
};
|
|
1973
|
+
armEndBackstop();
|
|
1575
1974
|
reapIfDone();
|
|
1975
|
+
if (demuxError) throw demuxError;
|
|
1576
1976
|
return {
|
|
1577
1977
|
code: res.StatusCode,
|
|
1578
1978
|
stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
|
|
@@ -1630,10 +2030,23 @@ var CloudBackupExecutor = class {
|
|
|
1630
2030
|
const ws = this.client.workspace(service.containerId);
|
|
1631
2031
|
const rt = await ws.runtime();
|
|
1632
2032
|
const fullCmd = this.composeShellCommand(cmd, opts);
|
|
1633
|
-
|
|
1634
|
-
|
|
2033
|
+
let resumeRead = null;
|
|
2034
|
+
const stdout = new Readable2({
|
|
2035
|
+
read() {
|
|
2036
|
+
const resume = resumeRead;
|
|
2037
|
+
resumeRead = null;
|
|
2038
|
+
resume?.();
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
const pushWithBackpressure = async (chunk) => {
|
|
2042
|
+
if (stdout.push(chunk)) return;
|
|
2043
|
+
if (stdout.destroyed) return;
|
|
2044
|
+
await new Promise((resolve2) => {
|
|
2045
|
+
resumeRead = resolve2;
|
|
2046
|
+
});
|
|
2047
|
+
};
|
|
1635
2048
|
let stderrBuf = "";
|
|
1636
|
-
const awaitExit = new Promise((
|
|
2049
|
+
const awaitExit = new Promise((resolve2, reject) => {
|
|
1637
2050
|
const timer = opts?.timeoutMs ? setTimeout(() => {
|
|
1638
2051
|
stdout.destroy(new Error(`exec timed out after ${opts.timeoutMs}ms`));
|
|
1639
2052
|
reject(new Error(`exec timed out after ${opts.timeoutMs}ms`));
|
|
@@ -1647,7 +2060,7 @@ var CloudBackupExecutor = class {
|
|
|
1647
2060
|
for await (const ev of stream) {
|
|
1648
2061
|
switch (ev.event) {
|
|
1649
2062
|
case "stdout":
|
|
1650
|
-
|
|
2063
|
+
await pushWithBackpressure(Buffer.from(ev.data, "base64"));
|
|
1651
2064
|
break;
|
|
1652
2065
|
case "stderr": {
|
|
1653
2066
|
const decoded = Buffer.from(ev.data, "base64").toString("utf8");
|
|
@@ -1666,11 +2079,15 @@ var CloudBackupExecutor = class {
|
|
|
1666
2079
|
}
|
|
1667
2080
|
stdout.push(null);
|
|
1668
2081
|
if (timer) clearTimeout(timer);
|
|
1669
|
-
|
|
2082
|
+
resolve2({ code: exitCode, stderr: stderrBuf });
|
|
1670
2083
|
} catch (err) {
|
|
1671
2084
|
if (timer) clearTimeout(timer);
|
|
1672
2085
|
stdout.destroy(err);
|
|
1673
2086
|
reject(err);
|
|
2087
|
+
} finally {
|
|
2088
|
+
const resume = resumeRead;
|
|
2089
|
+
resumeRead = null;
|
|
2090
|
+
resume?.();
|
|
1674
2091
|
}
|
|
1675
2092
|
};
|
|
1676
2093
|
void pump();
|
|
@@ -1680,9 +2097,9 @@ var CloudBackupExecutor = class {
|
|
|
1680
2097
|
/** Bake env + cwd into a shell command since Oblien's ExecRunParams
|
|
1681
2098
|
* doesn't carry them. */
|
|
1682
2099
|
composeShellCommand(cmd, opts) {
|
|
1683
|
-
const envPrefix = opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${
|
|
1684
|
-
const cwdPrefix = opts?.cwd ? `cd ${
|
|
1685
|
-
const quotedCmd = cmd.map((arg) =>
|
|
2100
|
+
const envPrefix = opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${shellQuote(v2)}`).join(" ") + " " : "";
|
|
2101
|
+
const cwdPrefix = opts?.cwd ? `cd ${shellQuote(opts.cwd)} && ` : "";
|
|
2102
|
+
const quotedCmd = cmd.map((arg) => shellQuote(arg)).join(" ");
|
|
1686
2103
|
return ["sh", "-c", `${cwdPrefix}${envPrefix}${quotedCmd}`];
|
|
1687
2104
|
}
|
|
1688
2105
|
async streamPath(service, sourceId, opts) {
|
|
@@ -1690,10 +2107,13 @@ var CloudBackupExecutor = class {
|
|
|
1690
2107
|
const compression = opts?.compression ?? "zstd";
|
|
1691
2108
|
const excludeArgs = (opts?.exclude ?? []).flatMap((p2) => [
|
|
1692
2109
|
"--exclude",
|
|
1693
|
-
|
|
2110
|
+
shellQuote(p2)
|
|
1694
2111
|
]);
|
|
1695
|
-
const tarCmd = compression === "
|
|
1696
|
-
return this.execStream(
|
|
2112
|
+
const tarCmd = compression === "gzip" ? `tar -cz -C ${shellQuote(path)} ${excludeArgs.join(" ")} .` : `tar -c -C ${shellQuote(path)} ${excludeArgs.join(" ")} .`;
|
|
2113
|
+
return this.execStream(
|
|
2114
|
+
service,
|
|
2115
|
+
safeDumpCommand(tarCmd, compression === "zstd" ? "zstd" : "none")
|
|
2116
|
+
);
|
|
1697
2117
|
}
|
|
1698
2118
|
async receiveStream(service, targetSourceId, body, opts) {
|
|
1699
2119
|
if (!service.containerId) {
|
|
@@ -1730,7 +2150,7 @@ var CloudBackupExecutor = class {
|
|
|
1730
2150
|
body,
|
|
1731
2151
|
dest: tmpPath
|
|
1732
2152
|
});
|
|
1733
|
-
const wrapped = `${this.composeShellCommand(cmd, opts).slice(2).join(" ")} < ${
|
|
2153
|
+
const wrapped = `${this.composeShellCommand(cmd, opts).slice(2).join(" ")} < ${shellQuote(tmpPath)}; ec=$?; rm -f ${shellQuote(tmpPath)}; exit $ec`;
|
|
1734
2154
|
const result = await this.execStream(service, ["sh", "-c", wrapped]);
|
|
1735
2155
|
result.stdout.resume();
|
|
1736
2156
|
return result.awaitExit;
|
|
@@ -1758,9 +2178,6 @@ var CloudBackupExecutor = class {
|
|
|
1758
2178
|
}
|
|
1759
2179
|
}
|
|
1760
2180
|
};
|
|
1761
|
-
function shellEscape5(s2) {
|
|
1762
|
-
return `'${s2.replace(/'/g, "'\\''")}'`;
|
|
1763
|
-
}
|
|
1764
2181
|
registerExecutor("cloud", (runtime) => {
|
|
1765
2182
|
if (!(runtime instanceof CloudRuntime)) {
|
|
1766
2183
|
throw new Error(
|
|
@@ -1775,7 +2192,9 @@ import { pipeline } from "stream/promises";
|
|
|
1775
2192
|
import { createWriteStream, promises as fs } from "fs";
|
|
1776
2193
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
1777
2194
|
import { tmpdir } from "os";
|
|
1778
|
-
import { join
|
|
2195
|
+
import { join } from "path";
|
|
2196
|
+
var DEFAULT_RESTORE_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
|
|
2197
|
+
var KILL_GRACE_MS = 3e4;
|
|
1779
2198
|
var BareBackupExecutor = class {
|
|
1780
2199
|
constructor(runtime) {
|
|
1781
2200
|
this.runtime = runtime;
|
|
@@ -1823,22 +2242,33 @@ var BareBackupExecutor = class {
|
|
|
1823
2242
|
const compression = opts?.compression ?? "zstd";
|
|
1824
2243
|
const excludeArgs = (opts?.exclude ?? []).flatMap((p2) => [
|
|
1825
2244
|
"--exclude",
|
|
1826
|
-
|
|
2245
|
+
shellQuote(p2)
|
|
1827
2246
|
]);
|
|
1828
2247
|
const path = sourceId;
|
|
1829
|
-
const tarCmd = compression === "zstd" ? `tar -c -C ${
|
|
2248
|
+
const tarCmd = compression === "zstd" ? `tar -c -C ${shellQuote(path)} ${excludeArgs.join(" ")} . | zstd -c -3` : compression === "gzip" ? `tar -cz -C ${shellQuote(path)} ${excludeArgs.join(" ")} .` : `tar -c -C ${shellQuote(path)} ${excludeArgs.join(" ")} .`;
|
|
1830
2249
|
return this.execStream(service, ["sh", "-c", tarCmd], { timeoutMs: opts?.timeoutMs });
|
|
1831
2250
|
}
|
|
1832
2251
|
async receiveStream(service, targetSourceId, body, opts) {
|
|
1833
2252
|
const compression = opts?.compression ?? "zstd";
|
|
1834
|
-
const
|
|
1835
|
-
const
|
|
1836
|
-
|
|
2253
|
+
const tool = compression === "zstd" ? "zstd" : compression === "gzip" ? "gzip" : null;
|
|
2254
|
+
const prelude = [
|
|
2255
|
+
`mkdir -p ${shellQuote(targetSourceId)}`,
|
|
2256
|
+
tool ? `command -v ${tool} >/dev/null 2>&1 || { echo "openship: '${tool}' is not installed on this host, so the artifact cannot be decompressed. Refusing to clear the target: nothing was written and your data is untouched." >&2; exit 90; }` : null,
|
|
2257
|
+
// Only now, with the decompressor proven present.
|
|
2258
|
+
opts?.clearTarget ? `rm -rf ${shellQuote(targetSourceId)}/* ${shellQuote(targetSourceId)}/.[!.]* 2>/dev/null || true` : null
|
|
2259
|
+
].filter(Boolean).join("\n");
|
|
2260
|
+
const cmd = safeRestoreCommand(
|
|
2261
|
+
compression === "none" ? "none" : compression,
|
|
2262
|
+
`tar -x -C ${shellQuote(targetSourceId)}`,
|
|
2263
|
+
prelude
|
|
2264
|
+
);
|
|
1837
2265
|
let bytesWritten = 0;
|
|
1838
2266
|
body.on("data", (chunk) => {
|
|
1839
2267
|
bytesWritten += chunk.byteLength;
|
|
1840
2268
|
});
|
|
1841
|
-
const exit = await this.pipeIntoCommand(service,
|
|
2269
|
+
const exit = await this.pipeIntoCommand(service, cmd, body, {
|
|
2270
|
+
timeoutMs: opts?.timeoutMs
|
|
2271
|
+
});
|
|
1842
2272
|
if (exit.code !== 0) {
|
|
1843
2273
|
throw new Error(
|
|
1844
2274
|
`receiveStream tar-extract exited ${exit.code}: ${exit.stderr.slice(0, 500)}`
|
|
@@ -1849,19 +2279,34 @@ var BareBackupExecutor = class {
|
|
|
1849
2279
|
async pipeIntoCommand(service, cmd, body, opts) {
|
|
1850
2280
|
const exec = this.executor();
|
|
1851
2281
|
const id = randomBytes2(6).toString("hex");
|
|
1852
|
-
const localDir =
|
|
1853
|
-
const localFile =
|
|
2282
|
+
const localDir = join(tmpdir(), `openship-bare-restore-${id}`);
|
|
2283
|
+
const localFile = join(localDir, "artifact.bin");
|
|
1854
2284
|
const remoteDir = `/tmp/openship-bare-restore-${id}`;
|
|
1855
2285
|
const remoteFile = `${remoteDir}/artifact.bin`;
|
|
2286
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_RESTORE_TIMEOUT_MS;
|
|
2287
|
+
const ceiling = () => `Restore into ${service.name} exceeded its ${Math.round(timeoutMs / 1e3)}s ceiling and was abandoned. The target may hold partial data.`;
|
|
2288
|
+
if (exec.execWithInput) {
|
|
2289
|
+
const run = exec.execWithInput(`sh -c ${shellQuote(shellScript(cmd, opts))}`, body);
|
|
2290
|
+
const result = await withTimeout(run, timeoutMs, ceiling()).catch((err) => {
|
|
2291
|
+
body.destroy();
|
|
2292
|
+
throw err;
|
|
2293
|
+
});
|
|
2294
|
+
return { code: result.code, stderr: result.stderr };
|
|
2295
|
+
}
|
|
2296
|
+
const deadline = Date.now() + timeoutMs;
|
|
2297
|
+
const remaining = () => Math.max(1e3, deadline - Date.now());
|
|
1856
2298
|
await fs.mkdir(localDir, { recursive: true });
|
|
1857
2299
|
try {
|
|
1858
|
-
await pipeline(body, createWriteStream(localFile));
|
|
1859
|
-
await exec.transferIn(localDir, remoteDir);
|
|
1860
|
-
const wrapped = `${
|
|
1861
|
-
const runner = `sh -c ${
|
|
1862
|
-
const { stdout, awaitExit } = await this.execStream(service, ["sh", "-c", runner],
|
|
2300
|
+
await withTimeout(pipeline(body, createWriteStream(localFile)), remaining(), ceiling());
|
|
2301
|
+
await withTimeout(exec.transferIn(localDir, remoteDir), remaining(), ceiling());
|
|
2302
|
+
const wrapped = `${shellScript(cmd, opts)} < ${shellQuote(remoteFile)}`;
|
|
2303
|
+
const runner = `sh -c ${shellQuote(wrapped)}; ec=$?; rm -rf ${shellQuote(remoteDir)}; exit $ec`;
|
|
2304
|
+
const { stdout, awaitExit } = await this.execStream(service, ["sh", "-c", runner], {
|
|
2305
|
+
...opts,
|
|
2306
|
+
timeoutMs: remaining()
|
|
2307
|
+
});
|
|
1863
2308
|
stdout.resume();
|
|
1864
|
-
return await awaitExit;
|
|
2309
|
+
return await withTimeout(awaitExit, remaining() + KILL_GRACE_MS, ceiling());
|
|
1865
2310
|
} finally {
|
|
1866
2311
|
await fs.rm(localDir, { recursive: true, force: true }).catch(() => {
|
|
1867
2312
|
});
|
|
@@ -1878,14 +2323,14 @@ var BareBackupExecutor = class {
|
|
|
1878
2323
|
return true;
|
|
1879
2324
|
}
|
|
1880
2325
|
};
|
|
1881
|
-
function
|
|
1882
|
-
const envPrefix = opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${
|
|
1883
|
-
const cwdPrefix = opts?.cwd ? `cd ${
|
|
1884
|
-
const inner = cmd.length >= 3 && cmd[0] === "sh" && cmd[1] === "-c" ? cmd.slice(2).join(" ") : cmd.map((a2) =>
|
|
1885
|
-
return
|
|
2326
|
+
function shellScript(cmd, opts) {
|
|
2327
|
+
const envPrefix = opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${shellQuote(v2)}`).join(" ") + " " : "";
|
|
2328
|
+
const cwdPrefix = opts?.cwd ? `cd ${shellQuote(opts.cwd)} && ` : "";
|
|
2329
|
+
const inner = cmd.length >= 3 && cmd[0] === "sh" && cmd[1] === "-c" ? cmd.slice(2).join(" ") : cmd.map((a2) => shellQuote(a2)).join(" ");
|
|
2330
|
+
return `${cwdPrefix}${envPrefix}${inner}`;
|
|
1886
2331
|
}
|
|
1887
|
-
function
|
|
1888
|
-
return `
|
|
2332
|
+
function composeShell(cmd, opts) {
|
|
2333
|
+
return `sh -c ${shellQuote(shellScript(cmd, opts))}`;
|
|
1889
2334
|
}
|
|
1890
2335
|
registerExecutor("bare", (runtime) => {
|
|
1891
2336
|
if (!(runtime instanceof BareRuntime)) {
|
|
@@ -1899,7 +2344,7 @@ registerExecutor("bare", (runtime) => {
|
|
|
1899
2344
|
// ../../packages/adapters/src/backup/destinations/local.ts
|
|
1900
2345
|
import { createHash as createHash4, randomBytes as randomBytes3 } from "crypto";
|
|
1901
2346
|
import { createReadStream, createWriteStream as createWriteStream2, promises as fs2 } from "fs";
|
|
1902
|
-
import { dirname, join as
|
|
2347
|
+
import { dirname, join as join2, normalize, posix, resolve } from "path";
|
|
1903
2348
|
import { pipeline as pipeline2 } from "stream";
|
|
1904
2349
|
import { promisify } from "util";
|
|
1905
2350
|
var pipelineP = promisify(pipeline2);
|
|
@@ -1911,24 +2356,64 @@ var LocalDestinationImpl = class {
|
|
|
1911
2356
|
kind = "local";
|
|
1912
2357
|
capabilities = CAPS;
|
|
1913
2358
|
root;
|
|
2359
|
+
/**
|
|
2360
|
+
* Where artifacts written BEFORE `pathPrefix` moved out of the key builder live, or
|
|
2361
|
+
* null when this destination has no prefix and the two are the same place.
|
|
2362
|
+
*/
|
|
2363
|
+
legacyRoot;
|
|
1914
2364
|
constructor(row) {
|
|
1915
2365
|
if (!row.endpoint) {
|
|
1916
2366
|
throw new Error(`LocalDestination "${row.name}" has no endpoint (root path) configured`);
|
|
1917
2367
|
}
|
|
1918
|
-
|
|
2368
|
+
const base = resolve(row.endpoint);
|
|
2369
|
+
const prefix = (row.pathPrefix ?? "").replace(/^\/+|\/+$/g, "");
|
|
2370
|
+
this.root = prefix ? resolve(base, prefix) : base;
|
|
2371
|
+
this.legacyRoot = prefix ? base : null;
|
|
1919
2372
|
}
|
|
1920
2373
|
resolveKey(key) {
|
|
1921
2374
|
const normalized = normalize(key);
|
|
1922
2375
|
if (normalized.startsWith("..") || normalized.includes(`${normalize("../")}`)) {
|
|
1923
2376
|
throw new Error(`Invalid key (traversal): ${key}`);
|
|
1924
2377
|
}
|
|
1925
|
-
return
|
|
2378
|
+
return join2(this.root, normalized);
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* The path to READ `key` from, preferring the current layout and falling back to the
|
|
2382
|
+
* pre-prefix one.
|
|
2383
|
+
*
|
|
2384
|
+
* A run captured before this change recorded a key that already CONTAINS the prefix
|
|
2385
|
+
* (the key builder put it there), and its bytes sit at `endpoint/<prefix>/<key…>` —
|
|
2386
|
+
* which is `legacyRoot + key`, not `root + key`. Resolving it against the new root
|
|
2387
|
+
* would look for `endpoint/<prefix>/<prefix>/…` and find nothing, so an existing
|
|
2388
|
+
* restore point would quietly become unrestorable.
|
|
2389
|
+
*
|
|
2390
|
+
* Deliberately an existence check rather than a "does the key already start with the
|
|
2391
|
+
* prefix" string test: the key builder's first literal segment is `openship`, so a
|
|
2392
|
+
* destination whose prefix is also `openship` would make that test ambiguous. The
|
|
2393
|
+
* filesystem is not ambiguous. Reads only — `put` always writes the current layout,
|
|
2394
|
+
* so this shim shrinks to nothing as old runs age out of retention.
|
|
2395
|
+
*/
|
|
2396
|
+
async resolveKeyForRead(key) {
|
|
2397
|
+
const current = this.resolveKey(key);
|
|
2398
|
+
if (this.legacyRoot === null) return current;
|
|
2399
|
+
try {
|
|
2400
|
+
await fs2.access(current);
|
|
2401
|
+
return current;
|
|
2402
|
+
} catch {
|
|
2403
|
+
const legacy = join2(this.legacyRoot, normalize(key));
|
|
2404
|
+
try {
|
|
2405
|
+
await fs2.access(legacy);
|
|
2406
|
+
return legacy;
|
|
2407
|
+
} catch {
|
|
2408
|
+
return current;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
1926
2411
|
}
|
|
1927
2412
|
async preflight() {
|
|
1928
2413
|
try {
|
|
1929
2414
|
await fs2.mkdir(this.root, { recursive: true });
|
|
1930
2415
|
const probeKey = `.openship-probe-${randomBytes3(6).toString("hex")}`;
|
|
1931
|
-
const probePath =
|
|
2416
|
+
const probePath = join2(this.root, probeKey);
|
|
1932
2417
|
await fs2.writeFile(probePath, "ok");
|
|
1933
2418
|
await fs2.readFile(probePath);
|
|
1934
2419
|
await fs2.unlink(probePath);
|
|
@@ -1970,12 +2455,11 @@ var LocalDestinationImpl = class {
|
|
|
1970
2455
|
return { bytesWritten, etag: digest };
|
|
1971
2456
|
}
|
|
1972
2457
|
async get(key) {
|
|
1973
|
-
|
|
1974
|
-
return createReadStream(path);
|
|
2458
|
+
return createReadStream(await this.resolveKeyForRead(key));
|
|
1975
2459
|
}
|
|
1976
2460
|
async head(key) {
|
|
1977
2461
|
try {
|
|
1978
|
-
const stat = await fs2.stat(this.
|
|
2462
|
+
const stat = await fs2.stat(await this.resolveKeyForRead(key));
|
|
1979
2463
|
return {
|
|
1980
2464
|
sizeBytes: stat.size,
|
|
1981
2465
|
uploadedAt: stat.mtime
|
|
@@ -1987,7 +2471,7 @@ var LocalDestinationImpl = class {
|
|
|
1987
2471
|
}
|
|
1988
2472
|
}
|
|
1989
2473
|
async list(prefix, opts) {
|
|
1990
|
-
const root2 = this.
|
|
2474
|
+
const root2 = await this.resolveKeyForRead(prefix);
|
|
1991
2475
|
const entries = [];
|
|
1992
2476
|
const limit = opts?.limit ?? 1e3;
|
|
1993
2477
|
async function walk(dir, relBase) {
|
|
@@ -2002,7 +2486,7 @@ var LocalDestinationImpl = class {
|
|
|
2002
2486
|
for (const dirent of dirents) {
|
|
2003
2487
|
if (entries.length >= limit) return;
|
|
2004
2488
|
const childRel = posix.join(relBase, dirent.name);
|
|
2005
|
-
const childAbs =
|
|
2489
|
+
const childAbs = join2(dir, dirent.name);
|
|
2006
2490
|
if (dirent.isDirectory()) {
|
|
2007
2491
|
await walk(childAbs, childRel);
|
|
2008
2492
|
} else if (dirent.isFile()) {
|
|
@@ -2020,7 +2504,7 @@ var LocalDestinationImpl = class {
|
|
|
2020
2504
|
}
|
|
2021
2505
|
async delete(key) {
|
|
2022
2506
|
try {
|
|
2023
|
-
await fs2.unlink(this.
|
|
2507
|
+
await fs2.unlink(await this.resolveKeyForRead(key));
|
|
2024
2508
|
} catch (err) {
|
|
2025
2509
|
const e2 = err;
|
|
2026
2510
|
if (e2?.code !== "ENOENT") throw err;
|
|
@@ -11672,7 +12156,7 @@ var defaultProvider = (init = {}) => memoizeChain([
|
|
|
11672
12156
|
},
|
|
11673
12157
|
async (awsIdentityProperties) => {
|
|
11674
12158
|
init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromIni");
|
|
11675
|
-
const { fromIni } = await import("./dist-es-
|
|
12159
|
+
const { fromIni } = await import("./dist-es-AXFRS7PS.js");
|
|
11676
12160
|
return fromIni(init)(awsIdentityProperties);
|
|
11677
12161
|
},
|
|
11678
12162
|
async (awsIdentityProperties) => {
|
|
@@ -11724,14 +12208,14 @@ var readableStreamHasher = (hashCtor, readableStream) => {
|
|
|
11724
12208
|
const hash = new hashCtor();
|
|
11725
12209
|
const hashCalculator = new HashCalculator(hash);
|
|
11726
12210
|
readableStream.pipe(hashCalculator);
|
|
11727
|
-
return new Promise((
|
|
12211
|
+
return new Promise((resolve2, reject) => {
|
|
11728
12212
|
readableStream.on("error", (err) => {
|
|
11729
12213
|
hashCalculator.end();
|
|
11730
12214
|
reject(err);
|
|
11731
12215
|
});
|
|
11732
12216
|
hashCalculator.on("error", reject);
|
|
11733
12217
|
hashCalculator.on("finish", () => {
|
|
11734
|
-
hash.digest().then(
|
|
12218
|
+
hash.digest().then(resolve2).catch(reject);
|
|
11735
12219
|
});
|
|
11736
12220
|
});
|
|
11737
12221
|
};
|
|
@@ -12587,7 +13071,7 @@ to input.params.ContentLength in bytes.
|
|
|
12587
13071
|
}
|
|
12588
13072
|
}
|
|
12589
13073
|
async __abortTimeout(abortSignal) {
|
|
12590
|
-
return new Promise((
|
|
13074
|
+
return new Promise((resolve2, reject) => {
|
|
12591
13075
|
abortSignal.onabort = () => {
|
|
12592
13076
|
const abortError = new Error("Upload aborted.");
|
|
12593
13077
|
abortError.name = "AbortError";
|
|
@@ -12751,7 +13235,22 @@ var CAPS2 = /* @__PURE__ */ new Set([
|
|
|
12751
13235
|
]);
|
|
12752
13236
|
var PRESIGN_TTL_DEFAULT = 60 * 60;
|
|
12753
13237
|
var MULTIPART_PART_SIZE = 16 * 1024 * 1024;
|
|
12754
|
-
var
|
|
13238
|
+
var MULTIPART_MEMORY_BUDGET = 64 * 1024 * 1024;
|
|
13239
|
+
var MULTIPART_MAX_PARTS = 9e3;
|
|
13240
|
+
var UNKNOWN_SIZE_PART_SIZE = 32 * 1024 * 1024;
|
|
13241
|
+
function partSizeFor(sizeBytes) {
|
|
13242
|
+
if (!sizeBytes || sizeBytes <= 0) return UNKNOWN_SIZE_PART_SIZE;
|
|
13243
|
+
return Math.max(MULTIPART_PART_SIZE, Math.ceil(sizeBytes / MULTIPART_MAX_PARTS));
|
|
13244
|
+
}
|
|
13245
|
+
function queueSizeFor(partSize) {
|
|
13246
|
+
return Math.max(1, Math.floor(MULTIPART_MEMORY_BUDGET / partSize));
|
|
13247
|
+
}
|
|
13248
|
+
function partLimitCeiling(partSize) {
|
|
13249
|
+
return partSize * MULTIPART_MAX_PARTS;
|
|
13250
|
+
}
|
|
13251
|
+
function gib(bytes) {
|
|
13252
|
+
return Math.round(bytes / (1024 * 1024 * 1024));
|
|
13253
|
+
}
|
|
12755
13254
|
var S3DestinationImpl = class {
|
|
12756
13255
|
kind = "s3_compatible";
|
|
12757
13256
|
capabilities = CAPS2;
|
|
@@ -12804,6 +13303,7 @@ var S3DestinationImpl = class {
|
|
|
12804
13303
|
}
|
|
12805
13304
|
async put(key, body, opts) {
|
|
12806
13305
|
const fullKey = this.fullKey(key);
|
|
13306
|
+
const partSize = partSizeFor(opts.size);
|
|
12807
13307
|
const upload = new Upload({
|
|
12808
13308
|
client: this.client,
|
|
12809
13309
|
params: {
|
|
@@ -12813,15 +13313,34 @@ var S3DestinationImpl = class {
|
|
|
12813
13313
|
ContentType: opts.contentType ?? "application/octet-stream",
|
|
12814
13314
|
Metadata: opts.metadata
|
|
12815
13315
|
},
|
|
12816
|
-
|
|
12817
|
-
|
|
13316
|
+
// Derived, not fixed — see partSizeFor. An artifact whose size we know never trips
|
|
13317
|
+
// S3's 10,000-part limit; one we don't (a DB dump has no sizeHint) gets
|
|
13318
|
+
// UNKNOWN_SIZE_PART_SIZE, whose ceiling is enforced below rather than discovered
|
|
13319
|
+
// at CompleteMultipartUpload.
|
|
13320
|
+
partSize,
|
|
13321
|
+
queueSize: queueSizeFor(partSize),
|
|
12818
13322
|
leavePartsOnError: false
|
|
12819
13323
|
});
|
|
13324
|
+
const ceiling = partLimitCeiling(partSize);
|
|
12820
13325
|
let bytesWritten = 0;
|
|
13326
|
+
let ceilingError = null;
|
|
12821
13327
|
upload.on("httpUploadProgress", (progress) => {
|
|
12822
13328
|
if (typeof progress.loaded === "number") bytesWritten = progress.loaded;
|
|
13329
|
+
if (!ceilingError && bytesWritten > ceiling) {
|
|
13330
|
+
ceilingError = new Error(
|
|
13331
|
+
`Artifact exceeds what one S3 multipart upload can hold at this part size: ${gib(bytesWritten)} GiB transferred, ceiling ${gib(ceiling)} GiB (${MULTIPART_MAX_PARTS} parts \xD7 ${Math.round(partSize / (1024 * 1024))} MiB). Aborted early rather than failing at CompleteMultipartUpload after transferring everything. A source whose size is known scales its part size automatically; a streamed database dump cannot, so this artifact needs to be split or sent to a destination without a part limit.`
|
|
13332
|
+
);
|
|
13333
|
+
void upload.abort().catch(() => {
|
|
13334
|
+
});
|
|
13335
|
+
}
|
|
12823
13336
|
});
|
|
12824
|
-
|
|
13337
|
+
let result;
|
|
13338
|
+
try {
|
|
13339
|
+
result = await upload.done();
|
|
13340
|
+
} catch (err) {
|
|
13341
|
+
if (ceilingError) throw ceilingError;
|
|
13342
|
+
throw err;
|
|
13343
|
+
}
|
|
12825
13344
|
return {
|
|
12826
13345
|
bytesWritten,
|
|
12827
13346
|
etag: result.ETag?.replace(/"/g, "") ?? void 0
|
|
@@ -12956,6 +13475,7 @@ var CAPS3 = /* @__PURE__ */ new Set([
|
|
|
12956
13475
|
]);
|
|
12957
13476
|
var UPLOAD_STALL_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
12958
13477
|
var UPLOAD_STALL_PROBE_MS = 30 * 1e3;
|
|
13478
|
+
var UPLOAD_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
12959
13479
|
var SftpDestinationImpl = class {
|
|
12960
13480
|
kind;
|
|
12961
13481
|
capabilities = CAPS3;
|
|
@@ -13003,7 +13523,7 @@ var SftpDestinationImpl = class {
|
|
|
13003
13523
|
// ── Connection helper ────────────────────────────────────────────────
|
|
13004
13524
|
async withSftp(fn) {
|
|
13005
13525
|
const client = new Client2();
|
|
13006
|
-
return new Promise((
|
|
13526
|
+
return new Promise((resolve2, reject) => {
|
|
13007
13527
|
const cleanup = () => {
|
|
13008
13528
|
try {
|
|
13009
13529
|
client.end();
|
|
@@ -13020,7 +13540,7 @@ var SftpDestinationImpl = class {
|
|
|
13020
13540
|
fn(sftp).then(
|
|
13021
13541
|
(val) => {
|
|
13022
13542
|
cleanup();
|
|
13023
|
-
|
|
13543
|
+
resolve2(val);
|
|
13024
13544
|
},
|
|
13025
13545
|
(e2) => {
|
|
13026
13546
|
cleanup();
|
|
@@ -13040,12 +13560,12 @@ var SftpDestinationImpl = class {
|
|
|
13040
13560
|
let current = dir.startsWith("/") ? "" : ".";
|
|
13041
13561
|
for (const part of parts) {
|
|
13042
13562
|
current = current === "" ? `/${part}` : posix2.join(current, part);
|
|
13043
|
-
await new Promise((
|
|
13563
|
+
await new Promise((resolve2, reject) => {
|
|
13044
13564
|
sftp.mkdir(current, (err) => {
|
|
13045
|
-
if (!err) return
|
|
13565
|
+
if (!err) return resolve2();
|
|
13046
13566
|
sftp.stat(current, (statErr, stats) => {
|
|
13047
13567
|
if (statErr) return reject(err);
|
|
13048
|
-
if (stats.isDirectory()) return
|
|
13568
|
+
if (stats.isDirectory()) return resolve2();
|
|
13049
13569
|
reject(new Error(`${current} exists but is not a directory`));
|
|
13050
13570
|
});
|
|
13051
13571
|
});
|
|
@@ -13059,14 +13579,14 @@ var SftpDestinationImpl = class {
|
|
|
13059
13579
|
await this.withSftp(async (sftp) => {
|
|
13060
13580
|
await this.ensureDir(sftp, this.rootPath);
|
|
13061
13581
|
const probePath = posix2.join(this.rootPath, probeName);
|
|
13062
|
-
await new Promise((
|
|
13582
|
+
await new Promise((resolve2, reject) => {
|
|
13063
13583
|
const ws = sftp.createWriteStream(probePath);
|
|
13064
13584
|
ws.on("error", reject);
|
|
13065
|
-
ws.on("close", () =>
|
|
13585
|
+
ws.on("close", () => resolve2());
|
|
13066
13586
|
ws.end("ok");
|
|
13067
13587
|
});
|
|
13068
13588
|
await new Promise(
|
|
13069
|
-
(
|
|
13589
|
+
(resolve2, reject) => sftp.unlink(probePath, (err) => err ? reject(err) : resolve2())
|
|
13070
13590
|
);
|
|
13071
13591
|
});
|
|
13072
13592
|
return { ok: true };
|
|
@@ -13083,21 +13603,25 @@ var SftpDestinationImpl = class {
|
|
|
13083
13603
|
let bytesWritten = 0;
|
|
13084
13604
|
await this.withSftp(async (sftp) => {
|
|
13085
13605
|
await this.ensureDir(sftp, posix2.dirname(target));
|
|
13086
|
-
await new Promise((
|
|
13087
|
-
const ws = sftp.createWriteStream(tmp);
|
|
13606
|
+
await new Promise((resolve2, reject) => {
|
|
13607
|
+
const ws = sftp.createWriteStream(tmp, { highWaterMark: UPLOAD_BUFFER_BYTES });
|
|
13088
13608
|
let lastProgressAt = Date.now();
|
|
13089
13609
|
let settled = false;
|
|
13090
13610
|
let watchdog;
|
|
13611
|
+
const onSettled = [];
|
|
13091
13612
|
const finish = (err) => {
|
|
13092
13613
|
if (settled) return;
|
|
13093
13614
|
settled = true;
|
|
13094
13615
|
if (watchdog) clearInterval(watchdog);
|
|
13616
|
+
for (const stop of onSettled) stop();
|
|
13617
|
+
const acked = ws.bytesWritten ?? 0;
|
|
13618
|
+
if (acked > bytesWritten) bytesWritten = acked;
|
|
13095
13619
|
if (err) {
|
|
13096
13620
|
ws.destroy();
|
|
13097
13621
|
body.destroy();
|
|
13098
13622
|
reject(err);
|
|
13099
13623
|
} else {
|
|
13100
|
-
|
|
13624
|
+
resolve2();
|
|
13101
13625
|
}
|
|
13102
13626
|
};
|
|
13103
13627
|
watchdog = setInterval(() => {
|
|
@@ -13111,21 +13635,25 @@ var SftpDestinationImpl = class {
|
|
|
13111
13635
|
watchdog.unref?.();
|
|
13112
13636
|
ws.on("error", (err) => finish(err));
|
|
13113
13637
|
ws.on("close", () => finish());
|
|
13114
|
-
|
|
13115
|
-
bytesWritten
|
|
13638
|
+
const trackProgress = setInterval(() => {
|
|
13639
|
+
const acked = ws.bytesWritten ?? 0;
|
|
13640
|
+
if (acked <= bytesWritten) return;
|
|
13641
|
+
bytesWritten = acked;
|
|
13116
13642
|
lastProgressAt = Date.now();
|
|
13117
|
-
});
|
|
13643
|
+
}, 1e3);
|
|
13644
|
+
trackProgress.unref?.();
|
|
13645
|
+
onSettled.push(() => clearInterval(trackProgress));
|
|
13118
13646
|
body.on("error", (err) => finish(err));
|
|
13119
13647
|
body.pipe(ws);
|
|
13120
13648
|
});
|
|
13121
|
-
await new Promise((
|
|
13649
|
+
await new Promise((resolve2, reject) => {
|
|
13122
13650
|
sftp.rename(tmp, target, (err) => {
|
|
13123
|
-
if (!err) return
|
|
13651
|
+
if (!err) return resolve2();
|
|
13124
13652
|
sftp.unlink(target, () => {
|
|
13125
13653
|
sftp.rename(
|
|
13126
13654
|
tmp,
|
|
13127
13655
|
target,
|
|
13128
|
-
(err2) => err2 ? reject(err2) :
|
|
13656
|
+
(err2) => err2 ? reject(err2) : resolve2()
|
|
13129
13657
|
);
|
|
13130
13658
|
});
|
|
13131
13659
|
});
|
|
@@ -13172,14 +13700,14 @@ var SftpDestinationImpl = class {
|
|
|
13172
13700
|
const target = this.fullPath(key);
|
|
13173
13701
|
try {
|
|
13174
13702
|
return await this.withSftp(
|
|
13175
|
-
(sftp) => new Promise((
|
|
13703
|
+
(sftp) => new Promise((resolve2, reject) => {
|
|
13176
13704
|
sftp.stat(target, (err, stats) => {
|
|
13177
13705
|
if (err) {
|
|
13178
13706
|
const code = err.code;
|
|
13179
|
-
if (code === 2) return
|
|
13707
|
+
if (code === 2) return resolve2(null);
|
|
13180
13708
|
return reject(err);
|
|
13181
13709
|
}
|
|
13182
|
-
|
|
13710
|
+
resolve2({
|
|
13183
13711
|
sizeBytes: stats.size,
|
|
13184
13712
|
uploadedAt: new Date(stats.mtime * 1e3)
|
|
13185
13713
|
});
|
|
@@ -13196,14 +13724,14 @@ var SftpDestinationImpl = class {
|
|
|
13196
13724
|
const entries = [];
|
|
13197
13725
|
await this.withSftp(async (sftp) => {
|
|
13198
13726
|
const walk = async (dir, relBase) => {
|
|
13199
|
-
const dirents = await new Promise((
|
|
13727
|
+
const dirents = await new Promise((resolve2, reject) => {
|
|
13200
13728
|
sftp.readdir(dir, (err, list) => {
|
|
13201
13729
|
if (err) {
|
|
13202
13730
|
const code = err.code;
|
|
13203
|
-
if (code === 2) return
|
|
13731
|
+
if (code === 2) return resolve2([]);
|
|
13204
13732
|
return reject(err);
|
|
13205
13733
|
}
|
|
13206
|
-
|
|
13734
|
+
resolve2(list);
|
|
13207
13735
|
});
|
|
13208
13736
|
});
|
|
13209
13737
|
for (const dirent of dirents) {
|
|
@@ -13229,10 +13757,10 @@ var SftpDestinationImpl = class {
|
|
|
13229
13757
|
const target = this.fullPath(key);
|
|
13230
13758
|
await this.withSftp(
|
|
13231
13759
|
(sftp) => new Promise(
|
|
13232
|
-
(
|
|
13233
|
-
if (!err) return
|
|
13760
|
+
(resolve2, reject) => sftp.unlink(target, (err) => {
|
|
13761
|
+
if (!err) return resolve2();
|
|
13234
13762
|
const code = err.code;
|
|
13235
|
-
if (code === 2) return
|
|
13763
|
+
if (code === 2) return resolve2();
|
|
13236
13764
|
reject(err);
|
|
13237
13765
|
})
|
|
13238
13766
|
)
|
|
@@ -13244,11 +13772,11 @@ var SftpDestinationImpl = class {
|
|
|
13244
13772
|
const failed = [];
|
|
13245
13773
|
await this.withSftp(async (sftp) => {
|
|
13246
13774
|
for (const key of keys) {
|
|
13247
|
-
await new Promise((
|
|
13775
|
+
await new Promise((resolve2) => {
|
|
13248
13776
|
sftp.unlink(this.fullPath(key), (err) => {
|
|
13249
13777
|
if (!err) {
|
|
13250
13778
|
deleted.push(key);
|
|
13251
|
-
return
|
|
13779
|
+
return resolve2();
|
|
13252
13780
|
}
|
|
13253
13781
|
const code = err.code;
|
|
13254
13782
|
if (code === 2) {
|
|
@@ -13256,7 +13784,7 @@ var SftpDestinationImpl = class {
|
|
|
13256
13784
|
} else {
|
|
13257
13785
|
failed.push({ key, error: err.message });
|
|
13258
13786
|
}
|
|
13259
|
-
|
|
13787
|
+
resolve2();
|
|
13260
13788
|
});
|
|
13261
13789
|
});
|
|
13262
13790
|
}
|
|
@@ -13280,1609 +13808,39 @@ function startUnitHint(unit) {
|
|
|
13280
13808
|
return pasteable(thisHost().serviceEnableStart(unit));
|
|
13281
13809
|
}
|
|
13282
13810
|
|
|
13283
|
-
// src/lib/
|
|
13284
|
-
import {
|
|
13285
|
-
import {
|
|
13286
|
-
|
|
13287
|
-
var
|
|
13288
|
-
var
|
|
13289
|
-
|
|
13290
|
-
|
|
13291
|
-
try {
|
|
13292
|
-
return spawnSync2(cmd, ["--version"], { stdio: "ignore" }).status === 0;
|
|
13293
|
-
} catch {
|
|
13294
|
-
return false;
|
|
13295
|
-
}
|
|
13296
|
-
}
|
|
13297
|
-
function run(cmd, args, cwd, env) {
|
|
13298
|
-
return new Promise((res, rej) => {
|
|
13299
|
-
const child = spawn(cmd, args, {
|
|
13300
|
-
cwd,
|
|
13301
|
-
stdio: "inherit",
|
|
13302
|
-
// Windows resolves the bun/git shims (.cmd/.exe) only through a shell.
|
|
13303
|
-
shell: process.platform === "win32",
|
|
13304
|
-
env: env ? { ...process.env, ...env } : process.env
|
|
13305
|
-
});
|
|
13306
|
-
child.on("error", rej);
|
|
13307
|
-
child.on(
|
|
13308
|
-
"exit",
|
|
13309
|
-
(code) => code === 0 ? res() : rej(new Error(`\`${cmd} ${args.join(" ")}\` (cwd=${cwd}) exited ${code ?? "?"}`))
|
|
13310
|
-
);
|
|
13311
|
-
});
|
|
13312
|
-
}
|
|
13313
|
-
function shortSha(cwd) {
|
|
13314
|
-
const r2 = spawnSync2("git", ["rev-parse", "--short", "HEAD"], { cwd, encoding: "utf8" });
|
|
13315
|
-
return r2.status === 0 ? (r2.stdout ?? "").trim() || "unknown" : "unknown";
|
|
13316
|
-
}
|
|
13317
|
-
function isMonorepo(dir) {
|
|
13318
|
-
return existsSync2(join4(dir, "package.json")) && existsSync2(join4(dir, "apps/api/package.json")) && existsSync2(join4(dir, "apps/dashboard/package.json"));
|
|
13811
|
+
// src/lib/compose-env.ts
|
|
13812
|
+
import { existsSync, readFileSync } from "fs";
|
|
13813
|
+
import { join as join3 } from "path";
|
|
13814
|
+
var COMPOSE_DIR = join3(OS_DIR, "compose");
|
|
13815
|
+
var COMPOSE_FILE = join3(COMPOSE_DIR, "docker-compose.yml");
|
|
13816
|
+
var COMPOSE_ENV_FILE = join3(COMPOSE_DIR, ".env");
|
|
13817
|
+
function composeInstallExists() {
|
|
13818
|
+
return existsSync(COMPOSE_FILE);
|
|
13319
13819
|
}
|
|
13320
|
-
|
|
13321
|
-
if (!has("bun")) {
|
|
13322
|
-
throw new Error(
|
|
13323
|
-
"`bun` is required to build from source but wasn't found on PATH \u2014 install it: https://bun.sh"
|
|
13324
|
-
);
|
|
13325
|
-
}
|
|
13326
|
-
let sourceDir;
|
|
13327
|
-
let ref;
|
|
13328
|
-
if (opts.source) {
|
|
13329
|
-
sourceDir = resolve2(opts.source);
|
|
13330
|
-
if (!isMonorepo(sourceDir)) {
|
|
13331
|
-
throw new Error(
|
|
13332
|
-
`--source ${sourceDir} doesn't look like an Openship checkout (missing package.json / apps/api / apps/dashboard).`
|
|
13333
|
-
);
|
|
13334
|
-
}
|
|
13335
|
-
ref = "local";
|
|
13336
|
-
} else {
|
|
13337
|
-
if (!has("git")) {
|
|
13338
|
-
throw new Error("`git` is required to clone the source but wasn't found on PATH.");
|
|
13339
|
-
}
|
|
13340
|
-
ref = (opts.ref || "main").trim();
|
|
13341
|
-
const repoUrl = opts.repo || DEFAULT_REPO;
|
|
13342
|
-
sourceDir = SOURCE_CHECKOUT_DIR;
|
|
13343
|
-
mkdirSync2(OS_DIR, { recursive: true });
|
|
13344
|
-
if (!existsSync2(join4(sourceDir, ".git"))) {
|
|
13345
|
-
console.log(` Cloning ${repoUrl} \u2192 ${sourceDir}`);
|
|
13346
|
-
await run("git", ["clone", repoUrl, sourceDir], OS_DIR);
|
|
13347
|
-
}
|
|
13348
|
-
console.log(` Fetching + checking out ${ref}`);
|
|
13349
|
-
await run("git", ["fetch", "origin", ref, "--tags"], sourceDir);
|
|
13350
|
-
await run("git", ["checkout", ref], sourceDir);
|
|
13351
|
-
await run("git", ["pull", "--ff-only", "origin", ref], sourceDir).catch(() => {
|
|
13352
|
-
console.log(" (pinned ref \u2014 not fast-forwarding)");
|
|
13353
|
-
});
|
|
13354
|
-
}
|
|
13355
|
-
const sha = shortSha(sourceDir);
|
|
13356
|
-
console.log(` Source: ${sourceDir} @ ${ref} (${sha})`);
|
|
13357
|
-
console.log(" Installing workspace dependencies (bun install)\u2026");
|
|
13358
|
-
await run("bun", ["install"], sourceDir);
|
|
13359
|
-
const distDir = SOURCE_DIST_DIR;
|
|
13360
|
-
console.log(" Building release dist (compiles the dashboard \u2014 needs RAM/CPU)\u2026");
|
|
13361
|
-
await run(
|
|
13362
|
-
"bun",
|
|
13363
|
-
["run", join4(sourceDir, "apps/api/scripts/build-release.ts")],
|
|
13364
|
-
sourceDir,
|
|
13365
|
-
{ DIST_DIR: distDir, NODE_ENV: "production", CLOUD_MODE: "false", OPENSHIP_TARGET: "local" }
|
|
13366
|
-
);
|
|
13367
|
-
console.log(" Installing runtime dependencies in the dist\u2026");
|
|
13368
|
-
await run("bun", ["install", "--production", "--frozen-lockfile"], distDir);
|
|
13369
|
-
const apiDir = join4(distDir, "api");
|
|
13370
|
-
const dashboardDir = join4(distDir, "dashboard");
|
|
13371
|
-
if (!existsSync2(join4(apiDir, "src/index.ts"))) {
|
|
13372
|
-
throw new Error(`Build produced no API at ${apiDir}/src/index.ts \u2014 build-release layout drift?`);
|
|
13373
|
-
}
|
|
13374
|
-
if (!existsSync2(join4(dashboardDir, "apps/dashboard/server.js"))) {
|
|
13375
|
-
throw new Error(`Build produced no dashboard at ${dashboardDir}/apps/dashboard/server.js.`);
|
|
13376
|
-
}
|
|
13377
|
-
return { apiDir, dashboardDir, ref, sha, sourceDir };
|
|
13378
|
-
}
|
|
13379
|
-
|
|
13380
|
-
// src/lib/compose.ts
|
|
13381
|
-
import { spawn as spawn2, spawnSync as spawnSync4 } from "child_process";
|
|
13382
|
-
import { randomBytes as randomBytes5 } from "crypto";
|
|
13383
|
-
import {
|
|
13384
|
-
chmodSync,
|
|
13385
|
-
existsSync as existsSync4,
|
|
13386
|
-
mkdirSync as mkdirSync3,
|
|
13387
|
-
readFileSync as readFileSync3,
|
|
13388
|
-
renameSync,
|
|
13389
|
-
rmSync,
|
|
13390
|
-
writeFileSync as writeFileSync2
|
|
13391
|
-
} from "fs";
|
|
13392
|
-
import { homedir, userInfo } from "os";
|
|
13393
|
-
import { dirname as dirname2, join as join6 } from "path";
|
|
13394
|
-
|
|
13395
|
-
// src/lib/source-install.ts
|
|
13396
|
-
import { spawnSync as spawnSync3 } from "child_process";
|
|
13397
|
-
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
13398
|
-
import { join as join5 } from "path";
|
|
13399
|
-
var MARKER = join5(OS_DIR, "source-install.json");
|
|
13400
|
-
function parseSourceInstall(raw) {
|
|
13401
|
-
try {
|
|
13402
|
-
const obj = JSON.parse(raw);
|
|
13403
|
-
if (!obj || typeof obj.dir !== "string" || !obj.dir) return null;
|
|
13404
|
-
return { repo: obj.repo || DEFAULT_REPO, ref: obj.ref || "main", dir: obj.dir };
|
|
13405
|
-
} catch {
|
|
13406
|
-
return null;
|
|
13407
|
-
}
|
|
13408
|
-
}
|
|
13409
|
-
function readSourceInstall() {
|
|
13410
|
-
if (!existsSync3(MARKER)) return null;
|
|
13411
|
-
try {
|
|
13412
|
-
return parseSourceInstall(readFileSync2(MARKER, "utf8"));
|
|
13413
|
-
} catch {
|
|
13414
|
-
return null;
|
|
13415
|
-
}
|
|
13416
|
-
}
|
|
13417
|
-
function parseRemoteSha(stdout) {
|
|
13418
|
-
const sha = stdout.split("\n")[0]?.trim().split(/\s+/)[0];
|
|
13419
|
-
return sha && /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 7) : null;
|
|
13420
|
-
}
|
|
13421
|
-
function remoteSha(repo, ref) {
|
|
13422
|
-
const r2 = spawnSync3("git", ["ls-remote", repo, ref], { encoding: "utf8" });
|
|
13423
|
-
if (r2.status !== 0 || !r2.stdout) return null;
|
|
13424
|
-
return parseRemoteSha(r2.stdout);
|
|
13425
|
-
}
|
|
13426
|
-
async function rebuildFromSource(info) {
|
|
13427
|
-
if (!has("bun")) {
|
|
13428
|
-
throw new Error("`bun` is required to rebuild from source but wasn't found on PATH \u2014 https://bun.sh");
|
|
13429
|
-
}
|
|
13430
|
-
if (!has("git")) {
|
|
13431
|
-
throw new Error("`git` is required to update the source checkout but wasn't found on PATH.");
|
|
13432
|
-
}
|
|
13433
|
-
if (!existsSync3(join5(info.dir, ".git"))) {
|
|
13434
|
-
throw new Error(
|
|
13435
|
-
`Source checkout ${info.dir} is missing (no .git) \u2014 reinstall from source with scripts/install-source.sh.`
|
|
13436
|
-
);
|
|
13437
|
-
}
|
|
13438
|
-
await run("git", ["fetch", "origin", info.ref, "--tags"], info.dir);
|
|
13439
|
-
await run("git", ["checkout", info.ref], info.dir);
|
|
13440
|
-
await run("git", ["pull", "--ff-only", "origin", info.ref], info.dir).catch(() => {
|
|
13441
|
-
});
|
|
13442
|
-
const cliDir = join5(info.dir, "apps/cli");
|
|
13443
|
-
await run("bun", ["install"], info.dir);
|
|
13444
|
-
await run("bun", ["run", "build"], cliDir);
|
|
13445
|
-
await run("bun", ["run", "build/stage-dashboard.ts"], cliDir);
|
|
13446
|
-
return shortSha(info.dir);
|
|
13447
|
-
}
|
|
13448
|
-
|
|
13449
|
-
// src/lib/compose.ts
|
|
13450
|
-
var EDGE_SITES_HOST_DIR = `${EDGE_HOST_STATE_DIR}/sites-enabled`;
|
|
13451
|
-
var EDGE_ACME_HOST_DIR = `${EDGE_HOST_STATE_DIR}/acme`;
|
|
13452
|
-
function edgeVolumeYaml(indent) {
|
|
13453
|
-
return EDGE_CONTAINER_MOUNTS.map((m2) => `${indent}- ${m2.host}:${m2.container}:z`).join("\n");
|
|
13454
|
-
}
|
|
13455
|
-
var COMPOSE_DIR = join6(OS_DIR, "compose");
|
|
13456
|
-
var INSTALL_METHOD_FILE = join6(OS_DIR, "install-method");
|
|
13457
|
-
var COMPOSE_FILE = join6(COMPOSE_DIR, "docker-compose.yml");
|
|
13458
|
-
var BUILD_FILE = join6(COMPOSE_DIR, "docker-compose.build.yml");
|
|
13459
|
-
var ENV_FILE = join6(COMPOSE_DIR, ".env");
|
|
13460
|
-
var ENV_BACKUP_FILE = join6(COMPOSE_DIR, ".env.bak");
|
|
13461
|
-
var ENV_TMP_FILE = join6(COMPOSE_DIR, ".env.tmp");
|
|
13462
|
-
var BUILT_SERVICES = [
|
|
13463
|
-
{ service: "api", dockerfile: "apps/api/Dockerfile" },
|
|
13464
|
-
{ service: "dashboard", dockerfile: "apps/dashboard/Dockerfile" },
|
|
13465
|
-
{ service: "edge", dockerfile: "apps/edge/Dockerfile" }
|
|
13466
|
-
];
|
|
13467
|
-
var ENV_CONSUMING_SERVICES = ["api", "dashboard", "edge"];
|
|
13468
|
-
function readInstallMethod() {
|
|
13469
|
-
try {
|
|
13470
|
-
const v2 = readFileSync3(INSTALL_METHOD_FILE, "utf8").trim();
|
|
13471
|
-
return v2 === "compose" || v2 === "bare" ? v2 : null;
|
|
13472
|
-
} catch {
|
|
13473
|
-
return null;
|
|
13474
|
-
}
|
|
13475
|
-
}
|
|
13476
|
-
function writeInstallMethod(method) {
|
|
13477
|
-
mkdirSync3(OS_DIR, { recursive: true, mode: 448 });
|
|
13478
|
-
writeFileSync2(INSTALL_METHOD_FILE, method, { mode: 384 });
|
|
13479
|
-
}
|
|
13480
|
-
function dockerState() {
|
|
13481
|
-
const ok = (args) => spawnSync4("docker", args, { stdio: "ignore" }).status === 0;
|
|
13482
|
-
if (!ok(["--version"])) return { binary: false, plugin: false, daemon: false };
|
|
13483
|
-
return { binary: true, plugin: ok(["compose", "version"]), daemon: ok(["version"]) };
|
|
13484
|
-
}
|
|
13485
|
-
function dockerGap(state = dockerState()) {
|
|
13486
|
-
if (!state.binary) {
|
|
13487
|
-
return { summary: "Docker isn't installed", installable: true };
|
|
13488
|
-
}
|
|
13489
|
-
if (!state.plugin) {
|
|
13490
|
-
return {
|
|
13491
|
-
summary: "Docker is installed but the Compose plugin (`docker compose`) is missing",
|
|
13492
|
-
installable: true
|
|
13493
|
-
};
|
|
13494
|
-
}
|
|
13495
|
-
if (!state.daemon) {
|
|
13496
|
-
const start = pasteable(thisHost().dockerStart());
|
|
13497
|
-
return {
|
|
13498
|
-
summary: "Docker is installed but its daemon isn't reachable",
|
|
13499
|
-
// Reinstalling changes nothing: a group change only applies to NEW logins,
|
|
13500
|
-
// and a stopped daemon needs starting, not installing.
|
|
13501
|
-
installable: false,
|
|
13502
|
-
hint: thisHost().profile.isRoot ? start ? `Start it with: ${start}` : "Start the Docker daemon \u2014 this host has no service manager Openship recognizes." : `Add your user to the docker group: sudo usermod -aG docker ${userInfo().username} \u2014 then log out and back in (or run: newgrp docker). If the daemon is stopped: ${start ?? "start it the way this host starts services"}`
|
|
13503
|
-
};
|
|
13504
|
-
}
|
|
13505
|
-
return null;
|
|
13506
|
-
}
|
|
13507
|
-
function hasDockerCompose() {
|
|
13508
|
-
return dockerGap() === null;
|
|
13509
|
-
}
|
|
13510
|
-
function hasCmd(cmd) {
|
|
13511
|
-
return spawnSync4(cmd, ["--version"], { stdio: "ignore" }).status === 0;
|
|
13512
|
-
}
|
|
13513
|
-
function shQuote(s2) {
|
|
13514
|
-
return `'${s2.replace(/'/g, `'\\''`)}'`;
|
|
13515
|
-
}
|
|
13516
|
-
function dockerInstallPreview(state = dockerState()) {
|
|
13517
|
-
const gap = dockerGap(state);
|
|
13518
|
-
if (!gap) return { state, gap: null, wouldInstall: false };
|
|
13519
|
-
if (process.platform !== "linux" || !gap.installable) return { state, gap, wouldInstall: false };
|
|
13520
|
-
const plan = systemCatalog.installs.docker(resolveLocalEnvironmentSync());
|
|
13521
|
-
if (!plan.supported) return { state, gap, wouldInstall: false, unsupportedReason: plan.reason };
|
|
13522
|
-
const start = plan.value.service;
|
|
13523
|
-
return {
|
|
13524
|
-
state,
|
|
13525
|
-
gap,
|
|
13526
|
-
wouldInstall: true,
|
|
13527
|
-
installCommand: plan.value.installCommand,
|
|
13528
|
-
...start?.supported ? { startCommand: start.value } : {},
|
|
13529
|
-
...start && !start.supported ? { startBlockedReason: start.reason } : {}
|
|
13530
|
-
};
|
|
13531
|
-
}
|
|
13532
|
-
async function ensureDocker(opts = {}) {
|
|
13533
|
-
const notice = opts.onNotice ?? ((line) => process.stderr.write(` ${line}
|
|
13534
|
-
`));
|
|
13535
|
-
const { state, gap, wouldInstall, installCommand, startCommand, unsupportedReason, startBlockedReason } = dockerInstallPreview();
|
|
13536
|
-
if (!gap) return true;
|
|
13537
|
-
if (process.platform !== "linux") return false;
|
|
13538
|
-
if (!gap.installable) {
|
|
13539
|
-
notice(gap.summary + ".");
|
|
13540
|
-
if (gap.hint) notice(gap.hint);
|
|
13541
|
-
return false;
|
|
13542
|
-
}
|
|
13543
|
-
if (!wouldInstall || !installCommand) {
|
|
13544
|
-
if (unsupportedReason) notice(unsupportedReason);
|
|
13545
|
-
return false;
|
|
13546
|
-
}
|
|
13547
|
-
const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
13548
|
-
const sudo = !asRoot && hasCmd("sudo") ? "sudo " : "";
|
|
13549
|
-
notice("This can take 2-5 minutes on a small VPS (~150 MB of packages) and stays quiet while apt works.");
|
|
13550
|
-
if (state.binary) {
|
|
13551
|
-
notice("Docker's installer will warn that docker already exists and pause ~20s before continuing \u2014 that's expected.");
|
|
13552
|
-
}
|
|
13553
|
-
const sh = (script) => new Promise((resolve3) => {
|
|
13554
|
-
const child = spawn2("sh", ["-c", sudo ? `${sudo}sh -c ${shQuote(script)}` : script], {
|
|
13555
|
-
stdio: "inherit"
|
|
13556
|
-
});
|
|
13557
|
-
const started = Date.now();
|
|
13558
|
-
const tick = setInterval(() => {
|
|
13559
|
-
const s2 = Math.round((Date.now() - started) / 1e3);
|
|
13560
|
-
notice(`still installing Docker \u2014 ${Math.floor(s2 / 60)}m${String(s2 % 60).padStart(2, "0")}s elapsed\u2026`);
|
|
13561
|
-
}, 3e4);
|
|
13562
|
-
const done = (code) => {
|
|
13563
|
-
clearInterval(tick);
|
|
13564
|
-
resolve3(code);
|
|
13565
|
-
};
|
|
13566
|
-
child.on("error", () => done(1));
|
|
13567
|
-
child.on("close", (code) => done(code ?? 1));
|
|
13568
|
-
});
|
|
13569
|
-
if (await sh(installCommand) !== 0) return false;
|
|
13570
|
-
if (startCommand) await sh(startCommand);
|
|
13571
|
-
else if (startBlockedReason) notice(startBlockedReason);
|
|
13572
|
-
const after = dockerGap();
|
|
13573
|
-
if (!after) {
|
|
13574
|
-
notice("Docker ready.");
|
|
13575
|
-
return true;
|
|
13576
|
-
}
|
|
13577
|
-
notice(after.summary + ".");
|
|
13578
|
-
if (after.hint) notice(after.hint);
|
|
13579
|
-
return false;
|
|
13580
|
-
}
|
|
13581
|
-
var COMPOSE_YAML = `# Managed by \`openship up\` \u2014 do not edit; re-run \`openship up\` to regenerate.
|
|
13582
|
-
services:
|
|
13583
|
-
postgres:
|
|
13584
|
-
image: postgres:16-alpine
|
|
13585
|
-
restart: unless-stopped
|
|
13586
|
-
environment:
|
|
13587
|
-
# Keep the data dir in a subdirectory of the volume so a fresh install never
|
|
13588
|
-
# runs initdb against a bare mount root (which fails on quirky host
|
|
13589
|
-
# filesystems with EPERM \u2014 #350). OPENSHIP_PGDATA is decided ONCE at install
|
|
13590
|
-
# by the CLI (fresh \u2192 subdir, pre-existing volume \u2192 root) and preserved in
|
|
13591
|
-
# .env, so this never moves an existing database.
|
|
13592
|
-
PGDATA: \${OPENSHIP_PGDATA:-/var/lib/postgresql/data/pgdata}
|
|
13593
|
-
POSTGRES_USER: \${POSTGRES_USER:-openship}
|
|
13594
|
-
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}
|
|
13595
|
-
POSTGRES_DB: \${POSTGRES_DB:-openship}
|
|
13596
|
-
expose: ["5432"]
|
|
13597
|
-
volumes: [postgres_data:/var/lib/postgresql/data]
|
|
13598
|
-
healthcheck:
|
|
13599
|
-
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-openship} -d \${POSTGRES_DB:-openship}"]
|
|
13600
|
-
interval: 5s
|
|
13601
|
-
timeout: 3s
|
|
13602
|
-
retries: 12
|
|
13603
|
-
|
|
13604
|
-
redis:
|
|
13605
|
-
image: redis:7-alpine
|
|
13606
|
-
restart: unless-stopped
|
|
13607
|
-
command: ["redis-server", "--appendonly", "yes"]
|
|
13608
|
-
expose: ["6379"]
|
|
13609
|
-
volumes: [redis_data:/data]
|
|
13610
|
-
healthcheck:
|
|
13611
|
-
test: ["CMD", "redis-cli", "ping"]
|
|
13612
|
-
interval: 5s
|
|
13613
|
-
timeout: 3s
|
|
13614
|
-
retries: 12
|
|
13615
|
-
|
|
13616
|
-
api:
|
|
13617
|
-
image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-api:\${OPENSHIP_VERSION:-latest}
|
|
13618
|
-
restart: unless-stopped
|
|
13619
|
-
# Loopback by default \u2014 the host-net edge reaches it over loopback, so nothing
|
|
13620
|
-
# sits on a public interface. OPENSHIP_BIND_ADDR opts into a public/LAN interface
|
|
13621
|
-
# (set by \`openship up\` when a public URL is configured for off-box access).
|
|
13622
|
-
ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${API_PORT:-4000}:\${API_PORT:-4000}"]
|
|
13623
|
-
volumes:
|
|
13624
|
-
# The daemon socket, at the path the api's own default expects INSIDE the
|
|
13625
|
-
# container. The HOST side is a variable because it MOVES: a rootless daemon
|
|
13626
|
-
# keeps its socket under the invoking user's runtime dir, and Docker creates a
|
|
13627
|
-
# missing bind source as an empty DIRECTORY instead of failing \u2014 so the rootful
|
|
13628
|
-
# path there brings the stack up and then fails every container operation in
|
|
13629
|
-
# the transport, naming neither the path nor the cause (#482). \`openship up\`
|
|
13630
|
-
# writes OPENSHIP_DOCKER_SOCKET when the detected path isn't this default.
|
|
13631
|
-
- \${OPENSHIP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
|
13632
|
-
# Routing state shared with the edge, as HOST BIND MOUNTS (generated from
|
|
13633
|
-
# EDGE_CONTAINER_MOUNTS): the vhost tree, /etc/letsencrypt, the ACME webroot
|
|
13634
|
-
# and the static doc-roots the API writes and the edge serves. Named volumes
|
|
13635
|
-
# hid all of it from every host-side reader (migrate's proxy scan, cert carry,
|
|
13636
|
-
# cert reuse, mail cert symlinks), each of which then silently saw "nothing".
|
|
13637
|
-
${edgeVolumeYaml(" ")}
|
|
13638
|
-
# Host-op SSH key (createHostExecutor \u2192 host.docker.internal). /dev/null
|
|
13639
|
-
# when the host channel isn't provisioned \u2192 OPENSHIP_HOST_SSH_HOST stays
|
|
13640
|
-
# unset, and host operations REFUSE (they'd otherwise run against this
|
|
13641
|
-
# container's filesystem, #509) while ordinary deploys keep working over
|
|
13642
|
-
# the socket below.
|
|
13643
|
-
- \${OPENSHIP_HOST_KEY_PATH:-/dev/null}:/run/secrets/openship_host_key:ro
|
|
13644
|
-
# \`host-gateway\` is resolved by the daemon and is right on a stock rootful
|
|
13645
|
-
# install. It is NOT universally right: under rootless Docker it lands in
|
|
13646
|
-
# RootlessKit's namespace rather than the host's, so the host's sshd isn't
|
|
13647
|
-
# there (#482). OPENSHIP_HOST_GATEWAY re-points the alias (e.g. at the box's
|
|
13648
|
-
# LAN address) without patching this file, which \`openship up\` rewrites.
|
|
13649
|
-
extra_hosts: ["host.docker.internal:\${OPENSHIP_HOST_GATEWAY:-host-gateway}"]
|
|
13650
|
-
env_file: [.env]
|
|
13651
|
-
environment:
|
|
13652
|
-
NODE_ENV: production
|
|
13653
|
-
PORT: "\${API_PORT:-4000}"
|
|
13654
|
-
DATABASE_URL: postgresql://\${POSTGRES_USER:-openship}:\${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}@postgres:5432/\${POSTGRES_DB:-openship}
|
|
13655
|
-
REDIS_URL: redis://redis:6379
|
|
13656
|
-
OPENSHIP_EDGE_MODE: docker
|
|
13657
|
-
OPENSHIP_EDGE_CONTAINER: openship-edge
|
|
13658
|
-
depends_on:
|
|
13659
|
-
postgres: { condition: service_healthy }
|
|
13660
|
-
redis: { condition: service_healthy }
|
|
13661
|
-
healthcheck:
|
|
13662
|
-
test: ["CMD-SHELL", "bun -e \\"fetch('http://127.0.0.1:\${API_PORT:-4000}/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\\""]
|
|
13663
|
-
interval: 10s
|
|
13664
|
-
timeout: 5s
|
|
13665
|
-
retries: 12
|
|
13666
|
-
start_period: 40s
|
|
13667
|
-
|
|
13668
|
-
dashboard:
|
|
13669
|
-
image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-dashboard:\${OPENSHIP_VERSION:-latest}
|
|
13670
|
-
restart: unless-stopped
|
|
13671
|
-
# Loopback by default (see api note); OPENSHIP_BIND_ADDR opts into a public interface.
|
|
13672
|
-
ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${DASHBOARD_PORT:-3001}:\${DASHBOARD_PORT:-3001}"]
|
|
13673
|
-
env_file: [.env]
|
|
13674
|
-
environment:
|
|
13675
|
-
NODE_ENV: production
|
|
13676
|
-
PORT: "\${DASHBOARD_PORT:-3001}"
|
|
13677
|
-
INTERNAL_API_URL: http://api:\${API_PORT:-4000}
|
|
13678
|
-
depends_on:
|
|
13679
|
-
api: { condition: service_healthy }
|
|
13680
|
-
|
|
13681
|
-
edge:
|
|
13682
|
-
image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-edge:\${OPENSHIP_VERSION:-latest}
|
|
13683
|
-
# PINNED, and it must stay pinned: the api reaches the edge by NAME through
|
|
13684
|
-
# DockerEdgeExecutor (OPENSHIP_EDGE_CONTAINER above), and "ours" edge
|
|
13685
|
-
# detection greps \`docker ps --filter name=openship-edge\`. Without this,
|
|
13686
|
-
# compose derives \`<project>-edge-1\` from the directory and every
|
|
13687
|
-
# \`docker exec\` into the edge fails with "No such container: openship-edge" \u2014
|
|
13688
|
-
# which silently migrated 0 sites after the operator's proxy was stopped.
|
|
13689
|
-
# Safe here: the edge is a singleton (host networking, one per box).
|
|
13690
|
-
container_name: openship-edge
|
|
13691
|
-
restart: unless-stopped
|
|
13692
|
-
network_mode: host
|
|
13693
|
-
volumes:
|
|
13694
|
-
${edgeVolumeYaml(" ")}
|
|
13695
|
-
|
|
13696
|
-
volumes:
|
|
13697
|
-
postgres_data:
|
|
13698
|
-
redis_data:
|
|
13699
|
-
`;
|
|
13700
|
-
function keepSecret(existing, key) {
|
|
13701
|
-
return existing[key] || randomBytes5(32).toString("hex");
|
|
13702
|
-
}
|
|
13703
|
-
var GENERATED_SECRET_KEYS = ["POSTGRES_PASSWORD", "BETTER_AUTH_SECRET", "INTERNAL_TOKEN"];
|
|
13704
|
-
function survivingDbVolume(prev) {
|
|
13705
|
-
const candidates = /* @__PURE__ */ new Set([prev.COMPOSE_PROJECT_NAME, "openship", "compose"]);
|
|
13706
|
-
for (const project of candidates) {
|
|
13707
|
-
if (project && dbVolumeExists(project)) return `${project}_postgres_data`;
|
|
13708
|
-
}
|
|
13709
|
-
return null;
|
|
13710
|
-
}
|
|
13711
|
-
function secretRotationRisk(prev) {
|
|
13712
|
-
const keys = GENERATED_SECRET_KEYS.filter((k2) => !prev[k2]);
|
|
13713
|
-
if (keys.length === 0) return null;
|
|
13714
|
-
const volume = survivingDbVolume(prev);
|
|
13715
|
-
return volume ? { volume, keys: [...keys] } : null;
|
|
13716
|
-
}
|
|
13717
|
-
function composeSecretRotationRisk(opts = {}) {
|
|
13718
|
-
if (opts.resetSecrets) return null;
|
|
13719
|
-
return secretRotationRisk(readEnvFile());
|
|
13720
|
-
}
|
|
13721
|
-
function renderSecretRotationRefusal(rotation, opts = {}) {
|
|
13722
|
-
return `
|
|
13723
|
-
${opts.dryRun ? "A real run would REFUSE here" : "Refusing to continue"}: this would regenerate secrets your existing install already has.
|
|
13724
|
-
|
|
13725
|
-
missing from ${ENV_FILE}: ${rotation.keys.join(", ")}
|
|
13726
|
-
but this install's data is still here: ${rotation.volume}
|
|
13727
|
-
|
|
13728
|
-
A new POSTGRES_PASSWORD cannot match that volume \u2014 Postgres only applies the password
|
|
13729
|
-
when it first creates the data directory \u2014 and a new BETTER_AUTH_SECRET cannot decrypt
|
|
13730
|
-
the environment variables already stored in the database. Nothing has been written yet.
|
|
13731
|
-
|
|
13732
|
-
` + secretRecoveryAdvice(rotation);
|
|
13733
|
-
}
|
|
13734
|
-
function secretRecoveryAdvice(rotation) {
|
|
13735
|
-
const adopted = adoptedStack(projectOfDbVolume(rotation.volume));
|
|
13736
|
-
if (adopted) {
|
|
13737
|
-
return ` This stack was not created by \`openship up\`: it is already running under the compose
|
|
13738
|
-
project name we pin, so this run would ADOPT it.
|
|
13739
|
-
|
|
13740
|
-
project: ${adopted.project}
|
|
13741
|
-
started from: ${adopted.configFiles || "an unlabelled `docker run`"}
|
|
13742
|
-
not from: ${COMPOSE_FILE}
|
|
13743
|
-
|
|
13744
|
-
So there is no \`.env\` of ours to restore and no backup of one \u2014 the real values are in
|
|
13745
|
-
the api container's own environment. Copy them out of it, then re-run:
|
|
13746
|
-
|
|
13747
|
-
echo 'COMPOSE_PROJECT_NAME=${adopted.project}' >> ${ENV_FILE}
|
|
13748
|
-
docker inspect ${adopted.apiContainer} --format '{{range .Config.Env}}{{println .}}{{end}}' \\
|
|
13749
|
-
| grep -E '^(${rotation.keys.join("|")})=' >> ${ENV_FILE}
|
|
13750
|
-
|
|
13751
|
-
COMPOSE_PROJECT_NAME matters as much as the secrets do: it is the volume prefix, so
|
|
13752
|
-
without it this stack comes up on empty volumes beside the data above.
|
|
13753
|
-
|
|
13754
|
-
Only if that container is gone and the values exist nowhere else \u2014 this DESTROYS them,
|
|
13755
|
-
leaving every environment variable stored in the database permanently unreadable:
|
|
13756
|
-
openship up --reset-secrets
|
|
13757
|
-
`;
|
|
13758
|
-
}
|
|
13759
|
-
return ` Put the original values back and re-run:
|
|
13760
|
-
` + (existsSync4(ENV_BACKUP_FILE) ? ` cp ${ENV_BACKUP_FILE} ${ENV_FILE} # the .env a previous \`openship up\` replaced
|
|
13761
|
-
` : ` restore ${ENV_FILE} from a backup, or copy the keys above out of one
|
|
13762
|
-
`) + ` If the file is there but this user can't read it, re-run as the user that owns it
|
|
13763
|
-
(or as root) rather than letting the secrets be regenerated.
|
|
13764
|
-
|
|
13765
|
-
To reset instead \u2014 the database password gets realigned, but every stored environment
|
|
13766
|
-
variable becomes permanently unreadable:
|
|
13767
|
-
openship up --reset-secrets
|
|
13768
|
-
`;
|
|
13769
|
-
}
|
|
13770
|
-
function projectOfDbVolume(volume) {
|
|
13771
|
-
return volume.replace(/_postgres_data$/, "");
|
|
13772
|
-
}
|
|
13773
|
-
function readEnvFile() {
|
|
13774
|
-
const out = {};
|
|
13820
|
+
function readComposeEnvFile() {
|
|
13775
13821
|
let text;
|
|
13776
13822
|
try {
|
|
13777
|
-
text =
|
|
13823
|
+
text = readFileSync(COMPOSE_ENV_FILE, "utf8");
|
|
13778
13824
|
} catch (err) {
|
|
13779
|
-
|
|
13780
|
-
|
|
13781
|
-
` ! Could not read ${ENV_FILE}: ${err.message}
|
|
13782
|
-
Its contents are being treated as absent. If this install already exists, fix the permissions and re-run rather than letting secrets be regenerated.`
|
|
13783
|
-
);
|
|
13784
|
-
}
|
|
13785
|
-
return out;
|
|
13825
|
+
const code = err.code;
|
|
13826
|
+
return { env: {}, unreadable: code === "ENOENT" ? null : err.message };
|
|
13786
13827
|
}
|
|
13828
|
+
const env = {};
|
|
13787
13829
|
for (const line of text.split("\n")) {
|
|
13788
13830
|
const m2 = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
13789
|
-
if (m2)
|
|
13831
|
+
if (m2) env[m2[1]] = m2[2];
|
|
13790
13832
|
}
|
|
13791
|
-
return
|
|
13833
|
+
return { env, unreadable: null };
|
|
13792
13834
|
}
|
|
13793
|
-
var HOST_CHANNEL_DEFAULT_HOST = "host.docker.internal";
|
|
13794
|
-
var HOST_CHANNEL_DEFAULT_PORT = 22;
|
|
13795
|
-
var HOST_KEY_COMMENT = "openship-host-executor";
|
|
13796
|
-
var HOST_KEY_FROM = "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8,127.0.0.1";
|
|
13797
|
-
function hostKeyAuthLine(pub) {
|
|
13798
|
-
return `from="${HOST_KEY_FROM}",restrict,pty ${pub}`;
|
|
13799
|
-
}
|
|
13800
|
-
function rewriteHostAuthorizedKeys(existing, pub) {
|
|
13801
|
-
return [...keptForeignKeys(existing), hostKeyAuthLine(pub)].join("\n") + "\n";
|
|
13802
|
-
}
|
|
13803
|
-
function keptForeignKeys(existing) {
|
|
13804
|
-
return existing.split("\n").filter((line) => line.trim() && !line.includes(HOST_KEY_COMMENT));
|
|
13805
|
-
}
|
|
13806
|
-
function stripHostAuthorizedKeys(existing) {
|
|
13807
|
-
const kept = keptForeignKeys(existing);
|
|
13808
|
-
return kept.length ? kept.join("\n") + "\n" : "";
|
|
13809
|
-
}
|
|
13810
|
-
var ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
13811
|
-
function chooseHostChannelUser(input) {
|
|
13812
|
-
if (input.invokerUid === 0) {
|
|
13813
|
-
return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: false, rootUnavailable: false };
|
|
13814
|
-
}
|
|
13815
|
-
if (input.hasPasswordlessSudo) {
|
|
13816
|
-
return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: true, rootUnavailable: false };
|
|
13817
|
-
}
|
|
13818
|
-
return {
|
|
13819
|
-
user: input.invokerName,
|
|
13820
|
-
authKeysPath: join6(input.invokerHome, ".ssh", "authorized_keys"),
|
|
13821
|
-
viaSudo: false,
|
|
13822
|
-
rootUnavailable: true
|
|
13823
|
-
};
|
|
13824
|
-
}
|
|
13825
|
-
function hasPasswordlessSudo() {
|
|
13826
|
-
try {
|
|
13827
|
-
return spawnSync4("sudo", ["-n", "true"], { stdio: "ignore" }).status === 0;
|
|
13828
|
-
} catch {
|
|
13829
|
-
return false;
|
|
13830
|
-
}
|
|
13831
|
-
}
|
|
13832
|
-
function plannedHostChannel(hostControl) {
|
|
13833
|
-
if (!hostControl || process.platform !== "linux") return null;
|
|
13834
|
-
let invoker;
|
|
13835
|
-
try {
|
|
13836
|
-
const info = userInfo();
|
|
13837
|
-
invoker = { uid: info.uid, username: info.username, home: info.homedir };
|
|
13838
|
-
} catch {
|
|
13839
|
-
invoker = { uid: -1, username: process.env.USER || process.env.LOGNAME || "root", home: homedir() };
|
|
13840
|
-
}
|
|
13841
|
-
const decision = chooseHostChannelUser({
|
|
13842
|
-
invokerUid: invoker.uid,
|
|
13843
|
-
invokerName: invoker.username,
|
|
13844
|
-
invokerHome: invoker.home,
|
|
13845
|
-
// Only probe sudo when it could change the outcome — a root invoker never needs it.
|
|
13846
|
-
hasPasswordlessSudo: invoker.uid !== 0 && hasPasswordlessSudo()
|
|
13847
|
-
});
|
|
13848
|
-
return { ...decision, keyPath: join6(COMPOSE_DIR, "host-ssh", "id_ed25519") };
|
|
13849
|
-
}
|
|
13850
|
-
function authorizeKeyAt(authKeysPath, pub) {
|
|
13851
|
-
mkdirSync3(dirname2(authKeysPath), { recursive: true, mode: 448 });
|
|
13852
|
-
const existing = existsSync4(authKeysPath) ? readFileSync3(authKeysPath, "utf8") : "";
|
|
13853
|
-
const next = rewriteHostAuthorizedKeys(existing, pub);
|
|
13854
|
-
if (next !== existing) writeFileSync2(authKeysPath, next, { mode: 384 });
|
|
13855
|
-
chmodSync(authKeysPath, 384);
|
|
13856
|
-
}
|
|
13857
|
-
function authorizeRootKeyViaSudo(pub) {
|
|
13858
|
-
const mk = spawnSync4("sudo", ["-n", "sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"], {
|
|
13859
|
-
stdio: "ignore"
|
|
13860
|
-
});
|
|
13861
|
-
if (mk.status !== 0) return false;
|
|
13862
|
-
const read = spawnSync4(
|
|
13863
|
-
"sudo",
|
|
13864
|
-
["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`],
|
|
13865
|
-
{ encoding: "utf8" }
|
|
13866
|
-
);
|
|
13867
|
-
if (read.status !== 0) return false;
|
|
13868
|
-
const next = rewriteHostAuthorizedKeys(read.stdout ?? "", pub);
|
|
13869
|
-
const write = spawnSync4("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], {
|
|
13870
|
-
input: next,
|
|
13871
|
-
stdio: ["pipe", "ignore", "ignore"]
|
|
13872
|
-
});
|
|
13873
|
-
if (write.status !== 0) return false;
|
|
13874
|
-
return spawnSync4("sudo", ["-n", "chmod", "600", ROOT_AUTHORIZED_KEYS], { stdio: "ignore" }).status === 0;
|
|
13875
|
-
}
|
|
13876
|
-
function revokeKeyAt(authKeysPath) {
|
|
13877
|
-
try {
|
|
13878
|
-
if (!existsSync4(authKeysPath)) return true;
|
|
13879
|
-
const existing = readFileSync3(authKeysPath, "utf8");
|
|
13880
|
-
if (!existing.includes(HOST_KEY_COMMENT)) return true;
|
|
13881
|
-
writeFileSync2(authKeysPath, stripHostAuthorizedKeys(existing), { mode: 384 });
|
|
13882
|
-
chmodSync(authKeysPath, 384);
|
|
13883
|
-
return true;
|
|
13884
|
-
} catch {
|
|
13885
|
-
return false;
|
|
13886
|
-
}
|
|
13887
|
-
}
|
|
13888
|
-
function revokeRootKeyViaSudo() {
|
|
13889
|
-
const read = spawnSync4(
|
|
13890
|
-
"sudo",
|
|
13891
|
-
["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`],
|
|
13892
|
-
{ encoding: "utf8" }
|
|
13893
|
-
);
|
|
13894
|
-
if (read.status !== 0) return false;
|
|
13895
|
-
const existing = read.stdout ?? "";
|
|
13896
|
-
if (!existing.includes(HOST_KEY_COMMENT)) return true;
|
|
13897
|
-
const write = spawnSync4("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], {
|
|
13898
|
-
input: stripHostAuthorizedKeys(existing),
|
|
13899
|
-
stdio: ["pipe", "ignore", "ignore"]
|
|
13900
|
-
});
|
|
13901
|
-
return write.status === 0;
|
|
13902
|
-
}
|
|
13903
|
-
function retireHostSshChannel(prev) {
|
|
13904
|
-
const keyDir = join6(COMPOSE_DIR, "host-ssh");
|
|
13905
|
-
if (!existsSync4(keyDir) && !prev.OPENSHIP_HOST_SSH_HOST) return;
|
|
13906
|
-
const invokerKeys = join6(homedir(), ".ssh", "authorized_keys");
|
|
13907
|
-
const asRoot = (prev.OPENSHIP_HOST_SSH_USER?.trim() || "root") === "root";
|
|
13908
|
-
const weAreRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
13909
|
-
const revoked = asRoot ? weAreRoot ? revokeKeyAt(ROOT_AUTHORIZED_KEYS) : hasPasswordlessSudo() && revokeRootKeyViaSudo() : revokeKeyAt(invokerKeys);
|
|
13910
|
-
const alsoInvoker = asRoot ? revokeKeyAt(invokerKeys) : true;
|
|
13911
|
-
try {
|
|
13912
|
-
rmSync(keyDir, { recursive: true, force: true });
|
|
13913
|
-
} catch {
|
|
13914
|
-
}
|
|
13915
|
-
const stuck = asRoot ? ROOT_AUTHORIZED_KEYS : invokerKeys;
|
|
13916
|
-
console.log(
|
|
13917
|
-
revoked && alsoInvoker ? " Host control off: dropped OPENSHIP_HOST_SSH_* from .env, deleted the host key and\n revoked its authorized_keys line." : ` Host control off: dropped OPENSHIP_HOST_SSH_* from .env and deleted the host key (it
|
|
13918
|
-
can no longer be used). Removing its line needs root \u2014 run:
|
|
13919
|
-
sudo sed -i '/${HOST_KEY_COMMENT}/d' ${stuck}`
|
|
13920
|
-
);
|
|
13921
|
-
}
|
|
13922
|
-
function provisionHostSshChannel(cfg) {
|
|
13923
|
-
const target = plannedHostChannel(cfg.hostControl);
|
|
13924
|
-
if (!target) return { channel: null };
|
|
13925
|
-
const { user, keyPath, authKeysPath, viaSudo, rootUnavailable } = target;
|
|
13926
|
-
const provisioned = (channel) => {
|
|
13927
|
-
const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT;
|
|
13928
|
-
return probeHostSshd(port) === "absent" ? { channel, issue: { code: "no-sshd", detail: `port ${port}` } } : { channel };
|
|
13929
|
-
};
|
|
13930
|
-
try {
|
|
13931
|
-
mkdirSync3(join6(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
|
|
13932
|
-
if (!existsSync4(keyPath)) {
|
|
13933
|
-
const g2 = spawnSync4(
|
|
13934
|
-
"ssh-keygen",
|
|
13935
|
-
["-t", "ed25519", "-N", "", "-q", "-f", keyPath, "-C", HOST_KEY_COMMENT],
|
|
13936
|
-
// stderr is captured rather than discarded: it is the only account of why the
|
|
13937
|
-
// channel is missing, and the caller prints it.
|
|
13938
|
-
{ encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] }
|
|
13939
|
-
);
|
|
13940
|
-
if (g2.status !== 0) return { channel: null, issue: { code: "keygen", detail: keygenWhy(g2) } };
|
|
13941
|
-
}
|
|
13942
|
-
const pub = readFileSync3(`${keyPath}.pub`, "utf8").trim();
|
|
13943
|
-
if (!pub) return { channel: null, issue: { code: "empty-key" } };
|
|
13944
|
-
if (rootUnavailable) {
|
|
13945
|
-
console.warn(
|
|
13946
|
-
" \u26A0 Host control needs root, but this user isn't root and passwordless sudo isn't\n available. Mail and edge host operations will fail. Re-run `openship up` as root\n (or enable passwordless sudo) to fix. Continuing with a limited host channel."
|
|
13947
|
-
);
|
|
13948
|
-
authorizeKeyAt(authKeysPath, pub);
|
|
13949
|
-
return provisioned({ user, keyPath });
|
|
13950
|
-
}
|
|
13951
|
-
if (viaSudo) {
|
|
13952
|
-
if (!authorizeRootKeyViaSudo(pub)) {
|
|
13953
|
-
console.warn(
|
|
13954
|
-
" \u26A0 Could not authorize the host key for root via sudo \u2014 falling back to the\n current user. Root host operations (mail/edge) may fail."
|
|
13955
|
-
);
|
|
13956
|
-
const invoker = userInfo().username;
|
|
13957
|
-
authorizeKeyAt(join6(homedir(), ".ssh", "authorized_keys"), pub);
|
|
13958
|
-
return provisioned({ user: invoker, keyPath });
|
|
13959
|
-
}
|
|
13960
|
-
revokeKeyAt(join6(homedir(), ".ssh", "authorized_keys"));
|
|
13961
|
-
return provisioned({ user, keyPath });
|
|
13962
|
-
}
|
|
13963
|
-
authorizeKeyAt(authKeysPath, pub);
|
|
13964
|
-
return provisioned({ user, keyPath });
|
|
13965
|
-
} catch (err) {
|
|
13966
|
-
return { channel: null, issue: { code: "error", detail: err?.message } };
|
|
13967
|
-
}
|
|
13968
|
-
}
|
|
13969
|
-
function keygenWhy(r2) {
|
|
13970
|
-
if (r2.error?.code === "ENOENT") {
|
|
13971
|
-
return "ssh-keygen is not installed \u2014 the openssh-client package provides it";
|
|
13972
|
-
}
|
|
13973
|
-
const stderr = (r2.stderr ?? "").trim().split("\n").pop();
|
|
13974
|
-
return stderr || r2.error?.message || `ssh-keygen exited ${r2.status ?? "abnormally"}`;
|
|
13975
|
-
}
|
|
13976
|
-
function probeHostSshd(port) {
|
|
13977
|
-
for (const [cmd, args] of [
|
|
13978
|
-
["ss", ["-H", "-ltn"]],
|
|
13979
|
-
["netstat", ["-ltn"]]
|
|
13980
|
-
]) {
|
|
13981
|
-
const r2 = spawnSync4(cmd, [...args], { encoding: "utf8" });
|
|
13982
|
-
if (r2.status !== 0 || !r2.stdout) continue;
|
|
13983
|
-
return listensOnPort(r2.stdout, port) ? "listening" : "absent";
|
|
13984
|
-
}
|
|
13985
|
-
return "unknown";
|
|
13986
|
-
}
|
|
13987
|
-
function listensOnPort(listing, port) {
|
|
13988
|
-
return listing.split("\n").filter((line) => /\bLISTEN\b/.test(line)).some(
|
|
13989
|
-
(line) => line.trim().split(/\s+/).some((token) => token.endsWith(`:${port}`))
|
|
13990
|
-
);
|
|
13991
|
-
}
|
|
13992
|
-
function carriedHostChannel(prev) {
|
|
13993
|
-
const keyPath = prev.OPENSHIP_HOST_KEY_PATH?.trim();
|
|
13994
|
-
if (!prev.OPENSHIP_HOST_SSH_HOST?.trim() || !keyPath || !existsSync4(keyPath)) return null;
|
|
13995
|
-
return { user: prev.OPENSHIP_HOST_SSH_USER?.trim() || "root", keyPath };
|
|
13996
|
-
}
|
|
13997
|
-
var HOST_ISSUE_INDENT = " ";
|
|
13998
|
-
function hostIssueNote(text) {
|
|
13999
|
-
return wrapText(text, 80).map((l2) => `${HOST_ISSUE_INDENT}${l2}
|
|
14000
|
-
`).join("");
|
|
14001
|
-
}
|
|
14002
|
-
function renderHostChannelIssue(issue, ctx) {
|
|
14003
|
-
const detail = issue.detail ? ` (${issue.detail})` : "";
|
|
14004
|
-
const headline = issue.code === "no-sshd" ? ` \u26A0 Host control is provisioned, but nothing on this host is listening for SSH${detail}.` : ` \u26A0 Host control could NOT be provisioned on this box.`;
|
|
14005
|
-
let cause;
|
|
14006
|
-
switch (issue.code) {
|
|
14007
|
-
case "keygen":
|
|
14008
|
-
cause = `The channel's key could not be generated${detail}.`;
|
|
14009
|
-
break;
|
|
14010
|
-
case "empty-key":
|
|
14011
|
-
cause = "The channel's key was generated but its public half read back empty.";
|
|
14012
|
-
break;
|
|
14013
|
-
case "no-sshd":
|
|
14014
|
-
cause = `The api container now has a key and an address, and will be refused the first time it dials ${ctx.target}:
|
|
14015
|
-
` + explainHostChannelCause("refused", {
|
|
14016
|
-
target: ctx.target,
|
|
14017
|
-
// Measured here rather than left to core's default, which is Debian's `ssh` —
|
|
14018
|
-
// a unit name that does not exist on the RHEL family, so the remedy printed on
|
|
14019
|
-
// an Amazon Linux box was a command that silently does nothing.
|
|
14020
|
-
sshdEnableHint: thisHost().sshdEnableHint()
|
|
14021
|
-
}).body;
|
|
14022
|
-
break;
|
|
14023
|
-
case "error":
|
|
14024
|
-
cause = `Setting up the channel's key failed${detail}.`;
|
|
14025
|
-
break;
|
|
14026
|
-
}
|
|
14027
|
-
const continuity = issue.code === "no-sshd" ? "" : ctx.kept ? hostIssueNote(
|
|
14028
|
-
"Kept the host channel this install already had in `.env` rather than dropping it: its key is still authorized, so the api container keeps the access it had instead of losing it to this failure."
|
|
14029
|
-
) : hostIssueNote(
|
|
14030
|
-
"OPENSHIP_HOST_SSH_HOST is therefore unset, and host operations refuse rather than run against the api container's own filesystem."
|
|
14031
|
-
);
|
|
14032
|
-
return `${headline}
|
|
14033
|
-
` + hostIssueNote(cause) + continuity + hostIssueNote(HOST_CHANNEL_UNAFFECTED) + `${HOST_ISSUE_INDENT}Unavailable until the channel works:
|
|
14034
|
-
` + HOST_CHANNEL_BLOCKED.map((l2) => `${HOST_ISSUE_INDENT} \xB7 ${l2}
|
|
14035
|
-
`).join("") + hostIssueNote(HOST_CHANNEL_RECHECK);
|
|
14036
|
-
}
|
|
14037
|
-
function composeProjectName(prev) {
|
|
14038
|
-
if (prev.COMPOSE_PROJECT_NAME) return prev.COMPOSE_PROJECT_NAME;
|
|
14039
|
-
return Object.keys(prev).length > 0 ? "compose" : "openship";
|
|
14040
|
-
}
|
|
14041
|
-
var PGDATA_ROOT = "/var/lib/postgresql/data";
|
|
14042
|
-
function probePgDataDir(volume) {
|
|
14043
|
-
const r2 = spawnSync4(
|
|
14044
|
-
"docker",
|
|
14045
|
-
[
|
|
14046
|
-
"run",
|
|
14047
|
-
"--rm",
|
|
14048
|
-
"-v",
|
|
14049
|
-
`${volume}:/from:ro`,
|
|
14050
|
-
"alpine:3",
|
|
14051
|
-
"sh",
|
|
14052
|
-
"-c",
|
|
14053
|
-
`if [ -f /from/PG_VERSION ]; then echo root; elif [ -f /from/pgdata/PG_VERSION ]; then echo sub; elif [ -z "$(ls -A /from 2>/dev/null | grep -v '^lost+found$')" ]; then echo empty; else echo foreign; fi`
|
|
14054
|
-
],
|
|
14055
|
-
{ encoding: "utf8" }
|
|
14056
|
-
);
|
|
14057
|
-
if (r2.status !== 0) return "unknown";
|
|
14058
|
-
const out = `${r2.stdout ?? ""}`.trim();
|
|
14059
|
-
return out === "root" || out === "sub" || out === "empty" || out === "foreign" ? out : "unknown";
|
|
14060
|
-
}
|
|
14061
|
-
function resolvePgData(prev) {
|
|
14062
|
-
const SUB = `${PGDATA_ROOT}/pgdata`;
|
|
14063
|
-
if (prev.OPENSHIP_PGDATA) return prev.OPENSHIP_PGDATA;
|
|
14064
|
-
const volume = survivingDbVolume(prev);
|
|
14065
|
-
if (!volume) return SUB;
|
|
14066
|
-
return probePgDataDir(volume) === "root" ? PGDATA_ROOT : SUB;
|
|
14067
|
-
}
|
|
14068
|
-
function pgDataDetectionRisk(prev) {
|
|
14069
|
-
if (prev.OPENSHIP_PGDATA) return null;
|
|
14070
|
-
const volume = survivingDbVolume(prev);
|
|
14071
|
-
if (!volume) return null;
|
|
14072
|
-
return probePgDataDir(volume) === "foreign" ? { volume } : null;
|
|
14073
|
-
}
|
|
14074
|
-
function composePgDataRisk(_opts = {}) {
|
|
14075
|
-
return pgDataDetectionRisk(readEnvFile());
|
|
14076
|
-
}
|
|
14077
|
-
function renderPgDataRefusal(risk, opts = {}) {
|
|
14078
|
-
const SUB = `${PGDATA_ROOT}/pgdata`;
|
|
14079
|
-
return `
|
|
14080
|
-
${opts.dryRun ? "A real run would REFUSE here" : "Refusing to continue"}: this install's data volume holds data, but not a Postgres cluster we recognize.
|
|
14081
|
-
|
|
14082
|
-
volume: ${risk.volume}
|
|
14083
|
-
checked: ${PGDATA_ROOT}/PG_VERSION and ${SUB}/PG_VERSION \u2014 neither present
|
|
14084
|
-
|
|
14085
|
-
Guessing where the cluster lives is how a wrong path makes Postgres run initdb over
|
|
14086
|
-
your existing data and orphan it, so nothing has been written. If you know the layout,
|
|
14087
|
-
pin it and re-run:
|
|
14088
|
-
|
|
14089
|
-
echo 'OPENSHIP_PGDATA=${SUB}' >> ${ENV_FILE} # or ${PGDATA_ROOT} if the cluster is at the root
|
|
14090
|
-
`;
|
|
14091
|
-
}
|
|
14092
|
-
var PS_SWEEP_FORMAT = '{{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.config_files"}} {{.Label "com.docker.compose.service"}}';
|
|
14093
|
-
function parsePsSweep(stdout) {
|
|
14094
|
-
return stdout.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
14095
|
-
const [name, project, configFiles, service] = line.split(" ");
|
|
14096
|
-
return {
|
|
14097
|
-
name: name ?? "",
|
|
14098
|
-
project: project ?? "",
|
|
14099
|
-
configFiles: configFiles ?? "",
|
|
14100
|
-
service: service ?? ""
|
|
14101
|
-
};
|
|
14102
|
-
});
|
|
14103
|
-
}
|
|
14104
|
-
function psSweepRaw() {
|
|
14105
|
-
const r2 = spawnSync4("docker", ["ps", "-a", "--format", PS_SWEEP_FORMAT], { encoding: "utf8" });
|
|
14106
|
-
return r2.status === 0 && r2.stdout ? r2.stdout : null;
|
|
14107
|
-
}
|
|
14108
|
-
function psSweep() {
|
|
14109
|
-
return parsePsSweep(psSweepRaw() ?? "");
|
|
14110
|
-
}
|
|
14111
|
-
function fromOurComposeFile(row) {
|
|
14112
|
-
return row.configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE);
|
|
14113
|
-
}
|
|
14114
|
-
function orphanedStackContainers(project) {
|
|
14115
|
-
return psSweep().filter((c2) => c2.name && c2.project && c2.project !== project && fromOurComposeFile(c2)).map(({ name, project: proj }) => ({ name, project: proj }));
|
|
14116
|
-
}
|
|
14117
|
-
function adoptedStackOrigin(psStdout, project) {
|
|
14118
|
-
const rows = parsePsSweep(psStdout).filter((c2) => c2.name && c2.project === project);
|
|
14119
|
-
if (rows.length === 0 || rows.some(fromOurComposeFile)) return null;
|
|
14120
|
-
return {
|
|
14121
|
-
project,
|
|
14122
|
-
configFiles: rows.map((c2) => c2.configFiles).find(Boolean) ?? "",
|
|
14123
|
-
apiContainer: rows.find((c2) => c2.service === "api")?.name || `${project}-api-1`
|
|
14124
|
-
};
|
|
14125
|
-
}
|
|
14126
|
-
function adoptedStack(project) {
|
|
14127
|
-
const sweep = psSweepRaw();
|
|
14128
|
-
return sweep ? adoptedStackOrigin(sweep, project) : null;
|
|
14129
|
-
}
|
|
14130
|
-
function reconcileDbPassword(user, password) {
|
|
14131
|
-
if (!password) return;
|
|
14132
|
-
if (compose(["up", "-d", "--wait", "postgres"], { quiet: true }) !== 0) return;
|
|
14133
|
-
const attempt = (asRole) => spawnSync4(
|
|
14134
|
-
"docker",
|
|
14135
|
-
[
|
|
14136
|
-
"compose",
|
|
14137
|
-
"-f",
|
|
14138
|
-
COMPOSE_FILE,
|
|
14139
|
-
"exec",
|
|
14140
|
-
"-T",
|
|
14141
|
-
"postgres",
|
|
14142
|
-
"psql",
|
|
14143
|
-
"-U",
|
|
14144
|
-
asRole,
|
|
14145
|
-
"-d",
|
|
14146
|
-
"postgres",
|
|
14147
|
-
"-v",
|
|
14148
|
-
"ON_ERROR_STOP=1",
|
|
14149
|
-
// Read the statement from STDIN, not `-c`: psql has no bind parameters for
|
|
14150
|
-
// ALTER USER, so the password has to be inlined in SQL — and an argv copy
|
|
14151
|
-
// would be readable in `ps` for the life of the call. stdin keeps it to this
|
|
14152
|
-
// process and the socket. Our generated password is hex, so it cannot break
|
|
14153
|
-
// out of the quoted literal either.
|
|
14154
|
-
"-f",
|
|
14155
|
-
"-"
|
|
14156
|
-
],
|
|
14157
|
-
{
|
|
14158
|
-
cwd: COMPOSE_DIR,
|
|
14159
|
-
encoding: "utf8",
|
|
14160
|
-
// Create the role if the volume predates it, else just realign it. Both
|
|
14161
|
-
// branches leave exactly the credentials this `.env` will present.
|
|
14162
|
-
input: `DO $$ BEGIN
|
|
14163
|
-
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${user}') THEN
|
|
14164
|
-
ALTER ROLE "${user}" WITH LOGIN PASSWORD '${password}';
|
|
14165
|
-
ELSE
|
|
14166
|
-
CREATE ROLE "${user}" WITH LOGIN SUPERUSER PASSWORD '${password}';
|
|
14167
|
-
END IF;
|
|
14168
|
-
END $$;
|
|
14169
|
-
`
|
|
14170
|
-
}
|
|
14171
|
-
);
|
|
14172
|
-
let r2 = attempt(user);
|
|
14173
|
-
if (r2.status !== 0) r2 = attempt("postgres");
|
|
14174
|
-
if (r2.status !== 0) {
|
|
14175
|
-
console.log(
|
|
14176
|
-
`
|
|
14177
|
-
! Could not realign the database password (${(r2.stderr ?? "").trim() || "psql failed"}).
|
|
14178
|
-
The api will fail with "password authentication failed for user "${user}"" because the
|
|
14179
|
-
existing data volume was initialized with a different password.
|
|
14180
|
-
|
|
14181
|
-
Fix it one of two ways:
|
|
14182
|
-
\u2022 restore the .env that created the volume, or
|
|
14183
|
-
\u2022 start fresh (DESTROYS DB DATA): openship uninstall then openship up
|
|
14184
|
-
`
|
|
14185
|
-
);
|
|
14186
|
-
}
|
|
14187
|
-
}
|
|
14188
|
-
function removeOrphanedStack(project) {
|
|
14189
|
-
const orphans = orphanedStackContainers(project);
|
|
14190
|
-
if (orphans.length === 0) return;
|
|
14191
|
-
const projects = [...new Set(orphans.map((o2) => o2.project))].join(", ");
|
|
14192
|
-
console.log(
|
|
14193
|
-
` Removing ${orphans.length} container(s) from a previous stack (project "${projects}") \u2014 they would hold the ports this stack needs. Volumes are kept.`
|
|
14194
|
-
);
|
|
14195
|
-
spawnSync4("docker", ["rm", "-f", ...orphans.map((o2) => o2.name)], { stdio: "ignore" });
|
|
14196
|
-
}
|
|
14197
|
-
function edgeNameNeedsReclaim(psStdout, project) {
|
|
14198
|
-
const row = parsePsSweep(psStdout).find((c2) => c2.name === "openship-edge");
|
|
14199
|
-
if (!row) return false;
|
|
14200
|
-
return !(row.project === project && fromOurComposeFile(row));
|
|
14201
|
-
}
|
|
14202
|
-
function reconcileEdgeContainerName(project) {
|
|
14203
|
-
const sweep = psSweepRaw();
|
|
14204
|
-
if (!sweep) return;
|
|
14205
|
-
if (!edgeNameNeedsReclaim(sweep, project)) return;
|
|
14206
|
-
console.log(
|
|
14207
|
-
" Replacing an unmanaged `openship-edge` container so compose can own it (config is on the host, so no routes are lost)."
|
|
14208
|
-
);
|
|
14209
|
-
spawnSync4("docker", ["rm", "-f", "openship-edge"], { stdio: "ignore" });
|
|
14210
|
-
}
|
|
14211
|
-
function migrateLegacyEdgeVolumes(project) {
|
|
14212
|
-
const pairs = [
|
|
14213
|
-
{ volume: `${project}_openship_sites`, host: EDGE_SITES_HOST_DIR },
|
|
14214
|
-
{ volume: `${project}_openship_certs`, host: "/etc/letsencrypt" },
|
|
14215
|
-
{ volume: `${project}_openship_acme`, host: EDGE_ACME_HOST_DIR },
|
|
14216
|
-
{ volume: `${project}_openship_static`, host: "/opt/openship/static" }
|
|
14217
|
-
];
|
|
14218
|
-
const ls = spawnSync4("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
|
|
14219
|
-
if (ls.status !== 0 || !ls.stdout) return;
|
|
14220
|
-
const existing = new Set(ls.stdout.split("\n").map((l2) => l2.trim()).filter(Boolean));
|
|
14221
|
-
for (const { volume, host } of pairs) {
|
|
14222
|
-
if (!existing.has(volume)) continue;
|
|
14223
|
-
mkdirSync3(host, { recursive: true });
|
|
14224
|
-
const alreadyThere = spawnSync4("sh", ["-c", `ls -A ${JSON.stringify(host)} 2>/dev/null | head -1`], {
|
|
14225
|
-
encoding: "utf8"
|
|
14226
|
-
});
|
|
14227
|
-
if (alreadyThere.stdout?.trim()) continue;
|
|
14228
|
-
console.log(` Moving edge state from volume ${volume} onto ${host}...`);
|
|
14229
|
-
const copy = spawnSync4(
|
|
14230
|
-
"docker",
|
|
14231
|
-
[
|
|
14232
|
-
"run",
|
|
14233
|
-
"--rm",
|
|
14234
|
-
"-v",
|
|
14235
|
-
`${volume}:/from:ro`,
|
|
14236
|
-
"-v",
|
|
14237
|
-
`${host}:/to`,
|
|
14238
|
-
"alpine:3",
|
|
14239
|
-
"sh",
|
|
14240
|
-
"-c",
|
|
14241
|
-
"cp -a /from/. /to/ 2>/dev/null || true"
|
|
14242
|
-
],
|
|
14243
|
-
{ stdio: "inherit" }
|
|
14244
|
-
);
|
|
14245
|
-
if (copy.status !== 0) {
|
|
14246
|
-
console.log(
|
|
14247
|
-
` Could not copy ${volume} \u2014 the stack will start with an empty ${host}.
|
|
14248
|
-
Recover manually with: docker run --rm -v ${volume}:/from -v ${host}:/to alpine cp -a /from/. /to/`
|
|
14249
|
-
);
|
|
14250
|
-
}
|
|
14251
|
-
}
|
|
14252
|
-
}
|
|
14253
|
-
function warnOrphanedVolumes(project) {
|
|
14254
|
-
const r2 = spawnSync4("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
|
|
14255
|
-
if (r2.status !== 0 || !r2.stdout) return;
|
|
14256
|
-
const others = new Set(
|
|
14257
|
-
r2.stdout.split("\n").map((l2) => l2.trim()).filter((l2) => l2.endsWith("_openship_sites")).map((l2) => l2.slice(0, -"_openship_sites".length)).filter((p2) => p2 && p2 !== project)
|
|
14258
|
-
);
|
|
14259
|
-
if (others.size === 0) return;
|
|
14260
|
-
console.log(
|
|
14261
|
-
` Note: volumes from a previous install (project "${[...others].join(", ")}") are still on disk
|
|
14262
|
-
and are NOT mounted by this stack \u2014 its database starts fresh. (Certificates and
|
|
14263
|
-
vhosts now live on the host, so those carry over.)
|
|
14264
|
-
Remove them with \`docker volume ls | grep _openship_\` once you're sure they aren't needed.`
|
|
14265
|
-
);
|
|
14266
|
-
}
|
|
14267
|
-
function keepConfig(prev, key, explicit) {
|
|
14268
|
-
const set = explicit?.trim();
|
|
14269
|
-
if (set) return set;
|
|
14270
|
-
const carried = prev[key]?.trim();
|
|
14271
|
-
return carried || void 0;
|
|
14272
|
-
}
|
|
14273
|
-
var ACME_ENV_KEYS = [
|
|
14274
|
-
"OPENSHIP_ACME_EMAIL",
|
|
14275
|
-
"OPENSHIP_ACME_DIRECTORY_URL",
|
|
14276
|
-
"OPENSHIP_ACME_EAB_KID",
|
|
14277
|
-
"OPENSHIP_ACME_EAB_HMAC_KEY",
|
|
14278
|
-
"OPENSHIP_ACME_KEY_TYPE",
|
|
14279
|
-
"OPENSHIP_ACME_CA_BUNDLE",
|
|
14280
|
-
"OPENSHIP_ACME_TOS_AGREED"
|
|
14281
|
-
];
|
|
14282
|
-
function hostGatewayEnv(prev) {
|
|
14283
|
-
const value = process.env.OPENSHIP_HOST_GATEWAY?.trim() || prev.OPENSHIP_HOST_GATEWAY?.trim();
|
|
14284
|
-
return value ? [`OPENSHIP_HOST_GATEWAY=${value}`] : [];
|
|
14285
|
-
}
|
|
14286
|
-
function rootlessSocketCandidates() {
|
|
14287
|
-
const runtimeDir = process.env.XDG_RUNTIME_DIR?.trim();
|
|
14288
|
-
const sudoUid = process.env.SUDO_UID?.trim();
|
|
14289
|
-
let uid;
|
|
14290
|
-
try {
|
|
14291
|
-
uid = userInfo().uid;
|
|
14292
|
-
} catch {
|
|
14293
|
-
}
|
|
14294
|
-
return [
|
|
14295
|
-
...runtimeDir ? [join6(runtimeDir, "docker.sock")] : [],
|
|
14296
|
-
...sudoUid ? [`/run/user/${sudoUid}/docker.sock`] : [],
|
|
14297
|
-
...uid !== void 0 ? [`/run/user/${uid}/docker.sock`] : []
|
|
14298
|
-
];
|
|
14299
|
-
}
|
|
14300
|
-
function detectDockerSocketPath() {
|
|
14301
|
-
const fromClient = resolveLocalDockerSocketPath(void 0, process.env);
|
|
14302
|
-
if (fromClient !== DEFAULT_DOCKER_SOCKET_PATH) return fromClient;
|
|
14303
|
-
if (existsSync4(DEFAULT_DOCKER_SOCKET_PATH)) return DEFAULT_DOCKER_SOCKET_PATH;
|
|
14304
|
-
return rootlessSocketCandidates().find((p2) => existsSync4(p2)) ?? DEFAULT_DOCKER_SOCKET_PATH;
|
|
14305
|
-
}
|
|
14306
|
-
function resolveDockerSocket(prev) {
|
|
14307
|
-
const pinned = process.env.OPENSHIP_DOCKER_SOCKET?.trim() || prev.OPENSHIP_DOCKER_SOCKET?.trim();
|
|
14308
|
-
return pinned ? { path: pinned, pinned: true } : { path: detectDockerSocketPath(), pinned: false };
|
|
14309
|
-
}
|
|
14310
|
-
function dockerSocketEnv(prev) {
|
|
14311
|
-
const { path, pinned } = resolveDockerSocket(prev);
|
|
14312
|
-
return pinned || path !== DEFAULT_DOCKER_SOCKET_PATH ? [`OPENSHIP_DOCKER_SOCKET=${path}`] : [];
|
|
14313
|
-
}
|
|
14314
|
-
function warnMissingDockerSocket(path) {
|
|
14315
|
-
console.warn(
|
|
14316
|
-
`
|
|
14317
|
-
! The Docker socket this install mounts into the api isn't there:
|
|
14318
|
-
${path}
|
|
14319
|
-
Docker will create that path as an empty DIRECTORY, so the stack will come up
|
|
14320
|
-
and then fail every container operation with a transport error naming neither
|
|
14321
|
-
this path nor the cause (#482). Point it at the real socket:
|
|
14322
|
-
docker context inspect --format '{{.Endpoints.docker.Host}}'
|
|
14323
|
-
OPENSHIP_DOCKER_SOCKET=/run/user/$(id -u)/docker.sock openship up --compose
|
|
14324
|
-
(a rootless daemon keeps its socket under the daemon user's runtime dir).
|
|
14325
|
-
`
|
|
14326
|
-
);
|
|
14327
|
-
}
|
|
14328
|
-
function renderAcmeEnv(prev) {
|
|
14329
|
-
return ACME_ENV_KEYS.flatMap((key) => {
|
|
14330
|
-
const value = process.env[key]?.trim() || prev[key]?.trim();
|
|
14331
|
-
if (!value) return [];
|
|
14332
|
-
if (/[\r\n]/.test(value)) throw new Error(`${key} must be a single-line value`);
|
|
14333
|
-
return [`${key}=${value}`];
|
|
14334
|
-
});
|
|
14335
|
-
}
|
|
14336
|
-
function resolveEnvConfig(prev, opts) {
|
|
14337
|
-
const publicUrl = keepConfig(prev, "OPENSHIP_PUBLIC_URL", opts.publicUrl);
|
|
14338
|
-
const bindAddr = keepConfig(prev, "OPENSHIP_BIND_ADDR") ?? (publicUrl ? "0.0.0.0" : void 0);
|
|
14339
|
-
return {
|
|
14340
|
-
apiPort: keepConfig(prev, "API_PORT", opts.apiPort) ?? String(DEFAULT_API_PORT),
|
|
14341
|
-
dashPort: keepConfig(prev, "DASHBOARD_PORT", opts.dashboardPort) ?? String(DEFAULT_DASHBOARD_PORT),
|
|
14342
|
-
...bindAddr ? { bindAddr } : {},
|
|
14343
|
-
...publicUrl ? { publicUrl } : {},
|
|
14344
|
-
// A public URL always implies a proxy in front; otherwise keep whatever the
|
|
14345
|
-
// install was configured with.
|
|
14346
|
-
trustProxy: Boolean(opts.trustProxy) || Boolean(publicUrl) || prev.TRUST_PROXY === "true",
|
|
14347
|
-
...keepConfig(prev, "OPENSHIP_EXTRA_TRUSTED_ORIGINS", opts.extraTrustedOrigins) ? {
|
|
14348
|
-
extraTrustedOrigins: keepConfig(
|
|
14349
|
-
prev,
|
|
14350
|
-
"OPENSHIP_EXTRA_TRUSTED_ORIGINS",
|
|
14351
|
-
opts.extraTrustedOrigins
|
|
14352
|
-
)
|
|
14353
|
-
} : {},
|
|
14354
|
-
registry: keepConfig(prev, "OPENSHIP_IMAGE_REGISTRY", opts.registry) ?? DEFAULT_IMAGE_REGISTRY,
|
|
14355
|
-
// Tri-state: the flag is absent on a plain re-run, so fall back to what the
|
|
14356
|
-
// install chose rather than silently re-granting host control.
|
|
14357
|
-
hostControl: opts.noHostControl === void 0 ? prev.OPENSHIP_HOST_CONTROL !== "false" : !opts.noHostControl,
|
|
14358
|
-
// Sticky too: `--mail` is only the install-time default, so a plain re-run
|
|
14359
|
-
// (or `openship update`) must not hand a mail box back the platform shell.
|
|
14360
|
-
// Turning it OFF is the dashboard's job — Settings → Instance stores the mode
|
|
14361
|
-
// in the database, which wins over this env var either way.
|
|
14362
|
-
productMode: opts.mail || keepConfig(prev, "OPENSHIP_PRODUCT") === "mail" ? "mail" : "platform",
|
|
14363
|
-
// Sticky like every other operator choice here. These were literals in
|
|
14364
|
-
// `renderEnv`, so a box where `host.docker.internal:22` is simply not the
|
|
14365
|
-
// right address — rootless Docker, sshd on another port — had nowhere to say
|
|
14366
|
-
// so, and every `openship up` overwrote a hand-edited `.env` back to the
|
|
14367
|
-
// value that didn't work (#490/#482).
|
|
14368
|
-
hostSshHost: keepConfig(prev, "OPENSHIP_HOST_SSH_HOST", opts.hostSshHost) ?? HOST_CHANNEL_DEFAULT_HOST,
|
|
14369
|
-
hostSshPort: keepConfig(prev, "OPENSHIP_HOST_SSH_PORT", opts.hostSshPort) ?? String(HOST_CHANNEL_DEFAULT_PORT)
|
|
14370
|
-
};
|
|
14371
|
-
}
|
|
14372
|
-
function toPort(value) {
|
|
14373
|
-
const n2 = Number(value);
|
|
14374
|
-
return Number.isInteger(n2) && n2 > 0 && n2 <= 65535 ? n2 : void 0;
|
|
14375
|
-
}
|
|
14376
|
-
function composeEnvPorts() {
|
|
14377
|
-
const env = readEnvFile();
|
|
14378
|
-
return { api: toPort(env.API_PORT), dashboard: toPort(env.DASHBOARD_PORT) };
|
|
14379
|
-
}
|
|
14380
|
-
function composeBindAddr() {
|
|
14381
|
-
return readEnvFile().OPENSHIP_BIND_ADDR?.trim() || "127.0.0.1";
|
|
14382
|
-
}
|
|
14383
|
-
function composeHostChannel() {
|
|
14384
|
-
const env = readEnvFile();
|
|
14385
|
-
const host = env.OPENSHIP_HOST_SSH_HOST?.trim();
|
|
14386
|
-
if (!host) return null;
|
|
14387
|
-
return {
|
|
14388
|
-
host,
|
|
14389
|
-
port: toPort(env.OPENSHIP_HOST_SSH_PORT) ?? 22,
|
|
14390
|
-
user: env.OPENSHIP_HOST_SSH_USER?.trim() || "root"
|
|
14391
|
-
};
|
|
14392
|
-
}
|
|
14393
|
-
function composeHostChannelExpected() {
|
|
14394
|
-
if (!existsSync4(COMPOSE_FILE)) return false;
|
|
14395
|
-
return readEnvFile().OPENSHIP_HOST_CONTROL !== "false";
|
|
14396
|
-
}
|
|
14397
|
-
function composeTrustedOriginUrls() {
|
|
14398
|
-
const env = readEnvFile();
|
|
14399
|
-
return [env.OPENSHIP_PUBLIC_URL, env.OPENSHIP_EXTRA_TRUSTED_ORIGINS].filter(
|
|
14400
|
-
(v2) => !!v2?.trim()
|
|
14401
|
-
);
|
|
14402
|
-
}
|
|
14403
|
-
function composeHeldPorts() {
|
|
14404
|
-
const r2 = spawnSync4(
|
|
14405
|
-
"docker",
|
|
14406
|
-
["ps", "--format", '{{.Label "com.docker.compose.project.config_files"}} {{.Ports}}'],
|
|
14407
|
-
{ encoding: "utf8" }
|
|
14408
|
-
);
|
|
14409
|
-
if (r2.status !== 0 || !r2.stdout) return [];
|
|
14410
|
-
const ports = /* @__PURE__ */ new Set();
|
|
14411
|
-
for (const line of r2.stdout.split("\n")) {
|
|
14412
|
-
const [configFiles = "", published = ""] = line.split(" ");
|
|
14413
|
-
if (!configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE)) continue;
|
|
14414
|
-
for (const m2 of published.matchAll(/:(\d+)->/g)) ports.add(Number(m2[1]));
|
|
14415
|
-
}
|
|
14416
|
-
return [...ports];
|
|
14417
|
-
}
|
|
14418
|
-
async function resolveComposePorts(prefs, opts = {}) {
|
|
14419
|
-
return resolvePorts(
|
|
14420
|
-
{
|
|
14421
|
-
api: toPort(prefs.api),
|
|
14422
|
-
dashboard: toPort(prefs.dashboard),
|
|
14423
|
-
previous: composeEnvPorts(),
|
|
14424
|
-
bindAddr: composeBindAddr(),
|
|
14425
|
-
reclaimable: composeHeldPorts()
|
|
14426
|
-
},
|
|
14427
|
-
opts
|
|
14428
|
-
);
|
|
14429
|
-
}
|
|
14430
|
-
var OMITTABLE_MANAGED_KEYS = /* @__PURE__ */ new Set([
|
|
14431
|
-
"OPENSHIP_HOST_SSH_HOST",
|
|
14432
|
-
"OPENSHIP_HOST_SSH_USER",
|
|
14433
|
-
"OPENSHIP_HOST_SSH_PORT",
|
|
14434
|
-
"OPENSHIP_HOST_SSH_KEY",
|
|
14435
|
-
"OPENSHIP_HOST_KEY_PATH",
|
|
14436
|
-
"OPENSHIP_HOST_GATEWAY",
|
|
14437
|
-
"TRUST_PROXY"
|
|
14438
|
-
]);
|
|
14439
|
-
var envKeyOf = (line) => line.match(/^([A-Z0-9_]+)=/)?.[1];
|
|
14440
|
-
function operatorEnvPassthrough(managed, prev) {
|
|
14441
|
-
return Object.entries(prev).filter(([key]) => !managed.has(key) && !OMITTABLE_MANAGED_KEYS.has(key)).map(([key, value]) => `${key}=${value}`);
|
|
14442
|
-
}
|
|
14443
|
-
var PRESERVED_ENV_HEADER = "# Preserved from your existing .env \u2014 set by you, not by `openship up` (#485).";
|
|
14444
|
-
function resolveImageVersion(opts) {
|
|
14445
|
-
return opts.version?.trim() || process.env.OPENSHIP_VERSION?.trim() || (true ? "0.6.5" : "latest");
|
|
14446
|
-
}
|
|
14447
|
-
function managedEnvLines(opts, host, cfg, prev) {
|
|
14448
|
-
const lines = [
|
|
14449
|
-
"# Managed by `openship up`. Secrets are generated once and preserved.",
|
|
14450
|
-
// Do NOT edit on a live install — it is the volume prefix (see composeProjectName).
|
|
14451
|
-
`COMPOSE_PROJECT_NAME=${composeProjectName(prev)}`,
|
|
14452
|
-
"CLOUD_MODE=false",
|
|
14453
|
-
"OPENSHIP_TARGET=local",
|
|
14454
|
-
"OPENSHIP_REQUIRE_AUTH=true",
|
|
14455
|
-
// Read by createHostExecutor (throws when false) and by the servers list
|
|
14456
|
-
// (hides the local row). Written explicitly so the policy is visible in .env.
|
|
14457
|
-
`OPENSHIP_HOST_CONTROL=${cfg.hostControl ? "true" : "false"}`,
|
|
14458
|
-
// The host side of the api's docker socket mount, when it isn't the rootful
|
|
14459
|
-
// default. Unlike the host channel above this is NOT optional plumbing — every
|
|
14460
|
-
// deploy goes through it — so it is resolved on every run, host control or not.
|
|
14461
|
-
...dockerSocketEnv(prev),
|
|
14462
|
-
// Which shell the dashboard defaults to. Emitted on EVERY run, including the
|
|
14463
|
-
// "platform" default: that keeps the key permanently in the managed set, so
|
|
14464
|
-
// the #485 passthrough can never mistake it for operator config (which would
|
|
14465
|
-
// pin it and make the Settings → Instance toggle look broken after an `up`).
|
|
14466
|
-
`OPENSHIP_PRODUCT=${cfg.productMode}`,
|
|
14467
|
-
`OPENSHIP_IMAGE_REGISTRY=${cfg.registry}`,
|
|
14468
|
-
`OPENSHIP_VERSION=${resolveImageVersion(opts)}`,
|
|
14469
|
-
`POSTGRES_PASSWORD=${keepSecret(prev, "POSTGRES_PASSWORD")}`,
|
|
14470
|
-
// Pinned once (see resolvePgData): fresh install → subdir, existing volume → root.
|
|
14471
|
-
`OPENSHIP_PGDATA=${resolvePgData(prev)}`,
|
|
14472
|
-
`BETTER_AUTH_SECRET=${keepSecret(prev, "BETTER_AUTH_SECRET")}`,
|
|
14473
|
-
`INTERNAL_TOKEN=${keepSecret(prev, "INTERNAL_TOKEN")}`,
|
|
14474
|
-
`API_PORT=${cfg.apiPort}`,
|
|
14475
|
-
`DASHBOARD_PORT=${cfg.dashPort}`,
|
|
14476
|
-
// The api's OWN view of the dashboard port: the self-app boot reconcile points
|
|
14477
|
-
// the operator's domain at the dashboard through this (self-deploy.ts), and it
|
|
14478
|
-
// silently defaults to 3001 when unset. Ports are dynamic now, so leaving it
|
|
14479
|
-
// out publishes a domain routed to a port nothing is listening on.
|
|
14480
|
-
`OPENSHIP_DASHBOARD_PORT=${cfg.dashPort}`,
|
|
14481
|
-
// Alternate CA/EAB values are operator configuration (including one secret),
|
|
14482
|
-
// so a routine `openship up`/upgrade must not silently discard them.
|
|
14483
|
-
...renderAcmeEnv(prev)
|
|
14484
|
-
];
|
|
14485
|
-
if (cfg.bindAddr) lines.push(`OPENSHIP_BIND_ADDR=${cfg.bindAddr}`);
|
|
14486
|
-
if (cfg.publicUrl) lines.push(`OPENSHIP_PUBLIC_URL=${cfg.publicUrl}`);
|
|
14487
|
-
if (cfg.extraTrustedOrigins) {
|
|
14488
|
-
lines.push(`OPENSHIP_EXTRA_TRUSTED_ORIGINS=${cfg.extraTrustedOrigins}`);
|
|
14489
|
-
}
|
|
14490
|
-
if (cfg.trustProxy) lines.push("TRUST_PROXY=true");
|
|
14491
|
-
if (host) {
|
|
14492
|
-
lines.push(
|
|
14493
|
-
`OPENSHIP_HOST_SSH_HOST=${cfg.hostSshHost}`,
|
|
14494
|
-
`OPENSHIP_HOST_SSH_USER=${host.user}`,
|
|
14495
|
-
`OPENSHIP_HOST_SSH_PORT=${cfg.hostSshPort}`,
|
|
14496
|
-
"OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key",
|
|
14497
|
-
`OPENSHIP_HOST_KEY_PATH=${host.keyPath}`,
|
|
14498
|
-
...hostGatewayEnv(prev)
|
|
14499
|
-
);
|
|
14500
|
-
}
|
|
14501
|
-
return lines;
|
|
14502
|
-
}
|
|
14503
|
-
function renderEnvAndCarried(opts, host, cfg, prev) {
|
|
14504
|
-
const lines = managedEnvLines(opts, host, cfg, prev);
|
|
14505
|
-
const managed = new Set(lines.map(envKeyOf).filter((k2) => !!k2));
|
|
14506
|
-
const preserved = operatorEnvPassthrough(managed, prev);
|
|
14507
|
-
const body = preserved.length ? [...lines, "", PRESERVED_ENV_HEADER, ...preserved] : lines;
|
|
14508
|
-
return {
|
|
14509
|
-
text: body.join("\n") + "\n",
|
|
14510
|
-
carried: preserved.map((line) => line.slice(0, line.indexOf("=")))
|
|
14511
|
-
};
|
|
14512
|
-
}
|
|
14513
|
-
function renderEnv(opts, host, cfg) {
|
|
14514
|
-
return renderEnvAndCarried(opts, host, cfg, readEnvFile()).text;
|
|
14515
|
-
}
|
|
14516
|
-
function sourceBuildDir() {
|
|
14517
|
-
const marker = readSourceInstall();
|
|
14518
|
-
if (!marker?.dir) return null;
|
|
14519
|
-
const hasAll = BUILT_SERVICES.every((s2) => existsSync4(join6(marker.dir, s2.dockerfile)));
|
|
14520
|
-
return hasAll ? marker.dir : null;
|
|
14521
|
-
}
|
|
14522
|
-
function renderBuildOverride(repoDir) {
|
|
14523
|
-
const services = BUILT_SERVICES.map(
|
|
14524
|
-
(s2) => ` ${s2.service}:
|
|
14525
|
-
build:
|
|
14526
|
-
context: ${repoDir}
|
|
14527
|
-
dockerfile: ${s2.dockerfile}
|
|
14528
|
-
`
|
|
14529
|
-
).join("");
|
|
14530
|
-
return `# Managed by \`openship up\` (from-source install) \u2014 builds instead of pulls.
|
|
14531
|
-
services:
|
|
14532
|
-
${services}`;
|
|
14533
|
-
}
|
|
14534
|
-
function materialize(opts) {
|
|
14535
|
-
mkdirSync3(COMPOSE_DIR, { recursive: true, mode: 448 });
|
|
14536
|
-
const prev = readEnvFile();
|
|
14537
|
-
const cfg = resolveEnvConfig(prev, opts);
|
|
14538
|
-
const provision = provisionHostSshChannel(cfg);
|
|
14539
|
-
let host = provision.channel;
|
|
14540
|
-
let kept = false;
|
|
14541
|
-
if (!host && provision.issue) {
|
|
14542
|
-
const previousChannel = carriedHostChannel(prev);
|
|
14543
|
-
if (previousChannel) {
|
|
14544
|
-
host = previousChannel;
|
|
14545
|
-
kept = true;
|
|
14546
|
-
}
|
|
14547
|
-
}
|
|
14548
|
-
if (!cfg.hostControl) retireHostSshChannel(prev);
|
|
14549
|
-
if (provision.issue && !opts.alreadyFetched) {
|
|
14550
|
-
console.warn(
|
|
14551
|
-
renderHostChannelIssue(provision.issue, {
|
|
14552
|
-
target: `${cfg.hostSshHost}:${cfg.hostSshPort}`,
|
|
14553
|
-
kept
|
|
14554
|
-
})
|
|
14555
|
-
);
|
|
14556
|
-
}
|
|
14557
|
-
const socket = resolveDockerSocket(prev);
|
|
14558
|
-
if (!opts.alreadyFetched && !existsSync4(socket.path)) warnMissingDockerSocket(socket.path);
|
|
14559
|
-
let before = "";
|
|
14560
|
-
try {
|
|
14561
|
-
before = readFileSync3(ENV_FILE, "utf8");
|
|
14562
|
-
} catch {
|
|
14563
|
-
}
|
|
14564
|
-
const { text: rendered, carried } = renderEnvAndCarried(opts, host, cfg, prev);
|
|
14565
|
-
writeFileSync2(COMPOSE_FILE, COMPOSE_YAML);
|
|
14566
|
-
writeEnvFile(rendered, before);
|
|
14567
|
-
if (carried.length) {
|
|
14568
|
-
console.log(` Kept ${carried.length} operator-set .env variable(s): ${carried.join(", ")}`);
|
|
14569
|
-
}
|
|
14570
|
-
const buildDir = opts.build === false ? null : sourceBuildDir();
|
|
14571
|
-
if (buildDir) writeFileSync2(BUILD_FILE, renderBuildOverride(buildDir));
|
|
14572
|
-
return { buildDir, cfg, envChanged: before !== "" && rendered !== before };
|
|
14573
|
-
}
|
|
14574
|
-
function writeEnvFile(rendered, before) {
|
|
14575
|
-
if (rendered === before) return;
|
|
14576
|
-
if (before) writeFileSync2(ENV_BACKUP_FILE, before, { mode: 384 });
|
|
14577
|
-
writeFileSync2(ENV_TMP_FILE, rendered, { mode: 384 });
|
|
14578
|
-
renameSync(ENV_TMP_FILE, ENV_FILE);
|
|
14579
|
-
}
|
|
14580
|
-
var SECRET_ENV_KEY = /(PASSWORD|SECRET|TOKEN|HMAC_KEY)$/;
|
|
14581
|
-
function composePlan(opts) {
|
|
14582
|
-
const prev = readEnvFile();
|
|
14583
|
-
const cfg = resolveEnvConfig(prev, opts);
|
|
14584
|
-
const buildDir = opts.build === false ? null : sourceBuildDir();
|
|
14585
|
-
const hostChannel = plannedHostChannel(cfg.hostControl);
|
|
14586
|
-
const settings = [];
|
|
14587
|
-
const newSecrets = [];
|
|
14588
|
-
for (const line of renderEnv(opts, hostChannel, cfg).split("\n")) {
|
|
14589
|
-
const at2 = line.indexOf("=");
|
|
14590
|
-
if (at2 < 1 || line.startsWith("#")) continue;
|
|
14591
|
-
const key = line.slice(0, at2);
|
|
14592
|
-
const value = line.slice(at2 + 1);
|
|
14593
|
-
if (!SECRET_ENV_KEY.test(key)) {
|
|
14594
|
-
settings.push({ key, value });
|
|
14595
|
-
continue;
|
|
14596
|
-
}
|
|
14597
|
-
const preserved = !!prev[key] && prev[key] === value;
|
|
14598
|
-
if (!preserved) newSecrets.push(key);
|
|
14599
|
-
settings.push({ key, value: preserved ? "<preserved>" : "<generated on this run>" });
|
|
14600
|
-
}
|
|
14601
|
-
return {
|
|
14602
|
-
composeFile: COMPOSE_FILE,
|
|
14603
|
-
envFile: ENV_FILE,
|
|
14604
|
-
installMethodFile: INSTALL_METHOD_FILE,
|
|
14605
|
-
...buildDir ? { buildFile: BUILD_FILE } : {},
|
|
14606
|
-
yaml: COMPOSE_YAML,
|
|
14607
|
-
settings,
|
|
14608
|
-
newSecrets,
|
|
14609
|
-
secretRotation: opts.resetSecrets ? null : secretRotationRisk(prev),
|
|
14610
|
-
pgDataRisk: pgDataDetectionRisk(prev),
|
|
14611
|
-
existing: Object.keys(prev).length > 0,
|
|
14612
|
-
mountDirs: EDGE_CONTAINER_MOUNTS.map((m2) => m2.host),
|
|
14613
|
-
buildDir,
|
|
14614
|
-
hostChannel
|
|
14615
|
-
};
|
|
14616
|
-
}
|
|
14617
|
-
function dbVolumeExists(project) {
|
|
14618
|
-
const r2 = spawnSync4("docker", ["volume", "inspect", `${project}_postgres_data`], {
|
|
14619
|
-
stdio: "ignore"
|
|
14620
|
-
});
|
|
14621
|
-
return r2.status === 0;
|
|
14622
|
-
}
|
|
14623
|
-
function compose(args, opts) {
|
|
14624
|
-
const files = ["-f", COMPOSE_FILE];
|
|
14625
|
-
if (opts?.withBuildOverride) files.push("-f", BUILD_FILE);
|
|
14626
|
-
const r2 = spawnSync4("docker", ["compose", ...files, ...args], {
|
|
14627
|
-
cwd: COMPOSE_DIR,
|
|
14628
|
-
stdio: opts?.quiet ? "ignore" : "inherit"
|
|
14629
|
-
});
|
|
14630
|
-
return r2.status ?? 1;
|
|
14631
|
-
}
|
|
14632
|
-
function missingPinnedImages(registry, version) {
|
|
14633
|
-
const missing = [];
|
|
14634
|
-
for (const { service } of BUILT_SERVICES) {
|
|
14635
|
-
const ref = `${registry}/openship-${service}:${version}`;
|
|
14636
|
-
const r2 = spawnSync4("docker", ["manifest", "inspect", ref], { encoding: "utf8" });
|
|
14637
|
-
if (r2.status === 0) continue;
|
|
14638
|
-
const stderr = `${r2.stderr ?? ""}`.toLowerCase();
|
|
14639
|
-
if (/manifest unknown|no such manifest|manifest for .+ not found/.test(stderr)) {
|
|
14640
|
-
missing.push(ref);
|
|
14641
|
-
continue;
|
|
14642
|
-
}
|
|
14643
|
-
return null;
|
|
14644
|
-
}
|
|
14645
|
-
return missing;
|
|
14646
|
-
}
|
|
14647
|
-
function pinnedImagesReady(opts) {
|
|
14648
|
-
if (sourceBuildDir()) return true;
|
|
14649
|
-
const prev = readEnvFile();
|
|
14650
|
-
const version = resolveImageVersion(opts);
|
|
14651
|
-
const missing = missingPinnedImages(resolveEnvConfig(prev, opts).registry, version);
|
|
14652
|
-
if (!missing || missing.length === 0) return true;
|
|
14653
|
-
const prevVersion = prev.OPENSHIP_VERSION?.trim();
|
|
14654
|
-
const out = [
|
|
14655
|
-
"",
|
|
14656
|
-
` The pinned image tag \`${version}\` isn't in the registry yet:`,
|
|
14657
|
-
...missing.map((ref) => ` ${ref}`),
|
|
14658
|
-
"",
|
|
14659
|
-
" The CLI reached npm before its container images finished publishing \u2014 a",
|
|
14660
|
-
" release-ordering race (#486). Nothing on this box was changed.",
|
|
14661
|
-
"",
|
|
14662
|
-
" Any one of these gets you moving:",
|
|
14663
|
-
" \u2022 Wait a few minutes and re-run \u2014 the images usually land right after a release.",
|
|
14664
|
-
" \u2022 Pin a tag that exists:",
|
|
14665
|
-
" openship up --compose --image-version <tag>",
|
|
14666
|
-
" OPENSHIP_VERSION=<tag> openship up --compose"
|
|
14667
|
-
];
|
|
14668
|
-
if (prevVersion && prevVersion !== version) {
|
|
14669
|
-
out.push(` \u2022 Your current install runs \`${prevVersion}\`, a tag known good on this box.`);
|
|
14670
|
-
}
|
|
14671
|
-
out.push("");
|
|
14672
|
-
console.error(out.join("\n"));
|
|
14673
|
-
return false;
|
|
14674
|
-
}
|
|
14675
|
-
function composePrefetch(opts) {
|
|
14676
|
-
const rotation = composeSecretRotationRisk(opts);
|
|
14677
|
-
if (rotation) {
|
|
14678
|
-
console.error(renderSecretRotationRefusal(rotation));
|
|
14679
|
-
return { ok: false, envChanged: false };
|
|
14680
|
-
}
|
|
14681
|
-
const { buildDir, envChanged } = materialize(opts);
|
|
14682
|
-
if (buildDir) {
|
|
14683
|
-
return {
|
|
14684
|
-
ok: compose(["pull", "postgres", "redis"], { withBuildOverride: true }) === 0 && compose(["build"], { withBuildOverride: true }) === 0,
|
|
14685
|
-
envChanged
|
|
14686
|
-
};
|
|
14687
|
-
}
|
|
14688
|
-
return { ok: compose(["pull"]) === 0, envChanged };
|
|
14689
|
-
}
|
|
14690
|
-
function ensureEdgeMountDirs() {
|
|
14691
|
-
const failed = [];
|
|
14692
|
-
for (const { host } of EDGE_CONTAINER_MOUNTS) {
|
|
14693
|
-
try {
|
|
14694
|
-
mkdirSync3(host, { recursive: true });
|
|
14695
|
-
} catch (err) {
|
|
14696
|
-
if (err.code !== "EEXIST") failed.push(host);
|
|
14697
|
-
}
|
|
14698
|
-
}
|
|
14699
|
-
return failed;
|
|
14700
|
-
}
|
|
14701
|
-
function isRootlessDocker() {
|
|
14702
|
-
const r2 = spawnSync4("docker", ["info", "-f", "{{println .SecurityOptions}}"], {
|
|
14703
|
-
encoding: "utf8"
|
|
14704
|
-
});
|
|
14705
|
-
return r2.status === 0 && /rootless/i.test(r2.stdout ?? "");
|
|
14706
|
-
}
|
|
14707
|
-
async function composeUp(opts) {
|
|
14708
|
-
if (!opts.alreadyFetched && !pinnedImagesReady(opts)) {
|
|
14709
|
-
const pre = resolveEnvConfig(readEnvFile(), opts);
|
|
14710
|
-
return { ok: false, apiPort: pre.apiPort, dashPort: pre.dashPort };
|
|
14711
|
-
}
|
|
14712
|
-
const rotation = composeSecretRotationRisk(opts);
|
|
14713
|
-
if (rotation) {
|
|
14714
|
-
const pre = resolveEnvConfig(readEnvFile(), opts);
|
|
14715
|
-
console.error(renderSecretRotationRefusal(rotation));
|
|
14716
|
-
return { ok: false, refused: true, apiPort: pre.apiPort, dashPort: pre.dashPort };
|
|
14717
|
-
}
|
|
14718
|
-
const pgData = composePgDataRisk(opts);
|
|
14719
|
-
if (pgData) {
|
|
14720
|
-
const pre = resolveEnvConfig(readEnvFile(), opts);
|
|
14721
|
-
console.error(renderPgDataRefusal(pgData));
|
|
14722
|
-
return { ok: false, refused: true, apiPort: pre.apiPort, dashPort: pre.dashPort };
|
|
14723
|
-
}
|
|
14724
|
-
const { buildDir, cfg, envChanged: rendered } = materialize(opts);
|
|
14725
|
-
const apiPort = cfg.apiPort;
|
|
14726
|
-
const dashPort = cfg.dashPort;
|
|
14727
|
-
const envChanged = opts.envChanged ?? rendered;
|
|
14728
|
-
const up = (extra = {}) => compose(
|
|
14729
|
-
envChanged ? ["up", "-d", "--force-recreate", ...ENV_CONSUMING_SERVICES] : ["up", "-d"],
|
|
14730
|
-
extra
|
|
14731
|
-
);
|
|
14732
|
-
const env = readEnvFile();
|
|
14733
|
-
const project = env.COMPOSE_PROJECT_NAME || "openship";
|
|
14734
|
-
removeOrphanedStack(project);
|
|
14735
|
-
reconcileEdgeContainerName(project);
|
|
14736
|
-
migrateLegacyEdgeVolumes(project);
|
|
14737
|
-
warnOrphanedVolumes(project);
|
|
14738
|
-
const unmakeableMounts = ensureEdgeMountDirs();
|
|
14739
|
-
if (unmakeableMounts.length && isRootlessDocker()) {
|
|
14740
|
-
const dirs = unmakeableMounts.join(" ");
|
|
14741
|
-
console.error(
|
|
14742
|
-
`
|
|
14743
|
-
Rootless Docker can't create the edge's host directories under root-owned paths:
|
|
14744
|
-
${unmakeableMounts.join("\n ")}
|
|
14745
|
-
|
|
14746
|
-
Create them once (owned by your user), then re-run \`openship up --compose\`:
|
|
14747
|
-
sudo mkdir -p ${dirs}
|
|
14748
|
-
sudo chown -R "$(id -un)" ${dirs}
|
|
14749
|
-
`
|
|
14750
|
-
);
|
|
14751
|
-
return { ok: false, apiPort, dashPort };
|
|
14752
|
-
}
|
|
14753
|
-
if (dbVolumeExists(project)) {
|
|
14754
|
-
reconcileDbPassword(env.POSTGRES_USER || "openship", env.POSTGRES_PASSWORD ?? "");
|
|
14755
|
-
}
|
|
14756
|
-
await sanitizeEdgeVhosts(
|
|
14757
|
-
new LocalExecutor(),
|
|
14758
|
-
EDGE_SITES_HOST_DIR,
|
|
14759
|
-
(l2) => console.log(` ${l2.message}`)
|
|
14760
|
-
).catch(() => {
|
|
14761
|
-
});
|
|
14762
|
-
if (buildDir) {
|
|
14763
|
-
if (!opts.alreadyFetched) {
|
|
14764
|
-
if (compose(["pull", "postgres", "redis"], { withBuildOverride: true }) !== 0) {
|
|
14765
|
-
return { ok: false, apiPort, dashPort };
|
|
14766
|
-
}
|
|
14767
|
-
if (compose(["build"], { withBuildOverride: true }) !== 0) {
|
|
14768
|
-
return { ok: false, apiPort, dashPort };
|
|
14769
|
-
}
|
|
14770
|
-
}
|
|
14771
|
-
if (up({ withBuildOverride: true }) !== 0) {
|
|
14772
|
-
return { ok: false, apiPort, dashPort };
|
|
14773
|
-
}
|
|
14774
|
-
onEdgeContainerChanged();
|
|
14775
|
-
writeInstallMethod("compose");
|
|
14776
|
-
return { ok: true, apiPort, dashPort };
|
|
14777
|
-
}
|
|
14778
|
-
if (!opts.alreadyFetched && compose(["pull"]) !== 0) {
|
|
14779
|
-
return { ok: false, apiPort, dashPort };
|
|
14780
|
-
}
|
|
14781
|
-
if (up() !== 0) return { ok: false, apiPort, dashPort };
|
|
14782
|
-
onEdgeContainerChanged();
|
|
14783
|
-
writeInstallMethod("compose");
|
|
14784
|
-
return { ok: true, apiPort, dashPort };
|
|
14785
|
-
}
|
|
14786
|
-
function onEdgeContainerChanged() {
|
|
14787
|
-
invalidateEdgeContainer();
|
|
14788
|
-
}
|
|
14789
|
-
function composeDown() {
|
|
14790
|
-
if (!existsSync4(COMPOSE_FILE)) return false;
|
|
14791
|
-
return compose(["down"]) === 0;
|
|
14792
|
-
}
|
|
14793
|
-
function composeUninstall(opts = {}) {
|
|
14794
|
-
const removedImages = [];
|
|
14795
|
-
const ok = existsSync4(COMPOSE_FILE) ? compose(["down", "-v", "--remove-orphans"]) === 0 : false;
|
|
14796
|
-
if (opts.removeImages) {
|
|
14797
|
-
const env = readEnvFile();
|
|
14798
|
-
const registry = env.OPENSHIP_IMAGE_REGISTRY || DEFAULT_IMAGE_REGISTRY;
|
|
14799
|
-
const version = env.OPENSHIP_VERSION || "latest";
|
|
14800
|
-
for (const { service } of BUILT_SERVICES) {
|
|
14801
|
-
const ref = `${registry}/openship-${service}:${version}`;
|
|
14802
|
-
if (spawnSync4("docker", ["image", "rm", "-f", ref], { stdio: "ignore" }).status === 0) {
|
|
14803
|
-
removedImages.push(ref);
|
|
14804
|
-
}
|
|
14805
|
-
}
|
|
14806
|
-
}
|
|
14807
|
-
return { ok, removedImages };
|
|
14808
|
-
}
|
|
14809
|
-
async function composeUpdate(version) {
|
|
14810
|
-
if (!existsSync4(COMPOSE_FILE)) return { applied: false, refused: false };
|
|
14811
|
-
const r2 = await composeUp(version ? { version } : {});
|
|
14812
|
-
return { applied: r2.ok, refused: !!r2.refused };
|
|
14813
|
-
}
|
|
14814
|
-
function composePs() {
|
|
14815
|
-
return compose(["ps"]);
|
|
14816
|
-
}
|
|
14817
|
-
function composeRunning() {
|
|
14818
|
-
const r2 = spawnSync4(
|
|
14819
|
-
"docker",
|
|
14820
|
-
["compose", "-f", COMPOSE_FILE, "ps", "--status", "running", "-q"],
|
|
14821
|
-
{ cwd: COMPOSE_DIR, encoding: "utf8" }
|
|
14822
|
-
);
|
|
14823
|
-
return r2.status === 0 && r2.stdout.trim().length > 0;
|
|
14824
|
-
}
|
|
14825
|
-
function composeStart() {
|
|
14826
|
-
reconcileEdgeContainerName(readEnvFile().COMPOSE_PROJECT_NAME || "openship");
|
|
14827
|
-
return compose(["up", "-d"], { quiet: true }) === 0;
|
|
14828
|
-
}
|
|
14829
|
-
function composeRestart() {
|
|
14830
|
-
return compose(["restart"], { quiet: true }) === 0;
|
|
14831
|
-
}
|
|
14832
|
-
function composeInternalToken() {
|
|
14833
|
-
return readEnvFile().INTERNAL_TOKEN ?? null;
|
|
14834
|
-
}
|
|
14835
|
-
var composePaths = {
|
|
14836
|
-
dir: COMPOSE_DIR,
|
|
14837
|
-
file: COMPOSE_FILE,
|
|
14838
|
-
env: ENV_FILE,
|
|
14839
|
-
envBackup: ENV_BACKUP_FILE
|
|
14840
|
-
};
|
|
14841
13835
|
|
|
14842
13836
|
export {
|
|
14843
|
-
|
|
14844
|
-
saveInstanceUrl,
|
|
14845
|
-
readInstanceUrl,
|
|
14846
|
-
readStoredPorts,
|
|
14847
|
-
storedApiPort,
|
|
14848
|
-
storedDashboardPort,
|
|
14849
|
-
portMoveNotice,
|
|
14850
|
-
resolvePorts,
|
|
13837
|
+
resolveLocalEnvironmentSync,
|
|
14851
13838
|
thisHost,
|
|
13839
|
+
pasteable,
|
|
14852
13840
|
startUnitHint,
|
|
14853
|
-
|
|
14854
|
-
|
|
14855
|
-
|
|
14856
|
-
|
|
14857
|
-
|
|
14858
|
-
prepareFromSource,
|
|
14859
|
-
readSourceInstall,
|
|
14860
|
-
remoteSha,
|
|
14861
|
-
rebuildFromSource,
|
|
14862
|
-
readInstallMethod,
|
|
14863
|
-
hasDockerCompose,
|
|
14864
|
-
dockerInstallPreview,
|
|
14865
|
-
ensureDocker,
|
|
14866
|
-
composeSecretRotationRisk,
|
|
14867
|
-
renderSecretRotationRefusal,
|
|
14868
|
-
composePgDataRisk,
|
|
14869
|
-
renderPgDataRefusal,
|
|
14870
|
-
composeHostChannel,
|
|
14871
|
-
composeHostChannelExpected,
|
|
14872
|
-
composeTrustedOriginUrls,
|
|
14873
|
-
resolveComposePorts,
|
|
14874
|
-
sourceBuildDir,
|
|
14875
|
-
composePlan,
|
|
14876
|
-
pinnedImagesReady,
|
|
14877
|
-
composePrefetch,
|
|
14878
|
-
composeUp,
|
|
14879
|
-
composeDown,
|
|
14880
|
-
composeUninstall,
|
|
14881
|
-
composeUpdate,
|
|
14882
|
-
composePs,
|
|
14883
|
-
composeRunning,
|
|
14884
|
-
composeStart,
|
|
14885
|
-
composeRestart,
|
|
14886
|
-
composeInternalToken,
|
|
14887
|
-
composePaths
|
|
13841
|
+
COMPOSE_DIR,
|
|
13842
|
+
COMPOSE_FILE,
|
|
13843
|
+
COMPOSE_ENV_FILE,
|
|
13844
|
+
composeInstallExists,
|
|
13845
|
+
readComposeEnvFile
|
|
14888
13846
|
};
|