openship 0.6.5 → 0.6.6
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-XQIPDO4R.js → bare-OXJVW3AK.js} +11 -10
- package/dist/{chunk-WE7BXW6L.js → chunk-2YM3NI2E.js} +2 -2
- package/dist/{chunk-RFWJE7ZN.js → chunk-4I2BM2DT.js} +3 -3
- package/dist/{chunk-QLX7NBEA.js → chunk-5DMWSTOM.js} +78 -1836
- package/dist/{chunk-VDESJSG6.js → chunk-77QDF6UD.js} +135 -21
- package/dist/{chunk-7334NSJL.js → chunk-A4GXYE4L.js} +67 -31
- package/dist/{chunk-HPXP6LXZ.js → chunk-CBLD2HS5.js} +3 -3
- package/dist/{chunk-6JELW75A.js → chunk-FE7ZZQSE.js} +3 -3
- package/dist/{chunk-XVOBL7BC.js → chunk-HKRQXPTI.js} +1 -1
- package/dist/{chunk-GRTXMUHA.js → chunk-LFE5KQ7Y.js} +11 -11
- package/dist/{chunk-TM2IQPYU.js → chunk-LH2PLRQO.js} +2 -2
- package/dist/{chunk-LFCSC4IU.js → chunk-LUKYPVJ4.js} +3 -3
- package/dist/{chunk-VHMOQL5Y.js → chunk-MLYSEZQB.js} +115 -19
- package/dist/{chunk-7S4USQYF.js → chunk-NK5N5LXN.js} +50 -122
- package/dist/{chunk-3ZAGC5NT.js → chunk-OBPKYXQL.js} +17 -6
- package/dist/{chunk-5OUWIPOB.js → chunk-PCPVTH6M.js} +12 -84
- package/dist/chunk-Q7WGTQQ5.js +88 -0
- package/dist/{chunk-VLQ3UIVO.js → chunk-QVW76WDL.js} +52 -26
- package/dist/{chunk-PZRRL5PC.js → chunk-RSR7PFIQ.js} +14 -3
- package/dist/{chunk-A7EWM2KA.js → chunk-SM26HDS2.js} +1 -1
- package/dist/{chunk-LEYSL46B.js → chunk-UJ6JZM65.js} +3426 -632
- package/dist/{chunk-WFRXYWK2.js → chunk-UVNMQM7W.js} +1 -1
- package/dist/chunk-VTGPEC6B.js +308 -0
- package/dist/chunk-WVAML4H6.js +2145 -0
- package/dist/{chunk-MSZUCNPV.js → chunk-YIMOCZBZ.js} +1 -1
- package/dist/{chunk-EZPPTWJQ.js → chunk-YMMQRE5J.js} +1 -1
- package/dist/{cloud-3DODOWTR.js → cloud-75VIT4KV.js} +16 -15
- package/dist/{detect-P4CIQBRH.js → detect-OIE5V3VZ.js} +3 -3
- package/dist/{docker-3K2NEBD6.js → docker-VTR2GK5Y.js} +7 -6
- package/dist/{docker-edge-executor-YZZ6P7IC.js → docker-edge-executor-22IBXNYJ.js} +4 -4
- package/dist/{edge-import-MCJNVTZ2.js → edge-import-WJUB4M7F.js} +23 -22
- package/dist/{edge-preflight-ES6FH4JK.js → edge-preflight-GBNCPNVU.js} +10 -10
- package/dist/{ensure-container-edge-YPITNH45.js → ensure-container-edge-KPI2CVXJ.js} +9 -9
- package/dist/{executor-DZGX55K2.js → executor-7LXXZPBR.js} +6 -3
- package/dist/{host-channel-preflight-MUXE3KZU.js → host-channel-preflight-KE6OONYK.js} +23 -22
- package/dist/index.js +95 -84
- package/dist/{nginx-NSAHWGJV.js → nginx-KUFMLMAC.js} +9 -7
- package/dist/{openresty-lua-DR4D3EZ6.js → openresty-lua-ATTH35BE.js} +6 -4
- package/dist/{privilege-YTVI4SKF.js → privilege-I4W3ZNZK.js} +4 -4
- 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 +35002 -27533
- 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-WUUZURHS.js → setup-XGLGUUFW.js} +10 -10
- package/dist/ssh-client-JBMA7SN4.js +28 -0
- package/dist/{up-XEO3SR6Z.js → up-K4BV7466.js} +26 -27
- package/package.json +1 -1
- package/dist/chunk-6HOA5JT7.js +0 -124
- package/dist/chunk-XRNRT37W.js +0 -316
- /package/dist/{dist-es-ZP5QDSJ3.js → dist-es-AXFRS7PS.js} +0 -0
|
@@ -0,0 +1,2145 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
":" //# ; exec "$(command -v node || command -v bun)" "$0" "$@"
|
|
3
|
+
import { createRequire as __ospCreateRequire } from "node:module";
|
|
4
|
+
const require = __ospCreateRequire(import.meta.url);
|
|
5
|
+
import {
|
|
6
|
+
COMPOSE_DIR,
|
|
7
|
+
COMPOSE_ENV_FILE,
|
|
8
|
+
COMPOSE_FILE,
|
|
9
|
+
pasteable,
|
|
10
|
+
readComposeEnvFile,
|
|
11
|
+
resolveLocalEnvironmentSync,
|
|
12
|
+
thisHost
|
|
13
|
+
} from "./chunk-5DMWSTOM.js";
|
|
14
|
+
import {
|
|
15
|
+
OS_DIR
|
|
16
|
+
} from "./chunk-ELGRMZOW.js";
|
|
17
|
+
import {
|
|
18
|
+
systemCatalog
|
|
19
|
+
} from "./chunk-A4GXYE4L.js";
|
|
20
|
+
import {
|
|
21
|
+
EDGE_CONTAINER_MOUNTS,
|
|
22
|
+
EDGE_HOST_STATE_DIR
|
|
23
|
+
} from "./chunk-RSR7PFIQ.js";
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_DOCKER_SOCKET_PATH,
|
|
26
|
+
resolveLocalDockerSocketPath
|
|
27
|
+
} from "./chunk-77QDF6UD.js";
|
|
28
|
+
import {
|
|
29
|
+
LocalExecutor
|
|
30
|
+
} from "./chunk-6FH4AXVY.js";
|
|
31
|
+
import {
|
|
32
|
+
invalidateEdgeContainer,
|
|
33
|
+
sanitizeEdgeVhosts
|
|
34
|
+
} from "./chunk-2YM3NI2E.js";
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_IMAGE_REGISTRY,
|
|
37
|
+
HOST_CHANNEL_BLOCKED,
|
|
38
|
+
HOST_CHANNEL_DEFAULT_ACCOUNT,
|
|
39
|
+
HOST_CHANNEL_NOT_PROVISIONED,
|
|
40
|
+
HOST_CHANNEL_OPT_OUT,
|
|
41
|
+
HOST_CHANNEL_RECHECK,
|
|
42
|
+
HOST_CHANNEL_SYMPTOM,
|
|
43
|
+
HOST_CHANNEL_UNAFFECTED,
|
|
44
|
+
HOST_CHANNEL_UNPROVISIONED,
|
|
45
|
+
explainHostChannelCause,
|
|
46
|
+
firewallAllowSteps,
|
|
47
|
+
firewallManager,
|
|
48
|
+
firewallPersists,
|
|
49
|
+
hostChannelAccount,
|
|
50
|
+
hostChannelRuleScope,
|
|
51
|
+
hostFirewallRule,
|
|
52
|
+
hostFirewallRuleRemoval,
|
|
53
|
+
wrapText
|
|
54
|
+
} from "./chunk-UJ6JZM65.js";
|
|
55
|
+
|
|
56
|
+
// src/lib/host-channel-preflight.ts
|
|
57
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
58
|
+
import chalk from "chalk";
|
|
59
|
+
import { confirm, isCancel } from "@clack/prompts";
|
|
60
|
+
|
|
61
|
+
// src/lib/compose.ts
|
|
62
|
+
import { spawn as spawn2, spawnSync as spawnSync3 } from "child_process";
|
|
63
|
+
import { randomBytes } from "crypto";
|
|
64
|
+
import {
|
|
65
|
+
chmodSync,
|
|
66
|
+
existsSync as existsSync4,
|
|
67
|
+
mkdirSync as mkdirSync3,
|
|
68
|
+
readFileSync as readFileSync3,
|
|
69
|
+
renameSync,
|
|
70
|
+
rmSync,
|
|
71
|
+
statSync,
|
|
72
|
+
writeFileSync as writeFileSync2
|
|
73
|
+
} from "fs";
|
|
74
|
+
import { homedir, userInfo } from "os";
|
|
75
|
+
import { dirname, join as join4, resolve as resolve2 } from "path";
|
|
76
|
+
|
|
77
|
+
// src/lib/ports.ts
|
|
78
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
79
|
+
import { join } from "path";
|
|
80
|
+
|
|
81
|
+
// ../../packages/core/src/ports.ts
|
|
82
|
+
import { createServer } from "net";
|
|
83
|
+
var DEFAULT_API_PORT = 4e3;
|
|
84
|
+
var DEFAULT_DASHBOARD_PORT = 3001;
|
|
85
|
+
var LOOPBACK = "127.0.0.1";
|
|
86
|
+
var SCAN_SPAN = 20;
|
|
87
|
+
var MAX_PORT = 65535;
|
|
88
|
+
function isPortFree(port, host = LOOPBACK) {
|
|
89
|
+
return new Promise((resolve3) => {
|
|
90
|
+
const srv = createServer();
|
|
91
|
+
srv.once("error", () => resolve3(false));
|
|
92
|
+
srv.listen(port, host, () => srv.close(() => resolve3(true)));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async function waitPortFree(port, opts = {}) {
|
|
96
|
+
const timeoutMs = opts.timeoutMs ?? 6e3;
|
|
97
|
+
const intervalMs = opts.intervalMs ?? 250;
|
|
98
|
+
const host = opts.host ?? LOOPBACK;
|
|
99
|
+
const deadline = Date.now() + timeoutMs;
|
|
100
|
+
for (; ; ) {
|
|
101
|
+
if (await isPortFree(port, host)) return true;
|
|
102
|
+
if (Date.now() >= deadline) return false;
|
|
103
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function getFreePort(host = LOOPBACK) {
|
|
107
|
+
return new Promise((resolve3, reject) => {
|
|
108
|
+
const srv = createServer();
|
|
109
|
+
srv.once("error", reject);
|
|
110
|
+
srv.listen(0, host, () => {
|
|
111
|
+
const addr = srv.address();
|
|
112
|
+
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
113
|
+
srv.close(() => port ? resolve3(port) : reject(new Error("no free port")));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async function findPortNear(from, opts = {}) {
|
|
118
|
+
const host = opts.host ?? LOOPBACK;
|
|
119
|
+
const span = opts.span ?? SCAN_SPAN;
|
|
120
|
+
const skip = opts.skip ?? (() => false);
|
|
121
|
+
for (let port2 = from; port2 <= Math.min(from + span, MAX_PORT); port2++) {
|
|
122
|
+
if (!skip(port2) && await isPortFree(port2, host)) return port2;
|
|
123
|
+
}
|
|
124
|
+
let port = await getFreePort(host);
|
|
125
|
+
for (let tries = 0; tries < 10 && skip(port); tries++) port = await getFreePort(host);
|
|
126
|
+
return port;
|
|
127
|
+
}
|
|
128
|
+
async function resolvePortPair(prefs = {}) {
|
|
129
|
+
const host = prefs.bindAddr?.trim() || LOOPBACK;
|
|
130
|
+
const ours = new Set(prefs.reclaimable ?? []);
|
|
131
|
+
const prefFor = (kind) => prefs[kind] ?? prefs.previous?.[kind] ?? prefs.stored?.[kind] ?? prefs.defaults?.[kind];
|
|
132
|
+
const remembered = (kind, pref) => prefs[kind] === void 0 && (prefs.previous?.[kind] === pref || prefs.stored?.[kind] === pref);
|
|
133
|
+
const availableForPref = async (port) => ours.has(port) || await isPortFree(port, host);
|
|
134
|
+
const apiPref = prefFor("api");
|
|
135
|
+
const dashPref = prefFor("dashboard");
|
|
136
|
+
let api;
|
|
137
|
+
if (apiPref === void 0) api = await getFreePort(host);
|
|
138
|
+
else if (await availableForPref(apiPref)) api = apiPref;
|
|
139
|
+
else if (remembered("api", apiPref) && await waitPortFree(apiPref, { host })) api = apiPref;
|
|
140
|
+
else {
|
|
141
|
+
api = await findPortNear(apiPref + 1, {
|
|
142
|
+
host,
|
|
143
|
+
skip: (p) => ours.has(p) || p === dashPref
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const skipForDash = (p) => ours.has(p) || p === api;
|
|
147
|
+
let dashboard;
|
|
148
|
+
if (dashPref === void 0) {
|
|
149
|
+
dashboard = await getFreePort(host);
|
|
150
|
+
for (let tries = 0; tries < 10 && dashboard === api; tries++) {
|
|
151
|
+
dashboard = await getFreePort(host);
|
|
152
|
+
}
|
|
153
|
+
} else if (dashPref !== api && await availableForPref(dashPref)) {
|
|
154
|
+
dashboard = dashPref;
|
|
155
|
+
} else if (dashPref !== api && remembered("dashboard", dashPref) && await waitPortFree(dashPref, { host })) {
|
|
156
|
+
dashboard = dashPref;
|
|
157
|
+
} else {
|
|
158
|
+
dashboard = await findPortNear(dashPref + 1, { host, skip: skipForDash });
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
api,
|
|
162
|
+
dashboard,
|
|
163
|
+
switched: {
|
|
164
|
+
api: apiPref !== void 0 && api !== apiPref,
|
|
165
|
+
dashboard: dashPref !== void 0 && dashboard !== dashPref
|
|
166
|
+
},
|
|
167
|
+
preferred: { api: apiPref ?? api, dashboard: dashPref ?? dashboard }
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/lib/ports.ts
|
|
172
|
+
var PORTS_FILE = join(OS_DIR, "ports.json");
|
|
173
|
+
var INSTANCE_FILE = join(OS_DIR, "instance.json");
|
|
174
|
+
function saveInstanceUrl(publicUrl) {
|
|
175
|
+
try {
|
|
176
|
+
if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
|
|
177
|
+
writeFileSync(INSTANCE_FILE, JSON.stringify({ publicUrl: publicUrl ?? null }));
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function readInstanceUrl() {
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(readFileSync(INSTANCE_FILE, "utf8")).publicUrl ?? null;
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function readStoredPorts() {
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(readFileSync(PORTS_FILE, "utf-8"));
|
|
191
|
+
} catch {
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function storedApiPort() {
|
|
196
|
+
return readStoredPorts().api ?? DEFAULT_API_PORT;
|
|
197
|
+
}
|
|
198
|
+
function storedDashboardPort() {
|
|
199
|
+
return readStoredPorts().dashboard ?? DEFAULT_DASHBOARD_PORT;
|
|
200
|
+
}
|
|
201
|
+
function saveStoredPorts(api, dashboard) {
|
|
202
|
+
try {
|
|
203
|
+
if (!existsSync(OS_DIR)) mkdirSync(OS_DIR, { recursive: true, mode: 448 });
|
|
204
|
+
writeFileSync(PORTS_FILE, JSON.stringify({ api, dashboard }));
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function portMoveNotice(resolved, trustedOriginUrls = []) {
|
|
209
|
+
if (!resolved.switched.api && !resolved.switched.dashboard) return [];
|
|
210
|
+
const lines = [
|
|
211
|
+
`A preferred port was busy \u2014 using API ${resolved.api}, dashboard ${resolved.dashboard}.`
|
|
212
|
+
];
|
|
213
|
+
const stale = [...new Set(stalePortOrigins(resolved.preferred.dashboard, trustedOriginUrls))];
|
|
214
|
+
if (stale.length) {
|
|
215
|
+
lines.push(
|
|
216
|
+
`Heads up: ${stale.join(", ")} still names port ${resolved.preferred.dashboard}, so logins`,
|
|
217
|
+
`from that origin will be rejected. Point it at :${resolved.dashboard} (or free port`,
|
|
218
|
+
`${resolved.preferred.dashboard} and re-run to move back).`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
function stalePortOrigins(movedFrom, urls) {
|
|
224
|
+
const stale = [];
|
|
225
|
+
for (const raw of urls) {
|
|
226
|
+
for (const candidate of (raw ?? "").split(",")) {
|
|
227
|
+
const value = candidate.trim();
|
|
228
|
+
if (!value) continue;
|
|
229
|
+
try {
|
|
230
|
+
if (new URL(value).port === String(movedFrom)) stale.push(value);
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return stale;
|
|
236
|
+
}
|
|
237
|
+
async function resolvePorts(prefs, opts = {}) {
|
|
238
|
+
const resolved = await resolvePortPair({
|
|
239
|
+
...prefs,
|
|
240
|
+
stored: readStoredPorts(),
|
|
241
|
+
defaults: { api: DEFAULT_API_PORT, dashboard: DEFAULT_DASHBOARD_PORT }
|
|
242
|
+
});
|
|
243
|
+
if (opts.persist !== false) saveStoredPorts(resolved.api, resolved.dashboard);
|
|
244
|
+
return resolved;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/lib/source-install.ts
|
|
248
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
249
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
250
|
+
import { join as join3 } from "path";
|
|
251
|
+
|
|
252
|
+
// src/lib/from-source.ts
|
|
253
|
+
import { spawn, spawnSync } from "child_process";
|
|
254
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
255
|
+
import { join as join2, resolve } from "path";
|
|
256
|
+
var DEFAULT_REPO = "https://github.com/oblien/openship.git";
|
|
257
|
+
var SOURCE_CHECKOUT_DIR = join2(OS_DIR, "src");
|
|
258
|
+
var SOURCE_DIST_DIR = join2(OS_DIR, "from-source-dist");
|
|
259
|
+
function has(cmd) {
|
|
260
|
+
try {
|
|
261
|
+
return spawnSync(cmd, ["--version"], { stdio: "ignore" }).status === 0;
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function run(cmd, args, cwd, env) {
|
|
267
|
+
return new Promise((res, rej) => {
|
|
268
|
+
const child = spawn(cmd, args, {
|
|
269
|
+
cwd,
|
|
270
|
+
stdio: "inherit",
|
|
271
|
+
// Windows resolves the bun/git shims (.cmd/.exe) only through a shell.
|
|
272
|
+
shell: process.platform === "win32",
|
|
273
|
+
env: env ? { ...process.env, ...env } : process.env
|
|
274
|
+
});
|
|
275
|
+
child.on("error", rej);
|
|
276
|
+
child.on(
|
|
277
|
+
"exit",
|
|
278
|
+
(code) => code === 0 ? res() : rej(new Error(`\`${cmd} ${args.join(" ")}\` (cwd=${cwd}) exited ${code ?? "?"}`))
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function shortSha(cwd) {
|
|
283
|
+
const r = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd, encoding: "utf8" });
|
|
284
|
+
return r.status === 0 ? (r.stdout ?? "").trim() || "unknown" : "unknown";
|
|
285
|
+
}
|
|
286
|
+
function isMonorepo(dir) {
|
|
287
|
+
return existsSync2(join2(dir, "package.json")) && existsSync2(join2(dir, "apps/api/package.json")) && existsSync2(join2(dir, "apps/dashboard/package.json"));
|
|
288
|
+
}
|
|
289
|
+
async function prepareFromSource(opts) {
|
|
290
|
+
if (!has("bun")) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
"`bun` is required to build from source but wasn't found on PATH \u2014 install it: https://bun.sh"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
let sourceDir;
|
|
296
|
+
let ref;
|
|
297
|
+
if (opts.source) {
|
|
298
|
+
sourceDir = resolve(opts.source);
|
|
299
|
+
if (!isMonorepo(sourceDir)) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`--source ${sourceDir} doesn't look like an Openship checkout (missing package.json / apps/api / apps/dashboard).`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
ref = "local";
|
|
305
|
+
} else {
|
|
306
|
+
if (!has("git")) {
|
|
307
|
+
throw new Error("`git` is required to clone the source but wasn't found on PATH.");
|
|
308
|
+
}
|
|
309
|
+
ref = (opts.ref || "main").trim();
|
|
310
|
+
const repoUrl = opts.repo || DEFAULT_REPO;
|
|
311
|
+
sourceDir = SOURCE_CHECKOUT_DIR;
|
|
312
|
+
mkdirSync2(OS_DIR, { recursive: true });
|
|
313
|
+
if (!existsSync2(join2(sourceDir, ".git"))) {
|
|
314
|
+
console.log(` Cloning ${repoUrl} \u2192 ${sourceDir}`);
|
|
315
|
+
await run("git", ["clone", repoUrl, sourceDir], OS_DIR);
|
|
316
|
+
}
|
|
317
|
+
console.log(` Fetching + checking out ${ref}`);
|
|
318
|
+
await run("git", ["fetch", "origin", ref, "--tags"], sourceDir);
|
|
319
|
+
await run("git", ["checkout", ref], sourceDir);
|
|
320
|
+
await run("git", ["pull", "--ff-only", "origin", ref], sourceDir).catch(() => {
|
|
321
|
+
console.log(" (pinned ref \u2014 not fast-forwarding)");
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const sha = shortSha(sourceDir);
|
|
325
|
+
console.log(` Source: ${sourceDir} @ ${ref} (${sha})`);
|
|
326
|
+
console.log(" Installing workspace dependencies (bun install)\u2026");
|
|
327
|
+
await run("bun", ["install"], sourceDir);
|
|
328
|
+
const distDir = SOURCE_DIST_DIR;
|
|
329
|
+
console.log(" Building release dist (compiles the dashboard \u2014 needs RAM/CPU)\u2026");
|
|
330
|
+
await run(
|
|
331
|
+
"bun",
|
|
332
|
+
["run", join2(sourceDir, "apps/api/scripts/build-release.ts")],
|
|
333
|
+
sourceDir,
|
|
334
|
+
{ DIST_DIR: distDir, NODE_ENV: "production", CLOUD_MODE: "false", OPENSHIP_TARGET: "local" }
|
|
335
|
+
);
|
|
336
|
+
console.log(" Installing runtime dependencies in the dist\u2026");
|
|
337
|
+
await run("bun", ["install", "--production", "--frozen-lockfile"], distDir);
|
|
338
|
+
const apiDir = join2(distDir, "api");
|
|
339
|
+
const dashboardDir = join2(distDir, "dashboard");
|
|
340
|
+
if (!existsSync2(join2(apiDir, "src/index.ts"))) {
|
|
341
|
+
throw new Error(`Build produced no API at ${apiDir}/src/index.ts \u2014 build-release layout drift?`);
|
|
342
|
+
}
|
|
343
|
+
if (!existsSync2(join2(dashboardDir, "apps/dashboard/server.js"))) {
|
|
344
|
+
throw new Error(`Build produced no dashboard at ${dashboardDir}/apps/dashboard/server.js.`);
|
|
345
|
+
}
|
|
346
|
+
return { apiDir, dashboardDir, ref, sha, sourceDir };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/lib/source-install.ts
|
|
350
|
+
var MARKER = join3(OS_DIR, "source-install.json");
|
|
351
|
+
function parseSourceInstall(raw) {
|
|
352
|
+
try {
|
|
353
|
+
const obj = JSON.parse(raw);
|
|
354
|
+
if (!obj || typeof obj.dir !== "string" || !obj.dir) return null;
|
|
355
|
+
return { repo: obj.repo || DEFAULT_REPO, ref: obj.ref || "main", dir: obj.dir };
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function readSourceInstall() {
|
|
361
|
+
if (!existsSync3(MARKER)) return null;
|
|
362
|
+
try {
|
|
363
|
+
return parseSourceInstall(readFileSync2(MARKER, "utf8"));
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function parseRemoteSha(stdout) {
|
|
369
|
+
const sha = stdout.split("\n")[0]?.trim().split(/\s+/)[0];
|
|
370
|
+
return sha && /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 7) : null;
|
|
371
|
+
}
|
|
372
|
+
function remoteSha(repo, ref) {
|
|
373
|
+
const r = spawnSync2("git", ["ls-remote", repo, ref], { encoding: "utf8" });
|
|
374
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
375
|
+
return parseRemoteSha(r.stdout);
|
|
376
|
+
}
|
|
377
|
+
async function rebuildFromSource(info) {
|
|
378
|
+
if (!has("bun")) {
|
|
379
|
+
throw new Error("`bun` is required to rebuild from source but wasn't found on PATH \u2014 https://bun.sh");
|
|
380
|
+
}
|
|
381
|
+
if (!has("git")) {
|
|
382
|
+
throw new Error("`git` is required to update the source checkout but wasn't found on PATH.");
|
|
383
|
+
}
|
|
384
|
+
if (!existsSync3(join3(info.dir, ".git"))) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Source checkout ${info.dir} is missing (no .git) \u2014 reinstall from source with scripts/install-source.sh.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
await run("git", ["fetch", "origin", info.ref, "--tags"], info.dir);
|
|
390
|
+
await run("git", ["checkout", info.ref], info.dir);
|
|
391
|
+
await run("git", ["pull", "--ff-only", "origin", info.ref], info.dir).catch(() => {
|
|
392
|
+
});
|
|
393
|
+
const cliDir = join3(info.dir, "apps/cli");
|
|
394
|
+
await run("bun", ["install"], info.dir);
|
|
395
|
+
await run("bun", ["run", "build"], cliDir);
|
|
396
|
+
await run("bun", ["run", "build/stage-dashboard.ts"], cliDir);
|
|
397
|
+
return shortSha(info.dir);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/lib/compose.ts
|
|
401
|
+
var EDGE_SITES_HOST_DIR = `${EDGE_HOST_STATE_DIR}/sites-enabled`;
|
|
402
|
+
var EDGE_ACME_HOST_DIR = `${EDGE_HOST_STATE_DIR}/acme`;
|
|
403
|
+
function edgeVolumeYaml(indent) {
|
|
404
|
+
return EDGE_CONTAINER_MOUNTS.map((m) => `${indent}- ${m.host}:${m.container}:z`).join("\n");
|
|
405
|
+
}
|
|
406
|
+
var DOCKER_CONFIG_MOUNT_MARKER = "__OPENSHIP_DOCKER_CONFIG_MOUNT__";
|
|
407
|
+
function dockerConfigMountYaml(indent) {
|
|
408
|
+
const path = resolve2(process.env.DOCKER_CONFIG || join4(homedir(), ".docker"), "config.json");
|
|
409
|
+
try {
|
|
410
|
+
if (!statSync(path).isFile()) return "";
|
|
411
|
+
} catch {
|
|
412
|
+
return "";
|
|
413
|
+
}
|
|
414
|
+
return [
|
|
415
|
+
`${indent}- type: bind`,
|
|
416
|
+
`${indent} source: ${JSON.stringify(path)}`,
|
|
417
|
+
`${indent} target: /root/.docker/config.json`,
|
|
418
|
+
`${indent} read_only: true`
|
|
419
|
+
].join("\n");
|
|
420
|
+
}
|
|
421
|
+
function renderComposeYaml() {
|
|
422
|
+
const mount = dockerConfigMountYaml(" ");
|
|
423
|
+
return COMPOSE_YAML.split(`${DOCKER_CONFIG_MOUNT_MARKER}
|
|
424
|
+
`).join(mount ? `${mount}
|
|
425
|
+
` : "");
|
|
426
|
+
}
|
|
427
|
+
var INSTALL_METHOD_FILE = join4(OS_DIR, "install-method");
|
|
428
|
+
var BUILD_FILE = join4(COMPOSE_DIR, "docker-compose.build.yml");
|
|
429
|
+
var ENV_BACKUP_FILE = join4(COMPOSE_DIR, ".env.bak");
|
|
430
|
+
var ENV_TMP_FILE = join4(COMPOSE_DIR, ".env.tmp");
|
|
431
|
+
var BUILT_SERVICES = [
|
|
432
|
+
{ service: "api", dockerfile: "apps/api/Dockerfile" },
|
|
433
|
+
{ service: "dashboard", dockerfile: "apps/dashboard/Dockerfile" },
|
|
434
|
+
{ service: "edge", dockerfile: "apps/edge/Dockerfile" }
|
|
435
|
+
];
|
|
436
|
+
var ENV_CONSUMING_SERVICES = ["api", "dashboard", "edge"];
|
|
437
|
+
function readInstallMethod() {
|
|
438
|
+
try {
|
|
439
|
+
const v = readFileSync3(INSTALL_METHOD_FILE, "utf8").trim();
|
|
440
|
+
return v === "compose" || v === "bare" ? v : null;
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function writeInstallMethod(method) {
|
|
446
|
+
mkdirSync3(OS_DIR, { recursive: true, mode: 448 });
|
|
447
|
+
writeFileSync2(INSTALL_METHOD_FILE, method, { mode: 384 });
|
|
448
|
+
}
|
|
449
|
+
function dockerState() {
|
|
450
|
+
const ok = (args) => spawnSync3("docker", args, { stdio: "ignore" }).status === 0;
|
|
451
|
+
if (!ok(["--version"])) return { binary: false, plugin: false, daemon: false };
|
|
452
|
+
return { binary: true, plugin: ok(["compose", "version"]), daemon: ok(["version"]) };
|
|
453
|
+
}
|
|
454
|
+
function dockerGap(state = dockerState()) {
|
|
455
|
+
if (!state.binary) {
|
|
456
|
+
return { summary: "Docker isn't installed", installable: true };
|
|
457
|
+
}
|
|
458
|
+
if (!state.plugin) {
|
|
459
|
+
return {
|
|
460
|
+
summary: "Docker is installed but the Compose plugin (`docker compose`) is missing",
|
|
461
|
+
installable: true
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
if (!state.daemon) {
|
|
465
|
+
const start = pasteable(thisHost().dockerStart());
|
|
466
|
+
return {
|
|
467
|
+
summary: "Docker is installed but its daemon isn't reachable",
|
|
468
|
+
// Reinstalling changes nothing: a group change only applies to NEW logins,
|
|
469
|
+
// and a stopped daemon needs starting, not installing.
|
|
470
|
+
installable: false,
|
|
471
|
+
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"}`
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
function hasDockerCompose() {
|
|
477
|
+
return dockerGap() === null;
|
|
478
|
+
}
|
|
479
|
+
function hasCmd(cmd) {
|
|
480
|
+
return spawnSync3(cmd, ["--version"], { stdio: "ignore" }).status === 0;
|
|
481
|
+
}
|
|
482
|
+
function shQuote(s) {
|
|
483
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
484
|
+
}
|
|
485
|
+
function dockerInstallPreview(state = dockerState()) {
|
|
486
|
+
const gap = dockerGap(state);
|
|
487
|
+
if (!gap) return { state, gap: null, wouldInstall: false };
|
|
488
|
+
if (process.platform !== "linux" || !gap.installable) return { state, gap, wouldInstall: false };
|
|
489
|
+
const plan = systemCatalog.installs.docker(resolveLocalEnvironmentSync());
|
|
490
|
+
if (!plan.supported) return { state, gap, wouldInstall: false, unsupportedReason: plan.reason };
|
|
491
|
+
const start = plan.value.service;
|
|
492
|
+
return {
|
|
493
|
+
state,
|
|
494
|
+
gap,
|
|
495
|
+
wouldInstall: true,
|
|
496
|
+
installCommand: plan.value.installCommand,
|
|
497
|
+
...start?.supported ? { startCommand: start.value } : {},
|
|
498
|
+
...start && !start.supported ? { startBlockedReason: start.reason } : {}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
async function ensureDocker(opts = {}) {
|
|
502
|
+
const notice = opts.onNotice ?? ((line) => process.stderr.write(` ${line}
|
|
503
|
+
`));
|
|
504
|
+
const { state, gap, wouldInstall, installCommand, startCommand, unsupportedReason, startBlockedReason } = dockerInstallPreview();
|
|
505
|
+
if (!gap) return true;
|
|
506
|
+
if (process.platform !== "linux") return false;
|
|
507
|
+
if (!gap.installable) {
|
|
508
|
+
notice(gap.summary + ".");
|
|
509
|
+
if (gap.hint) notice(gap.hint);
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
if (!wouldInstall || !installCommand) {
|
|
513
|
+
if (unsupportedReason) notice(unsupportedReason);
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
517
|
+
const sudo = !asRoot && hasCmd("sudo") ? "sudo " : "";
|
|
518
|
+
notice("This can take 2-5 minutes on a small VPS (~150 MB of packages) and stays quiet while apt works.");
|
|
519
|
+
if (state.binary) {
|
|
520
|
+
notice("Docker's installer will warn that docker already exists and pause ~20s before continuing \u2014 that's expected.");
|
|
521
|
+
}
|
|
522
|
+
const sh = (script) => new Promise((resolve3) => {
|
|
523
|
+
const child = spawn2("sh", ["-c", sudo ? `${sudo}sh -c ${shQuote(script)}` : script], {
|
|
524
|
+
stdio: "inherit"
|
|
525
|
+
});
|
|
526
|
+
const started = Date.now();
|
|
527
|
+
const tick = setInterval(() => {
|
|
528
|
+
const s = Math.round((Date.now() - started) / 1e3);
|
|
529
|
+
notice(`still installing Docker \u2014 ${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s elapsed\u2026`);
|
|
530
|
+
}, 3e4);
|
|
531
|
+
const done = (code) => {
|
|
532
|
+
clearInterval(tick);
|
|
533
|
+
resolve3(code);
|
|
534
|
+
};
|
|
535
|
+
child.on("error", () => done(1));
|
|
536
|
+
child.on("close", (code) => done(code ?? 1));
|
|
537
|
+
});
|
|
538
|
+
if (await sh(installCommand) !== 0) return false;
|
|
539
|
+
if (startCommand) await sh(startCommand);
|
|
540
|
+
else if (startBlockedReason) notice(startBlockedReason);
|
|
541
|
+
const after = dockerGap();
|
|
542
|
+
if (!after) {
|
|
543
|
+
notice("Docker ready.");
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
notice(after.summary + ".");
|
|
547
|
+
if (after.hint) notice(after.hint);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
var COMPOSE_YAML = `# Managed by \`openship up\` \u2014 do not edit; re-run \`openship up\` to regenerate.
|
|
551
|
+
services:
|
|
552
|
+
postgres:
|
|
553
|
+
image: postgres:16-alpine
|
|
554
|
+
restart: unless-stopped
|
|
555
|
+
environment:
|
|
556
|
+
# Keep the data dir in a subdirectory of the volume so a fresh install never
|
|
557
|
+
# runs initdb against a bare mount root (which fails on quirky host
|
|
558
|
+
# filesystems with EPERM \u2014 #350). OPENSHIP_PGDATA is decided ONCE at install
|
|
559
|
+
# by the CLI (fresh \u2192 subdir, pre-existing volume \u2192 root) and preserved in
|
|
560
|
+
# .env, so this never moves an existing database.
|
|
561
|
+
PGDATA: \${OPENSHIP_PGDATA:-/var/lib/postgresql/data/pgdata}
|
|
562
|
+
POSTGRES_USER: \${POSTGRES_USER:-openship}
|
|
563
|
+
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}
|
|
564
|
+
POSTGRES_DB: \${POSTGRES_DB:-openship}
|
|
565
|
+
expose: ["5432"]
|
|
566
|
+
volumes: [postgres_data:/var/lib/postgresql/data]
|
|
567
|
+
healthcheck:
|
|
568
|
+
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-openship} -d \${POSTGRES_DB:-openship}"]
|
|
569
|
+
interval: 5s
|
|
570
|
+
timeout: 3s
|
|
571
|
+
retries: 12
|
|
572
|
+
|
|
573
|
+
redis:
|
|
574
|
+
image: redis:7-alpine
|
|
575
|
+
restart: unless-stopped
|
|
576
|
+
command: ["redis-server", "--appendonly", "yes"]
|
|
577
|
+
expose: ["6379"]
|
|
578
|
+
volumes: [redis_data:/data]
|
|
579
|
+
healthcheck:
|
|
580
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
581
|
+
interval: 5s
|
|
582
|
+
timeout: 3s
|
|
583
|
+
retries: 12
|
|
584
|
+
|
|
585
|
+
api:
|
|
586
|
+
image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-api:\${OPENSHIP_VERSION:-latest}
|
|
587
|
+
restart: unless-stopped
|
|
588
|
+
# Loopback by default \u2014 the host-net edge reaches it over loopback, so nothing
|
|
589
|
+
# sits on a public interface. OPENSHIP_BIND_ADDR opts into a public/LAN interface
|
|
590
|
+
# (set by \`openship up\` when a public URL is configured for off-box access).
|
|
591
|
+
ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${API_PORT:-4000}:\${API_PORT:-4000}"]
|
|
592
|
+
volumes:
|
|
593
|
+
# The daemon socket, at the path the api's own default expects INSIDE the
|
|
594
|
+
# container. The HOST side is a variable because it MOVES: a rootless daemon
|
|
595
|
+
# keeps its socket under the invoking user's runtime dir, and Docker creates a
|
|
596
|
+
# missing bind source as an empty DIRECTORY instead of failing \u2014 so the rootful
|
|
597
|
+
# path there brings the stack up and then fails every container operation in
|
|
598
|
+
# the transport, naming neither the path nor the cause (#482). \`openship up\`
|
|
599
|
+
# writes OPENSHIP_DOCKER_SOCKET when the detected path isn't this default.
|
|
600
|
+
- \${OPENSHIP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
|
601
|
+
# Registry credentials for private-image pulls. The API talks to the daemon over
|
|
602
|
+
# the socket above via dockerode, which \u2014 unlike the docker CLI \u2014 reads no
|
|
603
|
+
# config.json of its own, so a private pull went out anonymous on a logged-in
|
|
604
|
+
# host (#581). Mounted READ-ONLY and only into this service; absent entirely when
|
|
605
|
+
# the host has no config, so nothing is invented. Substituted at write time (the
|
|
606
|
+
# path depends on DOCKER_CONFIG and on the file existing), hence the marker.
|
|
607
|
+
__OPENSHIP_DOCKER_CONFIG_MOUNT__
|
|
608
|
+
# Routing state shared with the edge, as HOST BIND MOUNTS (generated from
|
|
609
|
+
# EDGE_CONTAINER_MOUNTS): the vhost tree, /etc/letsencrypt, the ACME webroot
|
|
610
|
+
# and the static doc-roots the API writes and the edge serves. Named volumes
|
|
611
|
+
# hid all of it from every host-side reader (migrate's proxy scan, cert carry,
|
|
612
|
+
# cert reuse, mail cert symlinks), each of which then silently saw "nothing".
|
|
613
|
+
${edgeVolumeYaml(" ")}
|
|
614
|
+
# Host-op SSH key (createHostExecutor \u2192 host.docker.internal). /dev/null
|
|
615
|
+
# when the host channel isn't provisioned \u2192 OPENSHIP_HOST_SSH_HOST stays
|
|
616
|
+
# unset, and host operations REFUSE (they'd otherwise run against this
|
|
617
|
+
# container's filesystem, #509) while ordinary deploys keep working over
|
|
618
|
+
# the socket below.
|
|
619
|
+
- \${OPENSHIP_HOST_KEY_PATH:-/dev/null}:/run/secrets/openship_host_key:ro
|
|
620
|
+
# \`host-gateway\` is resolved by the daemon and is right on a stock rootful
|
|
621
|
+
# install. It is NOT universally right: under rootless Docker it lands in
|
|
622
|
+
# RootlessKit's namespace rather than the host's, so the host's sshd isn't
|
|
623
|
+
# there (#482). OPENSHIP_HOST_GATEWAY re-points the alias (e.g. at the box's
|
|
624
|
+
# LAN address) without patching this file, which \`openship up\` rewrites.
|
|
625
|
+
extra_hosts: ["host.docker.internal:\${OPENSHIP_HOST_GATEWAY:-host-gateway}"]
|
|
626
|
+
env_file: [.env]
|
|
627
|
+
environment:
|
|
628
|
+
NODE_ENV: production
|
|
629
|
+
PORT: "\${API_PORT:-4000}"
|
|
630
|
+
DATABASE_URL: postgresql://\${POSTGRES_USER:-openship}:\${POSTGRES_PASSWORD:?missing from .env \u2014 re-run openship up to regenerate it}@postgres:5432/\${POSTGRES_DB:-openship}
|
|
631
|
+
REDIS_URL: redis://redis:6379
|
|
632
|
+
OPENSHIP_EDGE_MODE: docker
|
|
633
|
+
OPENSHIP_EDGE_CONTAINER: openship-edge
|
|
634
|
+
depends_on:
|
|
635
|
+
postgres: { condition: service_healthy }
|
|
636
|
+
redis: { condition: service_healthy }
|
|
637
|
+
healthcheck:
|
|
638
|
+
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))\\""]
|
|
639
|
+
interval: 10s
|
|
640
|
+
timeout: 5s
|
|
641
|
+
retries: 12
|
|
642
|
+
start_period: 40s
|
|
643
|
+
|
|
644
|
+
dashboard:
|
|
645
|
+
image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-dashboard:\${OPENSHIP_VERSION:-latest}
|
|
646
|
+
restart: unless-stopped
|
|
647
|
+
# Loopback by default (see api note); OPENSHIP_BIND_ADDR opts into a public interface.
|
|
648
|
+
ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${DASHBOARD_PORT:-3001}:\${DASHBOARD_PORT:-3001}"]
|
|
649
|
+
env_file: [.env]
|
|
650
|
+
environment:
|
|
651
|
+
NODE_ENV: production
|
|
652
|
+
PORT: "\${DASHBOARD_PORT:-3001}"
|
|
653
|
+
INTERNAL_API_URL: http://api:\${API_PORT:-4000}
|
|
654
|
+
depends_on:
|
|
655
|
+
api: { condition: service_healthy }
|
|
656
|
+
|
|
657
|
+
edge:
|
|
658
|
+
image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-edge:\${OPENSHIP_VERSION:-latest}
|
|
659
|
+
# PINNED, and it must stay pinned: the api reaches the edge by NAME through
|
|
660
|
+
# DockerEdgeExecutor (OPENSHIP_EDGE_CONTAINER above), and "ours" edge
|
|
661
|
+
# detection greps \`docker ps --filter name=openship-edge\`. Without this,
|
|
662
|
+
# compose derives \`<project>-edge-1\` from the directory and every
|
|
663
|
+
# \`docker exec\` into the edge fails with "No such container: openship-edge" \u2014
|
|
664
|
+
# which silently migrated 0 sites after the operator's proxy was stopped.
|
|
665
|
+
# Safe here: the edge is a singleton (host networking, one per box).
|
|
666
|
+
container_name: openship-edge
|
|
667
|
+
restart: unless-stopped
|
|
668
|
+
network_mode: host
|
|
669
|
+
volumes:
|
|
670
|
+
${edgeVolumeYaml(" ")}
|
|
671
|
+
|
|
672
|
+
volumes:
|
|
673
|
+
postgres_data:
|
|
674
|
+
redis_data:
|
|
675
|
+
`;
|
|
676
|
+
function keepSecret(existing, key) {
|
|
677
|
+
return existing[key] || randomBytes(32).toString("hex");
|
|
678
|
+
}
|
|
679
|
+
var GENERATED_SECRET_KEYS = ["POSTGRES_PASSWORD", "BETTER_AUTH_SECRET", "INTERNAL_TOKEN"];
|
|
680
|
+
function survivingDbVolume(prev) {
|
|
681
|
+
const candidates = /* @__PURE__ */ new Set([prev.COMPOSE_PROJECT_NAME, "openship", "compose"]);
|
|
682
|
+
for (const project of candidates) {
|
|
683
|
+
if (project && dbVolumeExists(project)) return `${project}_postgres_data`;
|
|
684
|
+
}
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
function secretRotationRisk(prev) {
|
|
688
|
+
const keys = GENERATED_SECRET_KEYS.filter((k) => !prev[k]);
|
|
689
|
+
if (keys.length === 0) return null;
|
|
690
|
+
const volume = survivingDbVolume(prev);
|
|
691
|
+
return volume ? { volume, keys: [...keys] } : null;
|
|
692
|
+
}
|
|
693
|
+
function composeSecretRotationRisk(opts = {}) {
|
|
694
|
+
if (opts.resetSecrets) return null;
|
|
695
|
+
return secretRotationRisk(readEnvFile());
|
|
696
|
+
}
|
|
697
|
+
function renderSecretRotationRefusal(rotation, opts = {}) {
|
|
698
|
+
return `
|
|
699
|
+
${opts.dryRun ? "A real run would REFUSE here" : "Refusing to continue"}: this would regenerate secrets your existing install already has.
|
|
700
|
+
|
|
701
|
+
missing from ${COMPOSE_ENV_FILE}: ${rotation.keys.join(", ")}
|
|
702
|
+
but this install's data is still here: ${rotation.volume}
|
|
703
|
+
|
|
704
|
+
A new POSTGRES_PASSWORD cannot match that volume \u2014 Postgres only applies the password
|
|
705
|
+
when it first creates the data directory \u2014 and a new BETTER_AUTH_SECRET cannot decrypt
|
|
706
|
+
the environment variables already stored in the database. Nothing has been written yet.
|
|
707
|
+
|
|
708
|
+
` + secretRecoveryAdvice(rotation);
|
|
709
|
+
}
|
|
710
|
+
function secretRecoveryAdvice(rotation) {
|
|
711
|
+
const adopted = adoptedStack(projectOfDbVolume(rotation.volume));
|
|
712
|
+
if (adopted) {
|
|
713
|
+
return ` This stack was not created by \`openship up\`: it is already running under the compose
|
|
714
|
+
project name we pin, so this run would ADOPT it.
|
|
715
|
+
|
|
716
|
+
project: ${adopted.project}
|
|
717
|
+
started from: ${adopted.configFiles || "an unlabelled `docker run`"}
|
|
718
|
+
not from: ${COMPOSE_FILE}
|
|
719
|
+
|
|
720
|
+
So there is no \`.env\` of ours to restore and no backup of one \u2014 the real values are in
|
|
721
|
+
the api container's own environment. Copy them out of it, then re-run:
|
|
722
|
+
|
|
723
|
+
echo 'COMPOSE_PROJECT_NAME=${adopted.project}' >> ${COMPOSE_ENV_FILE}
|
|
724
|
+
docker inspect ${adopted.apiContainer} --format '{{range .Config.Env}}{{println .}}{{end}}' \\
|
|
725
|
+
| grep -E '^(${rotation.keys.join("|")})=' >> ${COMPOSE_ENV_FILE}
|
|
726
|
+
|
|
727
|
+
COMPOSE_PROJECT_NAME matters as much as the secrets do: it is the volume prefix, so
|
|
728
|
+
without it this stack comes up on empty volumes beside the data above.
|
|
729
|
+
|
|
730
|
+
Only if that container is gone and the values exist nowhere else \u2014 this DESTROYS them,
|
|
731
|
+
leaving every environment variable stored in the database permanently unreadable:
|
|
732
|
+
openship up --reset-secrets
|
|
733
|
+
`;
|
|
734
|
+
}
|
|
735
|
+
return ` Put the original values back and re-run:
|
|
736
|
+
` + (existsSync4(ENV_BACKUP_FILE) ? ` cp ${ENV_BACKUP_FILE} ${COMPOSE_ENV_FILE} # the .env a previous \`openship up\` replaced
|
|
737
|
+
` : ` restore ${COMPOSE_ENV_FILE} from a backup, or copy the keys above out of one
|
|
738
|
+
`) + ` If the file is there but this user can't read it, re-run as the user that owns it
|
|
739
|
+
(or as root) rather than letting the secrets be regenerated.
|
|
740
|
+
|
|
741
|
+
To reset instead \u2014 the database password gets realigned, but every stored environment
|
|
742
|
+
variable becomes permanently unreadable:
|
|
743
|
+
openship up --reset-secrets
|
|
744
|
+
`;
|
|
745
|
+
}
|
|
746
|
+
function projectOfDbVolume(volume) {
|
|
747
|
+
return volume.replace(/_postgres_data$/, "");
|
|
748
|
+
}
|
|
749
|
+
function readEnvFile() {
|
|
750
|
+
const { env, unreadable } = readComposeEnvFile();
|
|
751
|
+
if (unreadable) {
|
|
752
|
+
console.log(
|
|
753
|
+
` ! Could not read ${COMPOSE_ENV_FILE}: ${unreadable}
|
|
754
|
+
Its contents are being treated as absent. If this install already exists, fix the permissions and re-run rather than letting secrets be regenerated.`
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
return env;
|
|
758
|
+
}
|
|
759
|
+
var HOST_CHANNEL_DEFAULT_HOST = "host.docker.internal";
|
|
760
|
+
var HOST_CHANNEL_DEFAULT_PORT = 22;
|
|
761
|
+
var HOST_KEY_COMMENT = "openship-host-executor";
|
|
762
|
+
var HOST_KEY_FROM = "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8,127.0.0.1";
|
|
763
|
+
function hostKeyAuthLine(pub) {
|
|
764
|
+
return `from="${HOST_KEY_FROM}",restrict,pty,port-forwarding ${pub}`;
|
|
765
|
+
}
|
|
766
|
+
function rewriteHostAuthorizedKeys(existing, pub) {
|
|
767
|
+
return [...keptForeignKeys(existing), hostKeyAuthLine(pub)].join("\n") + "\n";
|
|
768
|
+
}
|
|
769
|
+
function keptForeignKeys(existing) {
|
|
770
|
+
return existing.split("\n").filter((line) => line.trim() && !line.includes(HOST_KEY_COMMENT));
|
|
771
|
+
}
|
|
772
|
+
function stripHostAuthorizedKeys(existing) {
|
|
773
|
+
const kept = keptForeignKeys(existing);
|
|
774
|
+
return kept.length ? kept.join("\n") + "\n" : "";
|
|
775
|
+
}
|
|
776
|
+
var ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
|
|
777
|
+
function chooseHostChannelUser(input) {
|
|
778
|
+
if (input.invokerUid === 0) {
|
|
779
|
+
return {
|
|
780
|
+
user: "root",
|
|
781
|
+
authKeysPath: ROOT_AUTHORIZED_KEYS,
|
|
782
|
+
rootUnavailable: false,
|
|
783
|
+
elevation: "login"
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
const invoker = {
|
|
787
|
+
user: input.invokerName,
|
|
788
|
+
authKeysPath: join4(input.invokerHome, ".ssh", "authorized_keys")
|
|
789
|
+
};
|
|
790
|
+
if (input.hasPasswordlessSudo) {
|
|
791
|
+
return { ...invoker, rootUnavailable: false, elevation: "sudo" };
|
|
792
|
+
}
|
|
793
|
+
return { ...invoker, rootUnavailable: true, elevation: "none" };
|
|
794
|
+
}
|
|
795
|
+
function hasPasswordlessSudo() {
|
|
796
|
+
try {
|
|
797
|
+
return spawnSync3("sudo", ["-n", "true"], { stdio: "ignore" }).status === 0;
|
|
798
|
+
} catch {
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function plannedHostChannel(hostControl) {
|
|
803
|
+
if (!hostControl || process.platform !== "linux") return null;
|
|
804
|
+
let invoker;
|
|
805
|
+
try {
|
|
806
|
+
const info = userInfo();
|
|
807
|
+
invoker = { uid: info.uid, username: info.username, home: info.homedir };
|
|
808
|
+
} catch {
|
|
809
|
+
invoker = { uid: -1, username: process.env.USER || process.env.LOGNAME || "root", home: homedir() };
|
|
810
|
+
}
|
|
811
|
+
const decision = chooseHostChannelUser({
|
|
812
|
+
invokerUid: invoker.uid,
|
|
813
|
+
invokerName: invoker.username,
|
|
814
|
+
invokerHome: invoker.home,
|
|
815
|
+
// Only probe sudo when it could change the outcome — a root invoker never needs it.
|
|
816
|
+
hasPasswordlessSudo: invoker.uid !== 0 && hasPasswordlessSudo()
|
|
817
|
+
});
|
|
818
|
+
return { ...decision, keyPath: join4(COMPOSE_DIR, "host-ssh", "id_ed25519") };
|
|
819
|
+
}
|
|
820
|
+
function authorizeKeyAt(authKeysPath, pub) {
|
|
821
|
+
mkdirSync3(dirname(authKeysPath), { recursive: true, mode: 448 });
|
|
822
|
+
const existing = existsSync4(authKeysPath) ? readFileSync3(authKeysPath, "utf8") : "";
|
|
823
|
+
const next = rewriteHostAuthorizedKeys(existing, pub);
|
|
824
|
+
if (next !== existing) writeFileSync2(authKeysPath, next, { mode: 384 });
|
|
825
|
+
chmodSync(authKeysPath, 384);
|
|
826
|
+
}
|
|
827
|
+
function revokeKeyAt(authKeysPath) {
|
|
828
|
+
try {
|
|
829
|
+
if (!existsSync4(authKeysPath)) return true;
|
|
830
|
+
const existing = readFileSync3(authKeysPath, "utf8");
|
|
831
|
+
if (!existing.includes(HOST_KEY_COMMENT)) return true;
|
|
832
|
+
writeFileSync2(authKeysPath, stripHostAuthorizedKeys(existing), { mode: 384 });
|
|
833
|
+
chmodSync(authKeysPath, 384);
|
|
834
|
+
return true;
|
|
835
|
+
} catch {
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function revokeRootKeyViaSudo() {
|
|
840
|
+
const read = spawnSync3(
|
|
841
|
+
"sudo",
|
|
842
|
+
["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`],
|
|
843
|
+
{ encoding: "utf8" }
|
|
844
|
+
);
|
|
845
|
+
if (read.status !== 0) return false;
|
|
846
|
+
const existing = read.stdout ?? "";
|
|
847
|
+
if (!existing.includes(HOST_KEY_COMMENT)) return true;
|
|
848
|
+
const write = spawnSync3("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], {
|
|
849
|
+
input: stripHostAuthorizedKeys(existing),
|
|
850
|
+
stdio: ["pipe", "ignore", "ignore"]
|
|
851
|
+
});
|
|
852
|
+
return write.status === 0;
|
|
853
|
+
}
|
|
854
|
+
function retireHostSshChannel(prev) {
|
|
855
|
+
const keyDir = join4(COMPOSE_DIR, "host-ssh");
|
|
856
|
+
if (!existsSync4(keyDir) && !prev.OPENSHIP_HOST_SSH_HOST) return;
|
|
857
|
+
const invokerKeys = join4(homedir(), ".ssh", "authorized_keys");
|
|
858
|
+
const asRoot = hostChannelAccount(prev) === HOST_CHANNEL_DEFAULT_ACCOUNT;
|
|
859
|
+
const weAreRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
860
|
+
const revoked = asRoot ? weAreRoot ? revokeKeyAt(ROOT_AUTHORIZED_KEYS) : hasPasswordlessSudo() && revokeRootKeyViaSudo() : revokeKeyAt(invokerKeys);
|
|
861
|
+
const alsoInvoker = asRoot ? revokeKeyAt(invokerKeys) : true;
|
|
862
|
+
try {
|
|
863
|
+
rmSync(keyDir, { recursive: true, force: true });
|
|
864
|
+
} catch {
|
|
865
|
+
}
|
|
866
|
+
const stuck = asRoot ? ROOT_AUTHORIZED_KEYS : invokerKeys;
|
|
867
|
+
console.log(
|
|
868
|
+
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
|
|
869
|
+
can no longer be used). Removing its line needs root \u2014 run:
|
|
870
|
+
sudo sed -i '/${HOST_KEY_COMMENT}/d' ${stuck}`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
function provisionHostSshChannel(cfg) {
|
|
874
|
+
const target = plannedHostChannel(cfg.hostControl);
|
|
875
|
+
if (!target) return { channel: null };
|
|
876
|
+
const { user, keyPath, authKeysPath, elevation, rootUnavailable } = target;
|
|
877
|
+
const provisioned = (channel) => {
|
|
878
|
+
const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT;
|
|
879
|
+
if (probeHostSshd(port) === "absent") {
|
|
880
|
+
return { channel, issue: { code: "no-sshd", detail: `port ${port}` } };
|
|
881
|
+
}
|
|
882
|
+
const auth = verifyChannelAuth(channel.user, channel.keyPath, port);
|
|
883
|
+
return auth.state === "refused" ? { channel, issue: { code: "auth-refused", detail: auth.detail, account: channel.user } } : { channel };
|
|
884
|
+
};
|
|
885
|
+
try {
|
|
886
|
+
mkdirSync3(join4(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
|
|
887
|
+
if (!existsSync4(keyPath)) {
|
|
888
|
+
const g = spawnSync3(
|
|
889
|
+
"ssh-keygen",
|
|
890
|
+
["-t", "ed25519", "-N", "", "-q", "-f", keyPath, "-C", HOST_KEY_COMMENT],
|
|
891
|
+
// stderr is captured rather than discarded: it is the only account of why the
|
|
892
|
+
// channel is missing, and the caller prints it.
|
|
893
|
+
{ encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] }
|
|
894
|
+
);
|
|
895
|
+
if (g.status !== 0) return { channel: null, issue: { code: "keygen", detail: keygenWhy(g) } };
|
|
896
|
+
}
|
|
897
|
+
const pub = readFileSync3(`${keyPath}.pub`, "utf8").trim();
|
|
898
|
+
if (!pub) return { channel: null, issue: { code: "empty-key" } };
|
|
899
|
+
if (rootUnavailable) {
|
|
900
|
+
console.warn(
|
|
901
|
+
" \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."
|
|
902
|
+
);
|
|
903
|
+
authorizeKeyAt(authKeysPath, pub);
|
|
904
|
+
return provisioned({ user, keyPath });
|
|
905
|
+
}
|
|
906
|
+
authorizeKeyAt(authKeysPath, pub);
|
|
907
|
+
if (elevation === "sudo") revokeRootKeyViaSudo();
|
|
908
|
+
return provisioned({ user, keyPath });
|
|
909
|
+
} catch (err) {
|
|
910
|
+
return { channel: null, issue: { code: "error", detail: err?.message } };
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function verifyChannelAuth(user, keyPath, port) {
|
|
914
|
+
const r = spawnSync3(
|
|
915
|
+
"ssh",
|
|
916
|
+
[
|
|
917
|
+
"-i",
|
|
918
|
+
keyPath,
|
|
919
|
+
"-p",
|
|
920
|
+
String(port),
|
|
921
|
+
"-o",
|
|
922
|
+
"BatchMode=yes",
|
|
923
|
+
"-o",
|
|
924
|
+
"StrictHostKeyChecking=no",
|
|
925
|
+
"-o",
|
|
926
|
+
"UserKnownHostsFile=/dev/null",
|
|
927
|
+
"-o",
|
|
928
|
+
"ConnectTimeout=5",
|
|
929
|
+
"-o",
|
|
930
|
+
"PreferredAuthentications=publickey",
|
|
931
|
+
"-o",
|
|
932
|
+
"IdentitiesOnly=yes",
|
|
933
|
+
`${user}@127.0.0.1`,
|
|
934
|
+
"true"
|
|
935
|
+
],
|
|
936
|
+
{ encoding: "utf8", stdio: ["ignore", "ignore", "pipe"], timeout: 15e3 }
|
|
937
|
+
);
|
|
938
|
+
if (r.status === 0) return { state: "ok" };
|
|
939
|
+
if (r.error?.code === "ENOENT" || r.status === null) {
|
|
940
|
+
return { state: "unknown" };
|
|
941
|
+
}
|
|
942
|
+
const detail = (r.stderr || "").split("\n").map((l) => l.trim()).filter((l) => l && !/^Warning: Permanently added/.test(l)).join(" ");
|
|
943
|
+
return { state: "refused", detail: detail || `ssh exited ${r.status}` };
|
|
944
|
+
}
|
|
945
|
+
function keygenWhy(r) {
|
|
946
|
+
if (r.error?.code === "ENOENT") {
|
|
947
|
+
return "ssh-keygen is not installed \u2014 the openssh-client package provides it";
|
|
948
|
+
}
|
|
949
|
+
const stderr = (r.stderr ?? "").trim().split("\n").pop();
|
|
950
|
+
return stderr || r.error?.message || `ssh-keygen exited ${r.status ?? "abnormally"}`;
|
|
951
|
+
}
|
|
952
|
+
function probeHostSshd(port) {
|
|
953
|
+
for (const [cmd, args] of [
|
|
954
|
+
["ss", ["-H", "-ltn"]],
|
|
955
|
+
["netstat", ["-ltn"]]
|
|
956
|
+
]) {
|
|
957
|
+
const r = spawnSync3(cmd, [...args], { encoding: "utf8" });
|
|
958
|
+
if (r.status !== 0 || !r.stdout) continue;
|
|
959
|
+
return listensOnPort(r.stdout, port) ? "listening" : "absent";
|
|
960
|
+
}
|
|
961
|
+
return "unknown";
|
|
962
|
+
}
|
|
963
|
+
function listensOnPort(listing, port) {
|
|
964
|
+
return listing.split("\n").filter((line) => /\bLISTEN\b/.test(line)).some(
|
|
965
|
+
(line) => line.trim().split(/\s+/).some((token) => token.endsWith(`:${port}`))
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
function carriedHostChannel(prev) {
|
|
969
|
+
const keyPath = prev.OPENSHIP_HOST_KEY_PATH?.trim();
|
|
970
|
+
if (!prev.OPENSHIP_HOST_SSH_HOST?.trim() || !keyPath || !existsSync4(keyPath)) return null;
|
|
971
|
+
return { user: hostChannelAccount(prev), keyPath };
|
|
972
|
+
}
|
|
973
|
+
var HOST_ISSUE_INDENT = " ";
|
|
974
|
+
function hostIssueNote(text) {
|
|
975
|
+
return wrapText(text, 80).map((l) => `${HOST_ISSUE_INDENT}${l}
|
|
976
|
+
`).join("");
|
|
977
|
+
}
|
|
978
|
+
function renderHostChannelIssue(issue, ctx) {
|
|
979
|
+
const detail = issue.detail ? ` (${issue.detail})` : "";
|
|
980
|
+
const headline = issue.code === "no-sshd" ? ` \u26A0 Host control is provisioned, but nothing on this host is listening for SSH${detail}.` : issue.code === "auth-refused" ? ` \u26A0 Host control is provisioned, but this host REFUSED the channel's key.` : ` \u26A0 Host control could NOT be provisioned on this box.`;
|
|
981
|
+
let cause;
|
|
982
|
+
switch (issue.code) {
|
|
983
|
+
case "keygen":
|
|
984
|
+
cause = `The channel's key could not be generated${detail}.`;
|
|
985
|
+
break;
|
|
986
|
+
case "empty-key":
|
|
987
|
+
cause = "The channel's key was generated but its public half read back empty.";
|
|
988
|
+
break;
|
|
989
|
+
case "no-sshd":
|
|
990
|
+
cause = `The api container now has a key and an address, and will be refused the first time it dials ${ctx.target}:
|
|
991
|
+
` + explainHostChannelCause("refused", {
|
|
992
|
+
target: ctx.target,
|
|
993
|
+
// Measured here rather than left to core's default, which is Debian's `ssh` —
|
|
994
|
+
// a unit name that does not exist on the RHEL family, so the remedy printed on
|
|
995
|
+
// an Amazon Linux box was a command that silently does nothing.
|
|
996
|
+
sshdEnableHint: thisHost().sshdEnableHint()
|
|
997
|
+
}).body;
|
|
998
|
+
break;
|
|
999
|
+
case "auth-refused":
|
|
1000
|
+
cause = `The key was authorized for \`${issue.account ?? "the channel account"}\` and sshd is listening, but the dial was refused${detail}. Either that account's \`authorized_keys\` is not the file we wrote to, or sshd permits it no login \u2014 \`sshd -T | grep -i permitrootlogin\` shows the second. Until it is fixed the api container will be refused every time it dials ${ctx.target}.`;
|
|
1001
|
+
break;
|
|
1002
|
+
case "error":
|
|
1003
|
+
cause = `Setting up the channel's key failed${detail}.`;
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
const continuity = issue.code === "no-sshd" || issue.code === "auth-refused" ? "" : ctx.kept ? hostIssueNote(
|
|
1007
|
+
"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."
|
|
1008
|
+
) : hostIssueNote(
|
|
1009
|
+
"OPENSHIP_HOST_SSH_HOST is therefore unset, and host operations refuse rather than run against the api container's own filesystem."
|
|
1010
|
+
);
|
|
1011
|
+
return `${headline}
|
|
1012
|
+
` + hostIssueNote(cause) + continuity + hostIssueNote(HOST_CHANNEL_UNAFFECTED) + `${HOST_ISSUE_INDENT}Unavailable until the channel works:
|
|
1013
|
+
` + HOST_CHANNEL_BLOCKED.map((l) => `${HOST_ISSUE_INDENT} \xB7 ${l}
|
|
1014
|
+
`).join("") + hostIssueNote(HOST_CHANNEL_RECHECK);
|
|
1015
|
+
}
|
|
1016
|
+
function composeProjectName(prev) {
|
|
1017
|
+
if (prev.COMPOSE_PROJECT_NAME) return prev.COMPOSE_PROJECT_NAME;
|
|
1018
|
+
return Object.keys(prev).length > 0 ? "compose" : "openship";
|
|
1019
|
+
}
|
|
1020
|
+
var PGDATA_ROOT = "/var/lib/postgresql/data";
|
|
1021
|
+
function probePgDataDir(volume) {
|
|
1022
|
+
const r = spawnSync3(
|
|
1023
|
+
"docker",
|
|
1024
|
+
[
|
|
1025
|
+
"run",
|
|
1026
|
+
"--rm",
|
|
1027
|
+
"-v",
|
|
1028
|
+
`${volume}:/from:ro`,
|
|
1029
|
+
"alpine:3",
|
|
1030
|
+
"sh",
|
|
1031
|
+
"-c",
|
|
1032
|
+
`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`
|
|
1033
|
+
],
|
|
1034
|
+
{ encoding: "utf8" }
|
|
1035
|
+
);
|
|
1036
|
+
if (r.status !== 0) return "unknown";
|
|
1037
|
+
const out = `${r.stdout ?? ""}`.trim();
|
|
1038
|
+
return out === "root" || out === "sub" || out === "empty" || out === "foreign" ? out : "unknown";
|
|
1039
|
+
}
|
|
1040
|
+
function resolvePgData(prev) {
|
|
1041
|
+
const SUB = `${PGDATA_ROOT}/pgdata`;
|
|
1042
|
+
if (prev.OPENSHIP_PGDATA) return prev.OPENSHIP_PGDATA;
|
|
1043
|
+
const volume = survivingDbVolume(prev);
|
|
1044
|
+
if (!volume) return SUB;
|
|
1045
|
+
return probePgDataDir(volume) === "root" ? PGDATA_ROOT : SUB;
|
|
1046
|
+
}
|
|
1047
|
+
function pgDataDetectionRisk(prev) {
|
|
1048
|
+
if (prev.OPENSHIP_PGDATA) return null;
|
|
1049
|
+
const volume = survivingDbVolume(prev);
|
|
1050
|
+
if (!volume) return null;
|
|
1051
|
+
return probePgDataDir(volume) === "foreign" ? { volume } : null;
|
|
1052
|
+
}
|
|
1053
|
+
function composePgDataRisk(_opts = {}) {
|
|
1054
|
+
return pgDataDetectionRisk(readEnvFile());
|
|
1055
|
+
}
|
|
1056
|
+
function renderPgDataRefusal(risk, opts = {}) {
|
|
1057
|
+
const SUB = `${PGDATA_ROOT}/pgdata`;
|
|
1058
|
+
return `
|
|
1059
|
+
${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.
|
|
1060
|
+
|
|
1061
|
+
volume: ${risk.volume}
|
|
1062
|
+
checked: ${PGDATA_ROOT}/PG_VERSION and ${SUB}/PG_VERSION \u2014 neither present
|
|
1063
|
+
|
|
1064
|
+
Guessing where the cluster lives is how a wrong path makes Postgres run initdb over
|
|
1065
|
+
your existing data and orphan it, so nothing has been written. If you know the layout,
|
|
1066
|
+
pin it and re-run:
|
|
1067
|
+
|
|
1068
|
+
echo 'OPENSHIP_PGDATA=${SUB}' >> ${COMPOSE_ENV_FILE} # or ${PGDATA_ROOT} if the cluster is at the root
|
|
1069
|
+
`;
|
|
1070
|
+
}
|
|
1071
|
+
var PS_SWEEP_FORMAT = '{{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.config_files"}} {{.Label "com.docker.compose.service"}}';
|
|
1072
|
+
function parsePsSweep(stdout) {
|
|
1073
|
+
return stdout.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
1074
|
+
const [name, project, configFiles, service] = line.split(" ");
|
|
1075
|
+
return {
|
|
1076
|
+
name: name ?? "",
|
|
1077
|
+
project: project ?? "",
|
|
1078
|
+
configFiles: configFiles ?? "",
|
|
1079
|
+
service: service ?? ""
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
function psSweepRaw() {
|
|
1084
|
+
const r = spawnSync3("docker", ["ps", "-a", "--format", PS_SWEEP_FORMAT], { encoding: "utf8" });
|
|
1085
|
+
return r.status === 0 && r.stdout ? r.stdout : null;
|
|
1086
|
+
}
|
|
1087
|
+
function psSweep() {
|
|
1088
|
+
return parsePsSweep(psSweepRaw() ?? "");
|
|
1089
|
+
}
|
|
1090
|
+
function fromOurComposeFile(row) {
|
|
1091
|
+
return row.configFiles.split(",").some((f) => f.trim() === COMPOSE_FILE);
|
|
1092
|
+
}
|
|
1093
|
+
function orphanedStackContainers(project) {
|
|
1094
|
+
return psSweep().filter((c) => c.name && c.project && c.project !== project && fromOurComposeFile(c)).map(({ name, project: proj }) => ({ name, project: proj }));
|
|
1095
|
+
}
|
|
1096
|
+
function adoptedStackOrigin(psStdout, project) {
|
|
1097
|
+
const rows = parsePsSweep(psStdout).filter((c) => c.name && c.project === project);
|
|
1098
|
+
if (rows.length === 0 || rows.some(fromOurComposeFile)) return null;
|
|
1099
|
+
return {
|
|
1100
|
+
project,
|
|
1101
|
+
configFiles: rows.map((c) => c.configFiles).find(Boolean) ?? "",
|
|
1102
|
+
apiContainer: rows.find((c) => c.service === "api")?.name || `${project}-api-1`
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
function adoptedStack(project) {
|
|
1106
|
+
const sweep = psSweepRaw();
|
|
1107
|
+
return sweep ? adoptedStackOrigin(sweep, project) : null;
|
|
1108
|
+
}
|
|
1109
|
+
function reconcileDbPassword(user, password) {
|
|
1110
|
+
if (!password) return;
|
|
1111
|
+
if (compose(["up", "-d", "--wait", "postgres"], { quiet: true }) !== 0) return;
|
|
1112
|
+
const attempt = (asRole) => spawnSync3(
|
|
1113
|
+
"docker",
|
|
1114
|
+
[
|
|
1115
|
+
"compose",
|
|
1116
|
+
"-f",
|
|
1117
|
+
COMPOSE_FILE,
|
|
1118
|
+
"exec",
|
|
1119
|
+
"-T",
|
|
1120
|
+
"postgres",
|
|
1121
|
+
"psql",
|
|
1122
|
+
"-U",
|
|
1123
|
+
asRole,
|
|
1124
|
+
"-d",
|
|
1125
|
+
"postgres",
|
|
1126
|
+
"-v",
|
|
1127
|
+
"ON_ERROR_STOP=1",
|
|
1128
|
+
// Read the statement from STDIN, not `-c`: psql has no bind parameters for
|
|
1129
|
+
// ALTER USER, so the password has to be inlined in SQL — and an argv copy
|
|
1130
|
+
// would be readable in `ps` for the life of the call. stdin keeps it to this
|
|
1131
|
+
// process and the socket. Our generated password is hex, so it cannot break
|
|
1132
|
+
// out of the quoted literal either.
|
|
1133
|
+
"-f",
|
|
1134
|
+
"-"
|
|
1135
|
+
],
|
|
1136
|
+
{
|
|
1137
|
+
cwd: COMPOSE_DIR,
|
|
1138
|
+
encoding: "utf8",
|
|
1139
|
+
// Create the role if the volume predates it, else just realign it. Both
|
|
1140
|
+
// branches leave exactly the credentials this `.env` will present.
|
|
1141
|
+
input: `DO $$ BEGIN
|
|
1142
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${user}') THEN
|
|
1143
|
+
ALTER ROLE "${user}" WITH LOGIN PASSWORD '${password}';
|
|
1144
|
+
ELSE
|
|
1145
|
+
CREATE ROLE "${user}" WITH LOGIN SUPERUSER PASSWORD '${password}';
|
|
1146
|
+
END IF;
|
|
1147
|
+
END $$;
|
|
1148
|
+
`
|
|
1149
|
+
}
|
|
1150
|
+
);
|
|
1151
|
+
let r = attempt(user);
|
|
1152
|
+
if (r.status !== 0) r = attempt("postgres");
|
|
1153
|
+
if (r.status !== 0) {
|
|
1154
|
+
console.log(
|
|
1155
|
+
`
|
|
1156
|
+
! Could not realign the database password (${(r.stderr ?? "").trim() || "psql failed"}).
|
|
1157
|
+
The api will fail with "password authentication failed for user "${user}"" because the
|
|
1158
|
+
existing data volume was initialized with a different password.
|
|
1159
|
+
|
|
1160
|
+
Fix it one of two ways:
|
|
1161
|
+
\u2022 restore the .env that created the volume, or
|
|
1162
|
+
\u2022 start fresh (DESTROYS DB DATA): openship uninstall then openship up
|
|
1163
|
+
`
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
function removeOrphanedStack(project) {
|
|
1168
|
+
const orphans = orphanedStackContainers(project);
|
|
1169
|
+
if (orphans.length === 0) return;
|
|
1170
|
+
const projects = [...new Set(orphans.map((o) => o.project))].join(", ");
|
|
1171
|
+
console.log(
|
|
1172
|
+
` Removing ${orphans.length} container(s) from a previous stack (project "${projects}") \u2014 they would hold the ports this stack needs. Volumes are kept.`
|
|
1173
|
+
);
|
|
1174
|
+
spawnSync3("docker", ["rm", "-f", ...orphans.map((o) => o.name)], { stdio: "ignore" });
|
|
1175
|
+
}
|
|
1176
|
+
function edgeNameNeedsReclaim(psStdout, project) {
|
|
1177
|
+
const row = parsePsSweep(psStdout).find((c) => c.name === "openship-edge");
|
|
1178
|
+
if (!row) return false;
|
|
1179
|
+
return !(row.project === project && fromOurComposeFile(row));
|
|
1180
|
+
}
|
|
1181
|
+
function reconcileEdgeContainerName(project) {
|
|
1182
|
+
const sweep = psSweepRaw();
|
|
1183
|
+
if (!sweep) return;
|
|
1184
|
+
if (!edgeNameNeedsReclaim(sweep, project)) return;
|
|
1185
|
+
console.log(
|
|
1186
|
+
" Replacing an unmanaged `openship-edge` container so compose can own it (config is on the host, so no routes are lost)."
|
|
1187
|
+
);
|
|
1188
|
+
spawnSync3("docker", ["rm", "-f", "openship-edge"], { stdio: "ignore" });
|
|
1189
|
+
}
|
|
1190
|
+
function migrateLegacyEdgeVolumes(project) {
|
|
1191
|
+
const pairs = [
|
|
1192
|
+
{ volume: `${project}_openship_sites`, host: EDGE_SITES_HOST_DIR },
|
|
1193
|
+
{ volume: `${project}_openship_certs`, host: "/etc/letsencrypt" },
|
|
1194
|
+
{ volume: `${project}_openship_acme`, host: EDGE_ACME_HOST_DIR },
|
|
1195
|
+
{ volume: `${project}_openship_static`, host: "/opt/openship/static" }
|
|
1196
|
+
];
|
|
1197
|
+
const ls = spawnSync3("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
|
|
1198
|
+
if (ls.status !== 0 || !ls.stdout) return;
|
|
1199
|
+
const existing = new Set(ls.stdout.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
1200
|
+
for (const { volume, host } of pairs) {
|
|
1201
|
+
if (!existing.has(volume)) continue;
|
|
1202
|
+
mkdirSync3(host, { recursive: true });
|
|
1203
|
+
const alreadyThere = spawnSync3("sh", ["-c", `ls -A ${JSON.stringify(host)} 2>/dev/null | head -1`], {
|
|
1204
|
+
encoding: "utf8"
|
|
1205
|
+
});
|
|
1206
|
+
if (alreadyThere.stdout?.trim()) continue;
|
|
1207
|
+
console.log(` Moving edge state from volume ${volume} onto ${host}...`);
|
|
1208
|
+
const copy = spawnSync3(
|
|
1209
|
+
"docker",
|
|
1210
|
+
[
|
|
1211
|
+
"run",
|
|
1212
|
+
"--rm",
|
|
1213
|
+
"-v",
|
|
1214
|
+
`${volume}:/from:ro`,
|
|
1215
|
+
"-v",
|
|
1216
|
+
`${host}:/to`,
|
|
1217
|
+
"alpine:3",
|
|
1218
|
+
"sh",
|
|
1219
|
+
"-c",
|
|
1220
|
+
"cp -a /from/. /to/ 2>/dev/null || true"
|
|
1221
|
+
],
|
|
1222
|
+
{ stdio: "inherit" }
|
|
1223
|
+
);
|
|
1224
|
+
if (copy.status !== 0) {
|
|
1225
|
+
console.log(
|
|
1226
|
+
` Could not copy ${volume} \u2014 the stack will start with an empty ${host}.
|
|
1227
|
+
Recover manually with: docker run --rm -v ${volume}:/from -v ${host}:/to alpine cp -a /from/. /to/`
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
function warnOrphanedVolumes(project) {
|
|
1233
|
+
const r = spawnSync3("docker", ["volume", "ls", "--format", "{{.Name}}"], { encoding: "utf8" });
|
|
1234
|
+
if (r.status !== 0 || !r.stdout) return;
|
|
1235
|
+
const others = new Set(
|
|
1236
|
+
r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.endsWith("_openship_sites")).map((l) => l.slice(0, -"_openship_sites".length)).filter((p) => p && p !== project)
|
|
1237
|
+
);
|
|
1238
|
+
if (others.size === 0) return;
|
|
1239
|
+
console.log(
|
|
1240
|
+
` Note: volumes from a previous install (project "${[...others].join(", ")}") are still on disk
|
|
1241
|
+
and are NOT mounted by this stack \u2014 its database starts fresh. (Certificates and
|
|
1242
|
+
vhosts now live on the host, so those carry over.)
|
|
1243
|
+
Remove them with \`docker volume ls | grep _openship_\` once you're sure they aren't needed.`
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
function keepConfig(prev, key, explicit) {
|
|
1247
|
+
const set = explicit?.trim();
|
|
1248
|
+
if (set) return set;
|
|
1249
|
+
const carried = prev[key]?.trim();
|
|
1250
|
+
return carried || void 0;
|
|
1251
|
+
}
|
|
1252
|
+
var ACME_ENV_KEYS = [
|
|
1253
|
+
"OPENSHIP_ACME_EMAIL",
|
|
1254
|
+
"OPENSHIP_ACME_DIRECTORY_URL",
|
|
1255
|
+
"OPENSHIP_ACME_EAB_KID",
|
|
1256
|
+
"OPENSHIP_ACME_EAB_HMAC_KEY",
|
|
1257
|
+
"OPENSHIP_ACME_KEY_TYPE",
|
|
1258
|
+
"OPENSHIP_ACME_CA_BUNDLE",
|
|
1259
|
+
"OPENSHIP_ACME_TOS_AGREED"
|
|
1260
|
+
];
|
|
1261
|
+
function hostGatewayEnv(prev) {
|
|
1262
|
+
const value = process.env.OPENSHIP_HOST_GATEWAY?.trim() || prev.OPENSHIP_HOST_GATEWAY?.trim();
|
|
1263
|
+
return value ? [`OPENSHIP_HOST_GATEWAY=${value}`] : [];
|
|
1264
|
+
}
|
|
1265
|
+
function rootlessSocketCandidates() {
|
|
1266
|
+
const runtimeDir = process.env.XDG_RUNTIME_DIR?.trim();
|
|
1267
|
+
const sudoUid = process.env.SUDO_UID?.trim();
|
|
1268
|
+
let uid;
|
|
1269
|
+
try {
|
|
1270
|
+
uid = userInfo().uid;
|
|
1271
|
+
} catch {
|
|
1272
|
+
}
|
|
1273
|
+
return [
|
|
1274
|
+
...runtimeDir ? [join4(runtimeDir, "docker.sock")] : [],
|
|
1275
|
+
...sudoUid ? [`/run/user/${sudoUid}/docker.sock`] : [],
|
|
1276
|
+
...uid !== void 0 ? [`/run/user/${uid}/docker.sock`] : []
|
|
1277
|
+
];
|
|
1278
|
+
}
|
|
1279
|
+
function detectDockerSocketPath() {
|
|
1280
|
+
const fromClient = resolveLocalDockerSocketPath(void 0, process.env);
|
|
1281
|
+
if (fromClient !== DEFAULT_DOCKER_SOCKET_PATH) return fromClient;
|
|
1282
|
+
if (existsSync4(DEFAULT_DOCKER_SOCKET_PATH)) return DEFAULT_DOCKER_SOCKET_PATH;
|
|
1283
|
+
return rootlessSocketCandidates().find((p) => existsSync4(p)) ?? DEFAULT_DOCKER_SOCKET_PATH;
|
|
1284
|
+
}
|
|
1285
|
+
function resolveDockerSocket(prev) {
|
|
1286
|
+
const pinned = process.env.OPENSHIP_DOCKER_SOCKET?.trim() || prev.OPENSHIP_DOCKER_SOCKET?.trim();
|
|
1287
|
+
return pinned ? { path: pinned, pinned: true } : { path: detectDockerSocketPath(), pinned: false };
|
|
1288
|
+
}
|
|
1289
|
+
function dockerSocketEnv(prev) {
|
|
1290
|
+
const { path, pinned } = resolveDockerSocket(prev);
|
|
1291
|
+
return pinned || path !== DEFAULT_DOCKER_SOCKET_PATH ? [`OPENSHIP_DOCKER_SOCKET=${path}`] : [];
|
|
1292
|
+
}
|
|
1293
|
+
function warnMissingDockerSocket(path) {
|
|
1294
|
+
console.warn(
|
|
1295
|
+
`
|
|
1296
|
+
! The Docker socket this install mounts into the api isn't there:
|
|
1297
|
+
${path}
|
|
1298
|
+
Docker will create that path as an empty DIRECTORY, so the stack will come up
|
|
1299
|
+
and then fail every container operation with a transport error naming neither
|
|
1300
|
+
this path nor the cause (#482). Point it at the real socket:
|
|
1301
|
+
docker context inspect --format '{{.Endpoints.docker.Host}}'
|
|
1302
|
+
OPENSHIP_DOCKER_SOCKET=/run/user/$(id -u)/docker.sock openship up --compose
|
|
1303
|
+
(a rootless daemon keeps its socket under the daemon user's runtime dir).
|
|
1304
|
+
`
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
function renderAcmeEnv(prev) {
|
|
1308
|
+
return ACME_ENV_KEYS.flatMap((key) => {
|
|
1309
|
+
const value = process.env[key]?.trim() || prev[key]?.trim();
|
|
1310
|
+
if (!value) return [];
|
|
1311
|
+
if (/[\r\n]/.test(value)) throw new Error(`${key} must be a single-line value`);
|
|
1312
|
+
return [`${key}=${value}`];
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
function resolveEnvConfig(prev, opts) {
|
|
1316
|
+
const publicUrl = keepConfig(prev, "OPENSHIP_PUBLIC_URL", opts.publicUrl);
|
|
1317
|
+
const bindAddr = keepConfig(prev, "OPENSHIP_BIND_ADDR") ?? (publicUrl ? "0.0.0.0" : void 0);
|
|
1318
|
+
return {
|
|
1319
|
+
apiPort: keepConfig(prev, "API_PORT", opts.apiPort) ?? String(DEFAULT_API_PORT),
|
|
1320
|
+
dashPort: keepConfig(prev, "DASHBOARD_PORT", opts.dashboardPort) ?? String(DEFAULT_DASHBOARD_PORT),
|
|
1321
|
+
...bindAddr ? { bindAddr } : {},
|
|
1322
|
+
...publicUrl ? { publicUrl } : {},
|
|
1323
|
+
// A public URL always implies a proxy in front; otherwise keep whatever the
|
|
1324
|
+
// install was configured with.
|
|
1325
|
+
trustProxy: Boolean(opts.trustProxy) || Boolean(publicUrl) || prev.TRUST_PROXY === "true",
|
|
1326
|
+
...keepConfig(prev, "OPENSHIP_EXTRA_TRUSTED_ORIGINS", opts.extraTrustedOrigins) ? {
|
|
1327
|
+
extraTrustedOrigins: keepConfig(
|
|
1328
|
+
prev,
|
|
1329
|
+
"OPENSHIP_EXTRA_TRUSTED_ORIGINS",
|
|
1330
|
+
opts.extraTrustedOrigins
|
|
1331
|
+
)
|
|
1332
|
+
} : {},
|
|
1333
|
+
registry: keepConfig(prev, "OPENSHIP_IMAGE_REGISTRY", opts.registry) ?? DEFAULT_IMAGE_REGISTRY,
|
|
1334
|
+
// Tri-state: the flag is absent on a plain re-run, so fall back to what the
|
|
1335
|
+
// install chose rather than silently re-granting host control.
|
|
1336
|
+
hostControl: opts.noHostControl === void 0 ? prev.OPENSHIP_HOST_CONTROL !== "false" : !opts.noHostControl,
|
|
1337
|
+
// Sticky too: `--mail` is only the install-time default, so a plain re-run
|
|
1338
|
+
// (or `openship update`) must not hand a mail box back the platform shell.
|
|
1339
|
+
// Turning it OFF is the dashboard's job — Settings → Instance stores the mode
|
|
1340
|
+
// in the database, which wins over this env var either way.
|
|
1341
|
+
productMode: opts.mail || keepConfig(prev, "OPENSHIP_PRODUCT") === "mail" ? "mail" : "platform",
|
|
1342
|
+
// Sticky like every other operator choice here. These were literals in
|
|
1343
|
+
// `renderEnv`, so a box where `host.docker.internal:22` is simply not the
|
|
1344
|
+
// right address — rootless Docker, sshd on another port — had nowhere to say
|
|
1345
|
+
// so, and every `openship up` overwrote a hand-edited `.env` back to the
|
|
1346
|
+
// value that didn't work (#490/#482).
|
|
1347
|
+
hostSshHost: keepConfig(prev, "OPENSHIP_HOST_SSH_HOST", opts.hostSshHost) ?? HOST_CHANNEL_DEFAULT_HOST,
|
|
1348
|
+
hostSshPort: keepConfig(prev, "OPENSHIP_HOST_SSH_PORT", opts.hostSshPort) ?? String(HOST_CHANNEL_DEFAULT_PORT)
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
function toPort(value) {
|
|
1352
|
+
const n = Number(value);
|
|
1353
|
+
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : void 0;
|
|
1354
|
+
}
|
|
1355
|
+
function composeEnvPorts() {
|
|
1356
|
+
const env = readEnvFile();
|
|
1357
|
+
return { api: toPort(env.API_PORT), dashboard: toPort(env.DASHBOARD_PORT) };
|
|
1358
|
+
}
|
|
1359
|
+
function composeBindAddr() {
|
|
1360
|
+
return readEnvFile().OPENSHIP_BIND_ADDR?.trim() || "127.0.0.1";
|
|
1361
|
+
}
|
|
1362
|
+
function composeHostChannel() {
|
|
1363
|
+
const env = readEnvFile();
|
|
1364
|
+
const host = env.OPENSHIP_HOST_SSH_HOST?.trim();
|
|
1365
|
+
if (!host) return null;
|
|
1366
|
+
return {
|
|
1367
|
+
host,
|
|
1368
|
+
port: toPort(env.OPENSHIP_HOST_SSH_PORT) ?? 22,
|
|
1369
|
+
user: hostChannelAccount(env)
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
function composeHostChannelExpected() {
|
|
1373
|
+
if (!existsSync4(COMPOSE_FILE)) return false;
|
|
1374
|
+
return readEnvFile().OPENSHIP_HOST_CONTROL !== "false";
|
|
1375
|
+
}
|
|
1376
|
+
function composeTrustedOriginUrls() {
|
|
1377
|
+
const env = readEnvFile();
|
|
1378
|
+
return [env.OPENSHIP_PUBLIC_URL, env.OPENSHIP_EXTRA_TRUSTED_ORIGINS].filter(
|
|
1379
|
+
(v) => !!v?.trim()
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
function composeHeldPorts() {
|
|
1383
|
+
const r = spawnSync3(
|
|
1384
|
+
"docker",
|
|
1385
|
+
["ps", "--format", '{{.Label "com.docker.compose.project.config_files"}} {{.Ports}}'],
|
|
1386
|
+
{ encoding: "utf8" }
|
|
1387
|
+
);
|
|
1388
|
+
if (r.status !== 0 || !r.stdout) return [];
|
|
1389
|
+
const ports = /* @__PURE__ */ new Set();
|
|
1390
|
+
for (const line of r.stdout.split("\n")) {
|
|
1391
|
+
const [configFiles = "", published = ""] = line.split(" ");
|
|
1392
|
+
if (!configFiles.split(",").some((f) => f.trim() === COMPOSE_FILE)) continue;
|
|
1393
|
+
for (const m of published.matchAll(/:(\d+)->/g)) ports.add(Number(m[1]));
|
|
1394
|
+
}
|
|
1395
|
+
return [...ports];
|
|
1396
|
+
}
|
|
1397
|
+
async function resolveComposePorts(prefs, opts = {}) {
|
|
1398
|
+
return resolvePorts(
|
|
1399
|
+
{
|
|
1400
|
+
api: toPort(prefs.api),
|
|
1401
|
+
dashboard: toPort(prefs.dashboard),
|
|
1402
|
+
previous: composeEnvPorts(),
|
|
1403
|
+
bindAddr: composeBindAddr(),
|
|
1404
|
+
reclaimable: composeHeldPorts()
|
|
1405
|
+
},
|
|
1406
|
+
opts
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
var OMITTABLE_MANAGED_KEYS = /* @__PURE__ */ new Set([
|
|
1410
|
+
"OPENSHIP_HOST_SSH_HOST",
|
|
1411
|
+
"OPENSHIP_HOST_SSH_USER",
|
|
1412
|
+
"OPENSHIP_HOST_SSH_PORT",
|
|
1413
|
+
"OPENSHIP_HOST_SSH_KEY",
|
|
1414
|
+
"OPENSHIP_HOST_KEY_PATH",
|
|
1415
|
+
"OPENSHIP_HOST_GATEWAY",
|
|
1416
|
+
"TRUST_PROXY"
|
|
1417
|
+
]);
|
|
1418
|
+
var envKeyOf = (line) => line.match(/^([A-Z0-9_]+)=/)?.[1];
|
|
1419
|
+
function operatorEnvPassthrough(managed, prev) {
|
|
1420
|
+
return Object.entries(prev).filter(([key]) => !managed.has(key) && !OMITTABLE_MANAGED_KEYS.has(key)).map(([key, value]) => `${key}=${value}`);
|
|
1421
|
+
}
|
|
1422
|
+
var PRESERVED_ENV_HEADER = "# Preserved from your existing .env \u2014 set by you, not by `openship up` (#485).";
|
|
1423
|
+
function resolveImageVersion(opts) {
|
|
1424
|
+
return opts.version?.trim() || process.env.OPENSHIP_VERSION?.trim() || (true ? "0.6.6" : "latest");
|
|
1425
|
+
}
|
|
1426
|
+
function managedEnvLines(opts, host, cfg, prev) {
|
|
1427
|
+
const lines = [
|
|
1428
|
+
"# Managed by `openship up`. Secrets are generated once and preserved.",
|
|
1429
|
+
// Do NOT edit on a live install — it is the volume prefix (see composeProjectName).
|
|
1430
|
+
`COMPOSE_PROJECT_NAME=${composeProjectName(prev)}`,
|
|
1431
|
+
"CLOUD_MODE=false",
|
|
1432
|
+
"OPENSHIP_TARGET=local",
|
|
1433
|
+
"OPENSHIP_REQUIRE_AUTH=true",
|
|
1434
|
+
// Read by createHostExecutor (throws when false) and by the servers list
|
|
1435
|
+
// (hides the local row). Written explicitly so the policy is visible in .env.
|
|
1436
|
+
`OPENSHIP_HOST_CONTROL=${cfg.hostControl ? "true" : "false"}`,
|
|
1437
|
+
// The host side of the api's docker socket mount, when it isn't the rootful
|
|
1438
|
+
// default. Unlike the host channel above this is NOT optional plumbing — every
|
|
1439
|
+
// deploy goes through it — so it is resolved on every run, host control or not.
|
|
1440
|
+
...dockerSocketEnv(prev),
|
|
1441
|
+
// Which shell the dashboard defaults to. Emitted on EVERY run, including the
|
|
1442
|
+
// "platform" default: that keeps the key permanently in the managed set, so
|
|
1443
|
+
// the #485 passthrough can never mistake it for operator config (which would
|
|
1444
|
+
// pin it and make the Settings → Instance toggle look broken after an `up`).
|
|
1445
|
+
`OPENSHIP_PRODUCT=${cfg.productMode}`,
|
|
1446
|
+
`OPENSHIP_IMAGE_REGISTRY=${cfg.registry}`,
|
|
1447
|
+
`OPENSHIP_VERSION=${resolveImageVersion(opts)}`,
|
|
1448
|
+
`POSTGRES_PASSWORD=${keepSecret(prev, "POSTGRES_PASSWORD")}`,
|
|
1449
|
+
// Pinned once (see resolvePgData): fresh install → subdir, existing volume → root.
|
|
1450
|
+
`OPENSHIP_PGDATA=${resolvePgData(prev)}`,
|
|
1451
|
+
`BETTER_AUTH_SECRET=${keepSecret(prev, "BETTER_AUTH_SECRET")}`,
|
|
1452
|
+
`INTERNAL_TOKEN=${keepSecret(prev, "INTERNAL_TOKEN")}`,
|
|
1453
|
+
`API_PORT=${cfg.apiPort}`,
|
|
1454
|
+
`DASHBOARD_PORT=${cfg.dashPort}`,
|
|
1455
|
+
// The api's OWN view of the dashboard port: the self-app boot reconcile points
|
|
1456
|
+
// the operator's domain at the dashboard through this (self-deploy.ts), and it
|
|
1457
|
+
// silently defaults to 3001 when unset. Ports are dynamic now, so leaving it
|
|
1458
|
+
// out publishes a domain routed to a port nothing is listening on.
|
|
1459
|
+
`OPENSHIP_DASHBOARD_PORT=${cfg.dashPort}`,
|
|
1460
|
+
// Alternate CA/EAB values are operator configuration (including one secret),
|
|
1461
|
+
// so a routine `openship up`/upgrade must not silently discard them.
|
|
1462
|
+
...renderAcmeEnv(prev)
|
|
1463
|
+
];
|
|
1464
|
+
if (cfg.bindAddr) lines.push(`OPENSHIP_BIND_ADDR=${cfg.bindAddr}`);
|
|
1465
|
+
if (cfg.publicUrl) lines.push(`OPENSHIP_PUBLIC_URL=${cfg.publicUrl}`);
|
|
1466
|
+
if (cfg.extraTrustedOrigins) {
|
|
1467
|
+
lines.push(`OPENSHIP_EXTRA_TRUSTED_ORIGINS=${cfg.extraTrustedOrigins}`);
|
|
1468
|
+
}
|
|
1469
|
+
if (cfg.trustProxy) lines.push("TRUST_PROXY=true");
|
|
1470
|
+
if (host) {
|
|
1471
|
+
lines.push(
|
|
1472
|
+
`OPENSHIP_HOST_SSH_HOST=${cfg.hostSshHost}`,
|
|
1473
|
+
`OPENSHIP_HOST_SSH_USER=${host.user}`,
|
|
1474
|
+
`OPENSHIP_HOST_SSH_PORT=${cfg.hostSshPort}`,
|
|
1475
|
+
"OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key",
|
|
1476
|
+
`OPENSHIP_HOST_KEY_PATH=${host.keyPath}`,
|
|
1477
|
+
...hostGatewayEnv(prev)
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
return lines;
|
|
1481
|
+
}
|
|
1482
|
+
function renderEnvAndCarried(opts, host, cfg, prev) {
|
|
1483
|
+
const lines = managedEnvLines(opts, host, cfg, prev);
|
|
1484
|
+
const managed = new Set(lines.map(envKeyOf).filter((k) => !!k));
|
|
1485
|
+
const preserved = operatorEnvPassthrough(managed, prev);
|
|
1486
|
+
const body = preserved.length ? [...lines, "", PRESERVED_ENV_HEADER, ...preserved] : lines;
|
|
1487
|
+
return {
|
|
1488
|
+
text: body.join("\n") + "\n",
|
|
1489
|
+
carried: preserved.map((line) => line.slice(0, line.indexOf("=")))
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
function renderEnv(opts, host, cfg) {
|
|
1493
|
+
return renderEnvAndCarried(opts, host, cfg, readEnvFile()).text;
|
|
1494
|
+
}
|
|
1495
|
+
function sourceBuildDir() {
|
|
1496
|
+
const marker = readSourceInstall();
|
|
1497
|
+
if (!marker?.dir) return null;
|
|
1498
|
+
const hasAll = BUILT_SERVICES.every((s) => existsSync4(join4(marker.dir, s.dockerfile)));
|
|
1499
|
+
return hasAll ? marker.dir : null;
|
|
1500
|
+
}
|
|
1501
|
+
function renderBuildOverride(repoDir) {
|
|
1502
|
+
const services = BUILT_SERVICES.map(
|
|
1503
|
+
(s) => ` ${s.service}:
|
|
1504
|
+
build:
|
|
1505
|
+
context: ${repoDir}
|
|
1506
|
+
dockerfile: ${s.dockerfile}
|
|
1507
|
+
`
|
|
1508
|
+
).join("");
|
|
1509
|
+
return `# Managed by \`openship up\` (from-source install) \u2014 builds instead of pulls.
|
|
1510
|
+
services:
|
|
1511
|
+
${services}`;
|
|
1512
|
+
}
|
|
1513
|
+
function materialize(opts) {
|
|
1514
|
+
mkdirSync3(COMPOSE_DIR, { recursive: true, mode: 448 });
|
|
1515
|
+
const prev = readEnvFile();
|
|
1516
|
+
const cfg = resolveEnvConfig(prev, opts);
|
|
1517
|
+
const provision = provisionHostSshChannel(cfg);
|
|
1518
|
+
let host = provision.channel;
|
|
1519
|
+
let kept = false;
|
|
1520
|
+
if (!host && provision.issue) {
|
|
1521
|
+
const previousChannel = carriedHostChannel(prev);
|
|
1522
|
+
if (previousChannel) {
|
|
1523
|
+
host = previousChannel;
|
|
1524
|
+
kept = true;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (!cfg.hostControl) retireHostSshChannel(prev);
|
|
1528
|
+
if (provision.issue && !opts.alreadyFetched) {
|
|
1529
|
+
console.warn(
|
|
1530
|
+
renderHostChannelIssue(provision.issue, {
|
|
1531
|
+
target: `${cfg.hostSshHost}:${cfg.hostSshPort}`,
|
|
1532
|
+
kept
|
|
1533
|
+
})
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
const socket = resolveDockerSocket(prev);
|
|
1537
|
+
if (!opts.alreadyFetched && !existsSync4(socket.path)) warnMissingDockerSocket(socket.path);
|
|
1538
|
+
let before = "";
|
|
1539
|
+
try {
|
|
1540
|
+
before = readFileSync3(COMPOSE_ENV_FILE, "utf8");
|
|
1541
|
+
} catch {
|
|
1542
|
+
}
|
|
1543
|
+
const { text: rendered, carried } = renderEnvAndCarried(opts, host, cfg, prev);
|
|
1544
|
+
writeFileSync2(COMPOSE_FILE, renderComposeYaml());
|
|
1545
|
+
writeEnvFile(rendered, before);
|
|
1546
|
+
if (carried.length) {
|
|
1547
|
+
console.log(` Kept ${carried.length} operator-set .env variable(s): ${carried.join(", ")}`);
|
|
1548
|
+
}
|
|
1549
|
+
const buildDir = opts.build === false ? null : sourceBuildDir();
|
|
1550
|
+
if (buildDir) writeFileSync2(BUILD_FILE, renderBuildOverride(buildDir));
|
|
1551
|
+
return { buildDir, cfg, envChanged: before !== "" && rendered !== before };
|
|
1552
|
+
}
|
|
1553
|
+
function writeEnvFile(rendered, before) {
|
|
1554
|
+
if (rendered === before) return;
|
|
1555
|
+
if (before) writeFileSync2(ENV_BACKUP_FILE, before, { mode: 384 });
|
|
1556
|
+
writeFileSync2(ENV_TMP_FILE, rendered, { mode: 384 });
|
|
1557
|
+
renameSync(ENV_TMP_FILE, COMPOSE_ENV_FILE);
|
|
1558
|
+
}
|
|
1559
|
+
var SECRET_ENV_KEY = /(PASSWORD|SECRET|TOKEN|HMAC_KEY)$/;
|
|
1560
|
+
function composePlan(opts) {
|
|
1561
|
+
const prev = readEnvFile();
|
|
1562
|
+
const cfg = resolveEnvConfig(prev, opts);
|
|
1563
|
+
const buildDir = opts.build === false ? null : sourceBuildDir();
|
|
1564
|
+
const hostChannel = plannedHostChannel(cfg.hostControl);
|
|
1565
|
+
const settings = [];
|
|
1566
|
+
const newSecrets = [];
|
|
1567
|
+
for (const line of renderEnv(opts, hostChannel, cfg).split("\n")) {
|
|
1568
|
+
const at = line.indexOf("=");
|
|
1569
|
+
if (at < 1 || line.startsWith("#")) continue;
|
|
1570
|
+
const key = line.slice(0, at);
|
|
1571
|
+
const value = line.slice(at + 1);
|
|
1572
|
+
if (!SECRET_ENV_KEY.test(key)) {
|
|
1573
|
+
settings.push({ key, value });
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
const preserved = !!prev[key] && prev[key] === value;
|
|
1577
|
+
if (!preserved) newSecrets.push(key);
|
|
1578
|
+
settings.push({ key, value: preserved ? "<preserved>" : "<generated on this run>" });
|
|
1579
|
+
}
|
|
1580
|
+
return {
|
|
1581
|
+
composeFile: COMPOSE_FILE,
|
|
1582
|
+
envFile: COMPOSE_ENV_FILE,
|
|
1583
|
+
installMethodFile: INSTALL_METHOD_FILE,
|
|
1584
|
+
...buildDir ? { buildFile: BUILD_FILE } : {},
|
|
1585
|
+
yaml: COMPOSE_YAML,
|
|
1586
|
+
settings,
|
|
1587
|
+
newSecrets,
|
|
1588
|
+
secretRotation: opts.resetSecrets ? null : secretRotationRisk(prev),
|
|
1589
|
+
pgDataRisk: pgDataDetectionRisk(prev),
|
|
1590
|
+
existing: Object.keys(prev).length > 0,
|
|
1591
|
+
mountDirs: EDGE_CONTAINER_MOUNTS.map((m) => m.host),
|
|
1592
|
+
buildDir,
|
|
1593
|
+
hostChannel
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
function dbVolumeExists(project) {
|
|
1597
|
+
const r = spawnSync3("docker", ["volume", "inspect", `${project}_postgres_data`], {
|
|
1598
|
+
stdio: "ignore"
|
|
1599
|
+
});
|
|
1600
|
+
return r.status === 0;
|
|
1601
|
+
}
|
|
1602
|
+
function compose(args, opts) {
|
|
1603
|
+
const files = ["-f", COMPOSE_FILE];
|
|
1604
|
+
if (opts?.withBuildOverride) files.push("-f", BUILD_FILE);
|
|
1605
|
+
const r = spawnSync3("docker", ["compose", ...files, ...args], {
|
|
1606
|
+
cwd: COMPOSE_DIR,
|
|
1607
|
+
stdio: opts?.quiet ? "ignore" : "inherit"
|
|
1608
|
+
});
|
|
1609
|
+
return r.status ?? 1;
|
|
1610
|
+
}
|
|
1611
|
+
function missingPinnedImages(registry, version) {
|
|
1612
|
+
const missing = [];
|
|
1613
|
+
for (const { service } of BUILT_SERVICES) {
|
|
1614
|
+
const ref = `${registry}/openship-${service}:${version}`;
|
|
1615
|
+
const r = spawnSync3("docker", ["manifest", "inspect", ref], { encoding: "utf8" });
|
|
1616
|
+
if (r.status === 0) continue;
|
|
1617
|
+
const stderr = `${r.stderr ?? ""}`.toLowerCase();
|
|
1618
|
+
if (/manifest unknown|no such manifest|manifest for .+ not found/.test(stderr)) {
|
|
1619
|
+
missing.push(ref);
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1624
|
+
return missing;
|
|
1625
|
+
}
|
|
1626
|
+
function pinnedImagesReady(opts) {
|
|
1627
|
+
if (sourceBuildDir()) return true;
|
|
1628
|
+
const prev = readEnvFile();
|
|
1629
|
+
const version = resolveImageVersion(opts);
|
|
1630
|
+
const missing = missingPinnedImages(resolveEnvConfig(prev, opts).registry, version);
|
|
1631
|
+
if (!missing || missing.length === 0) return true;
|
|
1632
|
+
const prevVersion = prev.OPENSHIP_VERSION?.trim();
|
|
1633
|
+
const out = [
|
|
1634
|
+
"",
|
|
1635
|
+
` The pinned image tag \`${version}\` isn't in the registry yet:`,
|
|
1636
|
+
...missing.map((ref) => ` ${ref}`),
|
|
1637
|
+
"",
|
|
1638
|
+
" The CLI reached npm before its container images finished publishing \u2014 a",
|
|
1639
|
+
" release-ordering race (#486). Nothing on this box was changed.",
|
|
1640
|
+
"",
|
|
1641
|
+
" Any one of these gets you moving:",
|
|
1642
|
+
" \u2022 Wait a few minutes and re-run \u2014 the images usually land right after a release.",
|
|
1643
|
+
" \u2022 Pin a tag that exists:",
|
|
1644
|
+
" openship up --compose --image-version <tag>",
|
|
1645
|
+
" OPENSHIP_VERSION=<tag> openship up --compose"
|
|
1646
|
+
];
|
|
1647
|
+
if (prevVersion && prevVersion !== version) {
|
|
1648
|
+
out.push(` \u2022 Your current install runs \`${prevVersion}\`, a tag known good on this box.`);
|
|
1649
|
+
}
|
|
1650
|
+
out.push("");
|
|
1651
|
+
console.error(out.join("\n"));
|
|
1652
|
+
return false;
|
|
1653
|
+
}
|
|
1654
|
+
function composePrefetch(opts) {
|
|
1655
|
+
const rotation = composeSecretRotationRisk(opts);
|
|
1656
|
+
if (rotation) {
|
|
1657
|
+
console.error(renderSecretRotationRefusal(rotation));
|
|
1658
|
+
return { ok: false, envChanged: false };
|
|
1659
|
+
}
|
|
1660
|
+
const { buildDir, envChanged } = materialize(opts);
|
|
1661
|
+
if (buildDir) {
|
|
1662
|
+
return {
|
|
1663
|
+
ok: compose(["pull", "postgres", "redis"], { withBuildOverride: true }) === 0 && compose(["build"], { withBuildOverride: true }) === 0,
|
|
1664
|
+
envChanged
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
return { ok: compose(["pull"]) === 0, envChanged };
|
|
1668
|
+
}
|
|
1669
|
+
function ensureEdgeMountDirs() {
|
|
1670
|
+
const failed = [];
|
|
1671
|
+
for (const { host } of EDGE_CONTAINER_MOUNTS) {
|
|
1672
|
+
try {
|
|
1673
|
+
mkdirSync3(host, { recursive: true });
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
if (err.code !== "EEXIST") failed.push(host);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
return failed;
|
|
1679
|
+
}
|
|
1680
|
+
function isRootlessDocker() {
|
|
1681
|
+
const r = spawnSync3("docker", ["info", "-f", "{{println .SecurityOptions}}"], {
|
|
1682
|
+
encoding: "utf8"
|
|
1683
|
+
});
|
|
1684
|
+
return r.status === 0 && /rootless/i.test(r.stdout ?? "");
|
|
1685
|
+
}
|
|
1686
|
+
async function composeUp(opts) {
|
|
1687
|
+
if (!opts.alreadyFetched && !pinnedImagesReady(opts)) {
|
|
1688
|
+
const pre = resolveEnvConfig(readEnvFile(), opts);
|
|
1689
|
+
return { ok: false, apiPort: pre.apiPort, dashPort: pre.dashPort };
|
|
1690
|
+
}
|
|
1691
|
+
const rotation = composeSecretRotationRisk(opts);
|
|
1692
|
+
if (rotation) {
|
|
1693
|
+
const pre = resolveEnvConfig(readEnvFile(), opts);
|
|
1694
|
+
console.error(renderSecretRotationRefusal(rotation));
|
|
1695
|
+
return { ok: false, refused: true, apiPort: pre.apiPort, dashPort: pre.dashPort };
|
|
1696
|
+
}
|
|
1697
|
+
const pgData = composePgDataRisk(opts);
|
|
1698
|
+
if (pgData) {
|
|
1699
|
+
const pre = resolveEnvConfig(readEnvFile(), opts);
|
|
1700
|
+
console.error(renderPgDataRefusal(pgData));
|
|
1701
|
+
return { ok: false, refused: true, apiPort: pre.apiPort, dashPort: pre.dashPort };
|
|
1702
|
+
}
|
|
1703
|
+
const { buildDir, cfg, envChanged: rendered } = materialize(opts);
|
|
1704
|
+
const apiPort = cfg.apiPort;
|
|
1705
|
+
const dashPort = cfg.dashPort;
|
|
1706
|
+
const envChanged = opts.envChanged ?? rendered;
|
|
1707
|
+
const up = (extra = {}) => compose(
|
|
1708
|
+
envChanged ? ["up", "-d", "--force-recreate", ...ENV_CONSUMING_SERVICES] : ["up", "-d"],
|
|
1709
|
+
extra
|
|
1710
|
+
);
|
|
1711
|
+
const env = readEnvFile();
|
|
1712
|
+
const project = env.COMPOSE_PROJECT_NAME || "openship";
|
|
1713
|
+
removeOrphanedStack(project);
|
|
1714
|
+
reconcileEdgeContainerName(project);
|
|
1715
|
+
migrateLegacyEdgeVolumes(project);
|
|
1716
|
+
warnOrphanedVolumes(project);
|
|
1717
|
+
const unmakeableMounts = ensureEdgeMountDirs();
|
|
1718
|
+
if (unmakeableMounts.length && isRootlessDocker()) {
|
|
1719
|
+
const dirs = unmakeableMounts.join(" ");
|
|
1720
|
+
console.error(
|
|
1721
|
+
`
|
|
1722
|
+
Rootless Docker can't create the edge's host directories under root-owned paths:
|
|
1723
|
+
${unmakeableMounts.join("\n ")}
|
|
1724
|
+
|
|
1725
|
+
Create them once (owned by your user), then re-run \`openship up --compose\`:
|
|
1726
|
+
sudo mkdir -p ${dirs}
|
|
1727
|
+
sudo chown -R "$(id -un)" ${dirs}
|
|
1728
|
+
`
|
|
1729
|
+
);
|
|
1730
|
+
return { ok: false, apiPort, dashPort };
|
|
1731
|
+
}
|
|
1732
|
+
if (dbVolumeExists(project)) {
|
|
1733
|
+
reconcileDbPassword(env.POSTGRES_USER || "openship", env.POSTGRES_PASSWORD ?? "");
|
|
1734
|
+
}
|
|
1735
|
+
await sanitizeEdgeVhosts(
|
|
1736
|
+
new LocalExecutor(),
|
|
1737
|
+
EDGE_SITES_HOST_DIR,
|
|
1738
|
+
(l) => console.log(` ${l.message}`)
|
|
1739
|
+
).catch(() => {
|
|
1740
|
+
});
|
|
1741
|
+
if (buildDir) {
|
|
1742
|
+
if (!opts.alreadyFetched) {
|
|
1743
|
+
if (compose(["pull", "postgres", "redis"], { withBuildOverride: true }) !== 0) {
|
|
1744
|
+
return { ok: false, apiPort, dashPort };
|
|
1745
|
+
}
|
|
1746
|
+
if (compose(["build"], { withBuildOverride: true }) !== 0) {
|
|
1747
|
+
return { ok: false, apiPort, dashPort };
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (up({ withBuildOverride: true }) !== 0) {
|
|
1751
|
+
return { ok: false, apiPort, dashPort };
|
|
1752
|
+
}
|
|
1753
|
+
onEdgeContainerChanged();
|
|
1754
|
+
writeInstallMethod("compose");
|
|
1755
|
+
return { ok: true, apiPort, dashPort };
|
|
1756
|
+
}
|
|
1757
|
+
if (!opts.alreadyFetched && compose(["pull"]) !== 0) {
|
|
1758
|
+
return { ok: false, apiPort, dashPort };
|
|
1759
|
+
}
|
|
1760
|
+
if (up() !== 0) return { ok: false, apiPort, dashPort };
|
|
1761
|
+
onEdgeContainerChanged();
|
|
1762
|
+
writeInstallMethod("compose");
|
|
1763
|
+
return { ok: true, apiPort, dashPort };
|
|
1764
|
+
}
|
|
1765
|
+
function onEdgeContainerChanged() {
|
|
1766
|
+
invalidateEdgeContainer();
|
|
1767
|
+
}
|
|
1768
|
+
function composeDown() {
|
|
1769
|
+
if (!existsSync4(COMPOSE_FILE)) return false;
|
|
1770
|
+
return compose(["down"]) === 0;
|
|
1771
|
+
}
|
|
1772
|
+
function composeUninstall(opts = {}) {
|
|
1773
|
+
const removedImages = [];
|
|
1774
|
+
const ok = existsSync4(COMPOSE_FILE) ? compose(["down", "-v", "--remove-orphans"]) === 0 : false;
|
|
1775
|
+
if (opts.removeImages) {
|
|
1776
|
+
const env = readEnvFile();
|
|
1777
|
+
const registry = env.OPENSHIP_IMAGE_REGISTRY || DEFAULT_IMAGE_REGISTRY;
|
|
1778
|
+
const version = env.OPENSHIP_VERSION || "latest";
|
|
1779
|
+
for (const { service } of BUILT_SERVICES) {
|
|
1780
|
+
const ref = `${registry}/openship-${service}:${version}`;
|
|
1781
|
+
if (spawnSync3("docker", ["image", "rm", "-f", ref], { stdio: "ignore" }).status === 0) {
|
|
1782
|
+
removedImages.push(ref);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
return { ok, removedImages };
|
|
1787
|
+
}
|
|
1788
|
+
async function composeUpdate(version) {
|
|
1789
|
+
if (!existsSync4(COMPOSE_FILE)) return { applied: false, refused: false };
|
|
1790
|
+
const r = await composeUp(version ? { version } : {});
|
|
1791
|
+
return { applied: r.ok, refused: !!r.refused };
|
|
1792
|
+
}
|
|
1793
|
+
function composePs() {
|
|
1794
|
+
return compose(["ps"]);
|
|
1795
|
+
}
|
|
1796
|
+
function composeRunning() {
|
|
1797
|
+
const r = spawnSync3(
|
|
1798
|
+
"docker",
|
|
1799
|
+
["compose", "-f", COMPOSE_FILE, "ps", "--status", "running", "-q"],
|
|
1800
|
+
{ cwd: COMPOSE_DIR, encoding: "utf8" }
|
|
1801
|
+
);
|
|
1802
|
+
return r.status === 0 && r.stdout.trim().length > 0;
|
|
1803
|
+
}
|
|
1804
|
+
function composeStart() {
|
|
1805
|
+
reconcileEdgeContainerName(readEnvFile().COMPOSE_PROJECT_NAME || "openship");
|
|
1806
|
+
return compose(["up", "-d"], { quiet: true }) === 0;
|
|
1807
|
+
}
|
|
1808
|
+
function composeRestart() {
|
|
1809
|
+
return compose(["restart"], { quiet: true }) === 0;
|
|
1810
|
+
}
|
|
1811
|
+
function composeInternalToken() {
|
|
1812
|
+
return readEnvFile().INTERNAL_TOKEN ?? null;
|
|
1813
|
+
}
|
|
1814
|
+
var composePaths = {
|
|
1815
|
+
dir: COMPOSE_DIR,
|
|
1816
|
+
file: COMPOSE_FILE,
|
|
1817
|
+
env: COMPOSE_ENV_FILE,
|
|
1818
|
+
envBackup: ENV_BACKUP_FILE
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
// src/lib/host-channel-preflight.ts
|
|
1822
|
+
var INDENT = " ";
|
|
1823
|
+
function dimWrapped(text, width = 84) {
|
|
1824
|
+
return wrapText(text, width).map((l) => chalk.dim(`${INDENT}${l}
|
|
1825
|
+
`)).join("");
|
|
1826
|
+
}
|
|
1827
|
+
function blockedTail() {
|
|
1828
|
+
return "\n" + dimWrapped(HOST_CHANNEL_RECHECK) + dimWrapped(HOST_CHANNEL_OPT_OUT);
|
|
1829
|
+
}
|
|
1830
|
+
var PROBE_SCRIPT = `const net = require("node:net");
|
|
1831
|
+
const s = net.connect({ host: process.env.OPENSHIP_PROBE_HOST, port: Number(process.env.OPENSHIP_PROBE_PORT) });
|
|
1832
|
+
const done = (m) => { console.log("openship-probe:" + m); try { s.destroy(); } catch {} process.exit(0); };
|
|
1833
|
+
s.setTimeout(Number(process.env.OPENSHIP_PROBE_TIMEOUT || 4000));
|
|
1834
|
+
s.on("connect", () => done("open"));
|
|
1835
|
+
s.on("timeout", () => done("timeout"));
|
|
1836
|
+
s.on("error", (e) => done("error:" + (e && e.code ? e.code : String(e && e.message))));`;
|
|
1837
|
+
async function realProbe(host, port) {
|
|
1838
|
+
const r = spawnSync4(
|
|
1839
|
+
"docker",
|
|
1840
|
+
[
|
|
1841
|
+
"compose",
|
|
1842
|
+
"-f",
|
|
1843
|
+
composePaths.file,
|
|
1844
|
+
"exec",
|
|
1845
|
+
"-T",
|
|
1846
|
+
"-e",
|
|
1847
|
+
`OPENSHIP_PROBE_HOST=${host}`,
|
|
1848
|
+
"-e",
|
|
1849
|
+
`OPENSHIP_PROBE_PORT=${port}`,
|
|
1850
|
+
"api",
|
|
1851
|
+
"bun",
|
|
1852
|
+
"-e",
|
|
1853
|
+
PROBE_SCRIPT
|
|
1854
|
+
],
|
|
1855
|
+
{ cwd: composePaths.dir, encoding: "utf8", timeout: 3e4 }
|
|
1856
|
+
);
|
|
1857
|
+
const token = (r.stdout || "").match(/openship-probe:(\S+)/)?.[1];
|
|
1858
|
+
if (!token) {
|
|
1859
|
+
const why = (r.stderr || r.stdout || r.error?.message || "").trim().split("\n").pop();
|
|
1860
|
+
return { code: "unavailable", detail: why || void 0 };
|
|
1861
|
+
}
|
|
1862
|
+
if (token === "open" || token === "timeout") return { code: token };
|
|
1863
|
+
const errno = token.startsWith("error:") ? token.slice("error:".length) : token;
|
|
1864
|
+
if (errno === "ECONNREFUSED" || errno === "ECONNRESET") return { code: "refused", detail: errno };
|
|
1865
|
+
if (errno === "ENOTFOUND" || errno === "EAI_AGAIN") return { code: "unresolved", detail: errno };
|
|
1866
|
+
if (errno === "ENETUNREACH" || errno === "EHOSTUNREACH" || errno === "EHOSTDOWN") {
|
|
1867
|
+
return { code: "no_route", detail: errno };
|
|
1868
|
+
}
|
|
1869
|
+
return { code: "error", detail: errno };
|
|
1870
|
+
}
|
|
1871
|
+
function realSourceCidrs() {
|
|
1872
|
+
const id = spawnSync4("docker", ["compose", "-f", composePaths.file, "ps", "-q", "api"], {
|
|
1873
|
+
cwd: composePaths.dir,
|
|
1874
|
+
encoding: "utf8"
|
|
1875
|
+
}).stdout?.trim().split("\n")[0];
|
|
1876
|
+
if (!id) return [];
|
|
1877
|
+
const nets = spawnSync4(
|
|
1878
|
+
"docker",
|
|
1879
|
+
["inspect", id, "--format", "{{range $k, $v := .NetworkSettings.Networks}}{{$k}}\n{{end}}"],
|
|
1880
|
+
{ encoding: "utf8" }
|
|
1881
|
+
).stdout;
|
|
1882
|
+
const cidrs = /* @__PURE__ */ new Set();
|
|
1883
|
+
for (const net of (nets || "").split("\n").map((n) => n.trim()).filter(Boolean)) {
|
|
1884
|
+
const subnets = spawnSync4(
|
|
1885
|
+
"docker",
|
|
1886
|
+
["network", "inspect", net, "--format", "{{range .IPAM.Config}}{{.Subnet}}\n{{end}}"],
|
|
1887
|
+
{ encoding: "utf8" }
|
|
1888
|
+
).stdout;
|
|
1889
|
+
for (const s of (subnets || "").split("\n").map((v) => v.trim())) {
|
|
1890
|
+
if (/^\d+\.\d+\.\d+\.\d+\/\d+$/.test(s)) cidrs.add(s);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
return [...cidrs];
|
|
1894
|
+
}
|
|
1895
|
+
function sudoPrefix() {
|
|
1896
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return [];
|
|
1897
|
+
return process.stdin.isTTY ? ["sudo"] : ["sudo", "-n"];
|
|
1898
|
+
}
|
|
1899
|
+
function realApplyRule(fw, cidrs, port) {
|
|
1900
|
+
const steps = firewallAllowSteps(fw, hostChannelRuleScope(cidrs, port));
|
|
1901
|
+
if (!steps.supported) return { ok: false, detail: steps.reason };
|
|
1902
|
+
for (const step of steps.value) {
|
|
1903
|
+
const [cmd, ...rest] = [...sudoPrefix(), "sh", "-c", step];
|
|
1904
|
+
const r = spawnSync4(cmd, rest, { encoding: "utf8", stdio: ["inherit", "pipe", "pipe"] });
|
|
1905
|
+
if (r.status !== 0) {
|
|
1906
|
+
return { ok: false, detail: (r.stderr || r.stdout || "").trim().split("\n").pop() };
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
return { ok: true };
|
|
1910
|
+
}
|
|
1911
|
+
function defaultDeps() {
|
|
1912
|
+
return {
|
|
1913
|
+
platform: process.platform,
|
|
1914
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
1915
|
+
channel: composeHostChannel,
|
|
1916
|
+
expected: composeHostChannelExpected,
|
|
1917
|
+
probe: realProbe,
|
|
1918
|
+
sourceCidrs: realSourceCidrs,
|
|
1919
|
+
// All three off the one resolver, which measures this box once. This file used to
|
|
1920
|
+
// run its own `command -v ufw` + `ufw status` ladder, so the CLI and the API could
|
|
1921
|
+
// reach different conclusions about the same host — and it had no way at all to
|
|
1922
|
+
// report nftables, the RHEL-family default.
|
|
1923
|
+
firewall: () => thisHost().profile.firewall,
|
|
1924
|
+
distroFamily: () => thisHost().profile.distroFamily,
|
|
1925
|
+
sshdEnableHint: () => thisHost().sshdEnableHint(),
|
|
1926
|
+
applyRule: realApplyRule,
|
|
1927
|
+
confirmOpen: async ({ rule, port }) => {
|
|
1928
|
+
const ok = await confirm({
|
|
1929
|
+
message: `Open the host firewall so containers can reach port ${port}?`,
|
|
1930
|
+
// Never default-yes: this edits the host's firewall.
|
|
1931
|
+
initialValue: false
|
|
1932
|
+
});
|
|
1933
|
+
return !isCancel(ok) && ok === true;
|
|
1934
|
+
},
|
|
1935
|
+
print: (text) => console.log(text)
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
function impactLines() {
|
|
1939
|
+
return dimWrapped(HOST_CHANNEL_UNAFFECTED) + chalk.dim(`${INDENT}Blocked until the channel works:
|
|
1940
|
+
`) + HOST_CHANNEL_BLOCKED.map((l) => chalk.dim(`${INDENT} \xB7 ${l}
|
|
1941
|
+
`)).join("") + dimWrapped(HOST_CHANNEL_SYMPTOM);
|
|
1942
|
+
}
|
|
1943
|
+
function causeOf(code) {
|
|
1944
|
+
switch (code) {
|
|
1945
|
+
case "timeout":
|
|
1946
|
+
case "refused":
|
|
1947
|
+
case "unresolved":
|
|
1948
|
+
case "no_route":
|
|
1949
|
+
case "error":
|
|
1950
|
+
return code;
|
|
1951
|
+
default:
|
|
1952
|
+
return null;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
function diagnose(target, probe, sshdEnableHint) {
|
|
1956
|
+
const cause = causeOf(probe.code);
|
|
1957
|
+
if (!cause) {
|
|
1958
|
+
return {
|
|
1959
|
+
firewall: false,
|
|
1960
|
+
text: chalk.yellow(`${INDENT}${target} \u2014 ${probe.detail || "the connection failed"}
|
|
1961
|
+
`)
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
const { headline, body, firewallShaped } = explainHostChannelCause(cause, {
|
|
1965
|
+
target,
|
|
1966
|
+
host: target.split(":")[0],
|
|
1967
|
+
detail: probe.detail,
|
|
1968
|
+
sshdEnableHint
|
|
1969
|
+
});
|
|
1970
|
+
return {
|
|
1971
|
+
firewall: firewallShaped,
|
|
1972
|
+
text: chalk.yellow(`${INDENT}${headline}
|
|
1973
|
+
|
|
1974
|
+
`) + (body ? dimWrapped(body) : "")
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
async function checkHostChannel(overrides = {}) {
|
|
1978
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
1979
|
+
if (deps.platform !== "linux") return { configured: false };
|
|
1980
|
+
const channel = deps.channel();
|
|
1981
|
+
if (!channel) return { configured: false, unprovisioned: deps.expected() };
|
|
1982
|
+
const probe = await deps.probe(channel.host, channel.port);
|
|
1983
|
+
return {
|
|
1984
|
+
configured: true,
|
|
1985
|
+
target: `${channel.host}:${channel.port}`,
|
|
1986
|
+
code: probe.code,
|
|
1987
|
+
detail: probe.detail
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
async function verifyHostChannel(opts = {}, overrides = {}) {
|
|
1991
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
1992
|
+
if (deps.platform !== "linux") return { status: "skipped" };
|
|
1993
|
+
const channel = deps.channel();
|
|
1994
|
+
if (!channel) {
|
|
1995
|
+
if (!deps.expected()) return { status: "skipped" };
|
|
1996
|
+
deps.print(
|
|
1997
|
+
chalk.yellow(`
|
|
1998
|
+
${INDENT}Host control was not provisioned on this box.
|
|
1999
|
+
|
|
2000
|
+
`) + dimWrapped(HOST_CHANNEL_UNPROVISIONED) + "\n" + impactLines() + // The remedy, not a firewall rule: nothing was dialed, so no packet filter has
|
|
2001
|
+
// been established — the same line core's `firewallShaped` draws. It carries the
|
|
2002
|
+
// recheck command itself, so `blockedTail` would only repeat it.
|
|
2003
|
+
chalk.dim(`
|
|
2004
|
+
${INDENT}Fix:
|
|
2005
|
+
`) + dimWrapped(HOST_CHANNEL_NOT_PROVISIONED) + dimWrapped(HOST_CHANNEL_OPT_OUT)
|
|
2006
|
+
);
|
|
2007
|
+
return { status: "unprovisioned" };
|
|
2008
|
+
}
|
|
2009
|
+
const target = `${channel.host}:${channel.port}`;
|
|
2010
|
+
const probe = await deps.probe(channel.host, channel.port);
|
|
2011
|
+
if (probe.code === "open") return { status: "ok", code: "open" };
|
|
2012
|
+
if (probe.code === "unavailable") {
|
|
2013
|
+
deps.print(
|
|
2014
|
+
chalk.dim(
|
|
2015
|
+
`
|
|
2016
|
+
${INDENT}Couldn't check the host channel from the api container${probe.detail ? ` (${probe.detail})` : ""}.
|
|
2017
|
+
${INDENT}Verify it later with \`openship doctor\`.
|
|
2018
|
+
`
|
|
2019
|
+
)
|
|
2020
|
+
);
|
|
2021
|
+
return { status: "skipped", code: "unavailable" };
|
|
2022
|
+
}
|
|
2023
|
+
const { text, firewall: firewallShaped } = diagnose(target, probe, deps.sshdEnableHint());
|
|
2024
|
+
deps.print(
|
|
2025
|
+
chalk.yellow(`
|
|
2026
|
+
${INDENT}Host control is configured, but the api container can't reach it.
|
|
2027
|
+
|
|
2028
|
+
`) + text + "\n" + impactLines()
|
|
2029
|
+
);
|
|
2030
|
+
if (!firewallShaped) {
|
|
2031
|
+
deps.print(blockedTail());
|
|
2032
|
+
return { status: "blocked", code: probe.code };
|
|
2033
|
+
}
|
|
2034
|
+
const cidrs = deps.sourceCidrs();
|
|
2035
|
+
const firewall = deps.firewall();
|
|
2036
|
+
const rule = hostFirewallRule(firewall, cidrs, channel.port, deps.distroFamily());
|
|
2037
|
+
const ruleBlock = chalk.dim(`
|
|
2038
|
+
${INDENT}Rule to allow it${cidrs.length ? "" : ` (docker's default pool \u2014 the exact subnet couldn't be read)`}:
|
|
2039
|
+
`) + rule.split("\n").map((l) => `${INDENT} ${chalk.cyan(l)}`).join("\n") + "\n";
|
|
2040
|
+
const offerOnly = (why) => {
|
|
2041
|
+
deps.print(ruleBlock + "\n" + dimWrapped(why) + blockedTail());
|
|
2042
|
+
return { status: "blocked", code: probe.code, rule };
|
|
2043
|
+
};
|
|
2044
|
+
const manager = firewallManager(firewall);
|
|
2045
|
+
if (!manager.supported) return offerOnly(manager.reason);
|
|
2046
|
+
if (!firewallPersists(firewall)) {
|
|
2047
|
+
return offerOnly(
|
|
2048
|
+
`Openship won't add this ${firewall} rule for you: it lives in the running ruleset only, so it would not survive a reboot unless something saves it \u2014 and where that is depends on this box. A rule that quietly disappears is worse than one you installed knowing where it lives.`
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
deps.print(ruleBlock);
|
|
2052
|
+
const consented = opts.openFirewall ? true : deps.interactive ? await deps.confirmOpen({ rule, port: channel.port }) : false;
|
|
2053
|
+
if (!consented) {
|
|
2054
|
+
deps.print(
|
|
2055
|
+
chalk.dim(
|
|
2056
|
+
`
|
|
2057
|
+
${INDENT}Left the firewall alone \u2014 apply the rule above when you're ready.
|
|
2058
|
+
` + (deps.interactive ? "" : `${INDENT}To have \`openship up\` add it for you: --open-host-firewall
|
|
2059
|
+
`)
|
|
2060
|
+
) + blockedTail()
|
|
2061
|
+
);
|
|
2062
|
+
return { status: "blocked", code: probe.code, rule };
|
|
2063
|
+
}
|
|
2064
|
+
const applied = deps.applyRule(manager.value, cidrs, channel.port);
|
|
2065
|
+
if (!applied.ok) {
|
|
2066
|
+
deps.print(
|
|
2067
|
+
chalk.red(
|
|
2068
|
+
`
|
|
2069
|
+
${INDENT}Couldn't add the rule${applied.detail ? `: ${applied.detail}` : "."}
|
|
2070
|
+
`
|
|
2071
|
+
) + chalk.dim(`${INDENT}Run it yourself with the command above.
|
|
2072
|
+
`) + blockedTail()
|
|
2073
|
+
);
|
|
2074
|
+
return { status: "blocked", code: probe.code, rule };
|
|
2075
|
+
}
|
|
2076
|
+
const again = await deps.probe(channel.host, channel.port);
|
|
2077
|
+
if (again.code === "open") {
|
|
2078
|
+
deps.print(chalk.green(`
|
|
2079
|
+
${INDENT}Opened \u2014 the host channel is reachable.
|
|
2080
|
+
`));
|
|
2081
|
+
return { status: "fixed", code: "open", rule };
|
|
2082
|
+
}
|
|
2083
|
+
deps.print(
|
|
2084
|
+
chalk.yellow(`
|
|
2085
|
+
${INDENT}Rule added, but ${target} still isn't reachable.
|
|
2086
|
+
|
|
2087
|
+
`) + (again.code === probe.code ? chalk.dim(
|
|
2088
|
+
`${INDENT}Nothing changed, so something ahead of ${firewall} is dropping it \u2014 check
|
|
2089
|
+
${INDENT}the DOCKER-USER chain and any cloud/provider firewall.
|
|
2090
|
+
`
|
|
2091
|
+
) : diagnose(target, again, deps.sshdEnableHint()).text) + // The rule is a host change made on the premise that it was the blocker, so it
|
|
2092
|
+
// must not be left invisible. It is deliberately NOT reverted for us: a failure
|
|
2093
|
+
// that changed shape means the rule did something, and it may well be needed
|
|
2094
|
+
// once the real cause is fixed.
|
|
2095
|
+
chalk.dim(`
|
|
2096
|
+
${INDENT}The rule is in place; to undo it:
|
|
2097
|
+
`) + hostFirewallRuleRemoval(firewall, cidrs, channel.port).split("\n").map((l) => `${INDENT} ${chalk.cyan(l)}`).join("\n") + "\n" + blockedTail()
|
|
2098
|
+
);
|
|
2099
|
+
return { status: "blocked", code: again.code, rule };
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
export {
|
|
2103
|
+
PORTS_FILE,
|
|
2104
|
+
saveInstanceUrl,
|
|
2105
|
+
readInstanceUrl,
|
|
2106
|
+
readStoredPorts,
|
|
2107
|
+
storedApiPort,
|
|
2108
|
+
storedDashboardPort,
|
|
2109
|
+
portMoveNotice,
|
|
2110
|
+
resolvePorts,
|
|
2111
|
+
DEFAULT_REPO,
|
|
2112
|
+
SOURCE_CHECKOUT_DIR,
|
|
2113
|
+
SOURCE_DIST_DIR,
|
|
2114
|
+
has,
|
|
2115
|
+
shortSha,
|
|
2116
|
+
prepareFromSource,
|
|
2117
|
+
readSourceInstall,
|
|
2118
|
+
remoteSha,
|
|
2119
|
+
rebuildFromSource,
|
|
2120
|
+
readInstallMethod,
|
|
2121
|
+
hasDockerCompose,
|
|
2122
|
+
dockerInstallPreview,
|
|
2123
|
+
ensureDocker,
|
|
2124
|
+
composeSecretRotationRisk,
|
|
2125
|
+
renderSecretRotationRefusal,
|
|
2126
|
+
composePgDataRisk,
|
|
2127
|
+
renderPgDataRefusal,
|
|
2128
|
+
composeTrustedOriginUrls,
|
|
2129
|
+
resolveComposePorts,
|
|
2130
|
+
sourceBuildDir,
|
|
2131
|
+
composePlan,
|
|
2132
|
+
pinnedImagesReady,
|
|
2133
|
+
composePrefetch,
|
|
2134
|
+
composeUp,
|
|
2135
|
+
composeDown,
|
|
2136
|
+
composeUninstall,
|
|
2137
|
+
composeUpdate,
|
|
2138
|
+
composePs,
|
|
2139
|
+
composeRunning,
|
|
2140
|
+
composeStart,
|
|
2141
|
+
composeRestart,
|
|
2142
|
+
composeInternalToken,
|
|
2143
|
+
checkHostChannel,
|
|
2144
|
+
verifyHostChannel
|
|
2145
|
+
};
|