openship 0.2.3 → 0.4.0
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/README.md +104 -61
- package/dist/bare-ITM45QBX.js +18 -0
- package/dist/bowser-MFQA6OS4.js +2821 -0
- package/dist/chunk-3U4SPZD5.js +241 -0
- package/dist/chunk-46YJFP4S.js +1771 -0
- package/dist/chunk-4CQWT2WL.js +116 -0
- package/dist/chunk-4FABQGQP.js +23 -0
- package/dist/chunk-4LEL3H2Z.js +84 -0
- package/dist/chunk-4MG3BVHE.js +326 -0
- package/dist/chunk-4VNS5WPM.js +42 -0
- package/dist/chunk-5N7VUGL6.js +2094 -0
- package/dist/chunk-5ZCKQ2MI.js +1360 -0
- package/dist/chunk-6ERIMTBC.js +1412 -0
- package/dist/chunk-7UOB5GGQ.js +132 -0
- package/dist/chunk-BUBSSKO6.js +1384 -0
- package/dist/chunk-E6W2JMMU.js +341 -0
- package/dist/chunk-E77LJWJK.js +1535 -0
- package/dist/chunk-F4DFVG6Q.js +573 -0
- package/dist/chunk-HFYDYK4D.js +1092 -0
- package/dist/chunk-JR2OCMJA.js +46 -0
- package/dist/chunk-JUJBBF5U.js +746 -0
- package/dist/chunk-JZLNHGLW.js +19145 -0
- package/dist/chunk-KD7VO4LG.js +2617 -0
- package/dist/chunk-LBFH7THC.js +435 -0
- package/dist/chunk-NB2VEIWG.js +129 -0
- package/dist/chunk-OJE6YWPQ.js +2866 -0
- package/dist/chunk-QKFRZNKN.js +4941 -0
- package/dist/chunk-RBZPFTHV.js +13354 -0
- package/dist/chunk-S6DSRLNE.js +121 -0
- package/dist/chunk-TEYJYSMT.js +409 -0
- package/dist/chunk-U2DPZSXA.js +112 -0
- package/dist/chunk-WJPCBU7Z.js +3147 -0
- package/dist/chunk-XIFRMKYT.js +13 -0
- package/dist/chunk-XVSAUO2I.js +7946 -0
- package/dist/chunk-Y37KHU7L.js +31 -0
- package/dist/chunk-YL67EWYZ.js +416 -0
- package/dist/chunk-ZKJOFWBJ.js +1879 -0
- package/dist/cloud-JMPHO3CW.js +7 -0
- package/dist/cloud-W5G6YXAY.js +24 -0
- package/dist/dist-L3MPOWCA.js +65 -0
- package/dist/dist-es-4C322O5D.js +22 -0
- package/dist/dist-es-GTAMF674.js +87 -0
- package/dist/dist-es-KE56MWYO.js +169 -0
- package/dist/dist-es-NRZIQY5B.js +484 -0
- package/dist/dist-es-WZCR7DHQ.js +317 -0
- package/dist/dist-es-XQCOTGC7.js +376 -0
- package/dist/dist-es-ZW3HFQUM.js +69 -0
- package/dist/docker-3FVJMFC3.js +12 -0
- package/dist/docker-edge-executor-ZIWAZWPL.js +9 -0
- package/dist/edge-import-53M5DGWZ.js +39 -0
- package/dist/event-streams-7LGDL56D.js +42 -0
- package/dist/executor-J7BHW5HF.js +26 -0
- package/dist/index.js +2459 -3461
- package/dist/loadSso-N273C5I7.js +577 -0
- package/dist/nginx-YZWKYKQU.js +16 -0
- package/dist/noop-RRNCIUI5.js +7 -0
- package/dist/openresty-lua-HAO4EUDY.js +32 -0
- package/dist/server/index.js +217916 -205055
- package/dist/server/migrations/0057_complete_prism.sql +18 -0
- package/dist/server/migrations/0058_add_server_is_local.sql +1 -0
- package/dist/server/migrations/0059_legal_fixer.sql +18 -0
- package/dist/server/migrations/0060_add_route_strategy_host_port.sql +3 -0
- package/dist/server/migrations/0061_notification_default_multi_kind.sql +3 -0
- package/dist/server/migrations/0062_add_incoming_webhook.sql +21 -0
- package/dist/server/migrations/0063_add_migration_logs.sql +1 -0
- package/dist/server/migrations/0064_add_migration_partial.sql +2 -0
- package/dist/server/migrations/0065_custom_app_template.sql +14 -0
- package/dist/server/migrations/0066_ensure_migration_partial_columns.sql +2 -0
- package/dist/server/migrations/0067_webhook_delivery.sql +28 -0
- package/dist/server/migrations/0068_add_migration_target_volumes.sql +1 -0
- package/dist/server/migrations/0069_add_project_composite_routes.sql +1 -0
- package/dist/server/migrations/0070_forward_git_to_server.sql +1 -0
- package/dist/server/migrations/meta/0057_snapshot.json +9326 -0
- package/dist/server/migrations/meta/0058_snapshot.json +9333 -0
- package/dist/server/migrations/meta/0059_snapshot.json +9495 -0
- package/dist/server/migrations/meta/0060_snapshot.json +9515 -0
- package/dist/server/migrations/meta/_journal.json +99 -1
- package/dist/setup-5X3WEEHA.js +14 -0
- package/dist/signin-DR462ERO.js +663 -0
- package/dist/sso-oidc-VBZLJUK5.js +830 -0
- package/dist/sts-EJLZ4Z77.js +1422 -0
- package/package.json +6 -2
|
@@ -0,0 +1,2617 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDockerBuildContext,
|
|
3
|
+
generateDockerfile,
|
|
4
|
+
prepareSourceTree,
|
|
5
|
+
resolveDockerfileCandidates,
|
|
6
|
+
resolveServiceDockerfile
|
|
7
|
+
} from "./chunk-HFYDYK4D.js";
|
|
8
|
+
import {
|
|
9
|
+
BuildLogger,
|
|
10
|
+
assembleGitClone,
|
|
11
|
+
materializeGitSsh,
|
|
12
|
+
parseLogLevel,
|
|
13
|
+
shellGitSshWriter,
|
|
14
|
+
sq,
|
|
15
|
+
transferLocalDirectory
|
|
16
|
+
} from "./chunk-YL67EWYZ.js";
|
|
17
|
+
import {
|
|
18
|
+
connectSshClient,
|
|
19
|
+
execSshCommand,
|
|
20
|
+
openSshExecChannel,
|
|
21
|
+
openSshUnixSocket
|
|
22
|
+
} from "./chunk-4MG3BVHE.js";
|
|
23
|
+
import {
|
|
24
|
+
isRuntimeNotFoundError
|
|
25
|
+
} from "./chunk-4LEL3H2Z.js";
|
|
26
|
+
import {
|
|
27
|
+
safeErrorMessage,
|
|
28
|
+
withTimeout
|
|
29
|
+
} from "./chunk-JZLNHGLW.js";
|
|
30
|
+
|
|
31
|
+
// ../../packages/adapters/src/runtime/docker.ts
|
|
32
|
+
import Dockerode from "dockerode";
|
|
33
|
+
import { PassThrough, Writable } from "stream";
|
|
34
|
+
|
|
35
|
+
// ../../packages/adapters/src/runtime/source-tarball.ts
|
|
36
|
+
function githubTarballUrl(repoUrl, ref) {
|
|
37
|
+
if (!repoUrl || !ref) return null;
|
|
38
|
+
const m = repoUrl.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/i);
|
|
39
|
+
if (!m) return null;
|
|
40
|
+
const [, owner, repo] = m;
|
|
41
|
+
if (!owner || !repo) return null;
|
|
42
|
+
return `https://api.github.com/repos/${owner}/${repo}/tarball/${ref}`;
|
|
43
|
+
}
|
|
44
|
+
async function downloadTarballOnRemote(executor, opts) {
|
|
45
|
+
const dest = sq(opts.destDir);
|
|
46
|
+
const tmp = sq(`${opts.destDir}.opsh-src.tar.gz`);
|
|
47
|
+
const authExport = opts.token ? `export OPSH_GH_TOK=${sq(opts.token)}; ` : "";
|
|
48
|
+
const authHeader = opts.token ? `-H "Authorization: Bearer $OPSH_GH_TOK" ` : "";
|
|
49
|
+
const cmd = `rm -rf ${dest} ${tmp} && mkdir -p ${dest} && ${authExport}curl -fSL --retry 3 --retry-delay 2 ${authHeader}-o ${tmp} ${sq(opts.url)} && tar -xzf ${tmp} --strip-components=1 -C ${dest} && rm -f ${tmp}`;
|
|
50
|
+
const { code } = await executor.streamExec(cmd, (entry) => opts.onLog?.(entry));
|
|
51
|
+
if (code !== 0) {
|
|
52
|
+
throw new Error(`tarball download exited with code ${code}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ../../packages/adapters/src/runtime/volume-namespace.ts
|
|
57
|
+
var MODE_SUFFIX = /:(ro|rw|z|Z|nocopy)$/;
|
|
58
|
+
function scopedVolumeName(slug, name) {
|
|
59
|
+
return `openship-${slug}-${name}`;
|
|
60
|
+
}
|
|
61
|
+
function isHostPathSource(source) {
|
|
62
|
+
return source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.startsWith("~");
|
|
63
|
+
}
|
|
64
|
+
function scopeVolumeBinds(slug, rawVolumes, enabled) {
|
|
65
|
+
if (!enabled) return rawVolumes;
|
|
66
|
+
const prefix = `openship-${slug}-`;
|
|
67
|
+
return rawVolumes.map((spec) => {
|
|
68
|
+
const modeMatch = spec.match(MODE_SUFFIX);
|
|
69
|
+
const mode = modeMatch ? modeMatch[0] : "";
|
|
70
|
+
const body = mode ? spec.slice(0, -mode.length) : spec;
|
|
71
|
+
const parts = body.split(":");
|
|
72
|
+
if (parts.length < 2) return spec;
|
|
73
|
+
const source = parts[0];
|
|
74
|
+
if (isHostPathSource(source) || source.startsWith(prefix)) return spec;
|
|
75
|
+
parts[0] = scopedVolumeName(slug, source);
|
|
76
|
+
return parts.join(":") + mode;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ../../packages/adapters/src/runtime/docker-ssh-agent.ts
|
|
81
|
+
import net from "net";
|
|
82
|
+
var DEFAULT_REMOTE_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
|
|
83
|
+
var resolvedDockerSocketPathCache = /* @__PURE__ */ new WeakMap();
|
|
84
|
+
var DOCKER_DIAL_STDIO_COMMAND = "docker system dial-stdio";
|
|
85
|
+
var STREAMLOCAL_PROBE_TIMEOUT_MS = 8e3;
|
|
86
|
+
function toSshConfig(opts) {
|
|
87
|
+
return {
|
|
88
|
+
host: opts.host ?? "",
|
|
89
|
+
port: opts.port ?? 22,
|
|
90
|
+
username: opts.username,
|
|
91
|
+
hostVerifier: opts.hostVerifier,
|
|
92
|
+
password: opts.password,
|
|
93
|
+
privateKey: opts.privateKey,
|
|
94
|
+
privateKeyPassphrase: opts.privateKeyPassphrase,
|
|
95
|
+
sshAgent: opts.sshAgent
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function getConfiguredDockerSocketPath(opts) {
|
|
99
|
+
const socketPath = opts.dockerSocketPath?.trim();
|
|
100
|
+
return socketPath ? socketPath : null;
|
|
101
|
+
}
|
|
102
|
+
function normalizeSocketPathLines(lines) {
|
|
103
|
+
const seen = /* @__PURE__ */ new Set();
|
|
104
|
+
const normalized = [];
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
const socketPath = line.trim();
|
|
107
|
+
if (!socketPath.startsWith("/")) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (seen.has(socketPath)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
seen.add(socketPath);
|
|
114
|
+
normalized.push(socketPath);
|
|
115
|
+
}
|
|
116
|
+
return normalized;
|
|
117
|
+
}
|
|
118
|
+
var DOCKER_SOCKET_DISCOVERY_SCRIPT = [
|
|
119
|
+
"set -eu",
|
|
120
|
+
'uid="$(id -u 2>/dev/null || printf 0)"',
|
|
121
|
+
'printf "%s\\n" "/var/run/docker.sock" "/run/docker.sock" "/run/podman/podman.sock" "/run/user/$uid/docker.sock" "$HOME/.docker/run/docker.sock" | while IFS= read -r candidate; do if [ -S "$candidate" ]; then printf "%s\\n" "$candidate"; fi; done',
|
|
122
|
+
"find /run/user -maxdepth 2 -type s \\( -name docker.sock -o -name podman.sock \\) -print 2>/dev/null || true",
|
|
123
|
+
'for dir in /run /var/run "$HOME/.docker/run"; do',
|
|
124
|
+
' if [ -d "$dir" ]; then',
|
|
125
|
+
' find "$dir" -maxdepth 3 -type s \\( -name docker.sock -o -name podman.sock \\) -print 2>/dev/null || true',
|
|
126
|
+
" fi",
|
|
127
|
+
"done"
|
|
128
|
+
].join("\n");
|
|
129
|
+
async function discoverRemoteDockerSocketPathsWithClient(client) {
|
|
130
|
+
const result = await execSshCommand(client, DOCKER_SOCKET_DISCOVERY_SCRIPT);
|
|
131
|
+
const lines = [result.stdout, result.stderr].filter(Boolean).flatMap((text) => text.split(/\r?\n/)).map((line) => line.trim()).filter(Boolean);
|
|
132
|
+
return normalizeSocketPathLines(lines);
|
|
133
|
+
}
|
|
134
|
+
async function discoverRemoteDockerSocketPathsWithExecutor(executor) {
|
|
135
|
+
try {
|
|
136
|
+
const output = await executor.exec(DOCKER_SOCKET_DISCOVERY_SCRIPT, { timeout: 1e4 });
|
|
137
|
+
return normalizeSocketPathLines(output.split(/\r?\n/));
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function discoverRemoteDockerSocketPaths(opts) {
|
|
143
|
+
if (opts.executor) {
|
|
144
|
+
return discoverRemoteDockerSocketPathsWithExecutor(opts.executor);
|
|
145
|
+
}
|
|
146
|
+
let conn = null;
|
|
147
|
+
try {
|
|
148
|
+
conn = await connectSshClient(toSshConfig(opts));
|
|
149
|
+
return await discoverRemoteDockerSocketPathsWithClient(conn);
|
|
150
|
+
} finally {
|
|
151
|
+
conn?.end();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function resolveRemoteDockerSocketPath(opts) {
|
|
155
|
+
const configuredSocketPath = getConfiguredDockerSocketPath(opts);
|
|
156
|
+
if (configuredSocketPath) {
|
|
157
|
+
return configuredSocketPath;
|
|
158
|
+
}
|
|
159
|
+
const cachedPath = resolvedDockerSocketPathCache.get(opts);
|
|
160
|
+
if (cachedPath) {
|
|
161
|
+
return cachedPath;
|
|
162
|
+
}
|
|
163
|
+
const pendingPath = discoverRemoteDockerSocketPaths(opts).then((paths) => paths[0] ?? DEFAULT_REMOTE_DOCKER_SOCKET_PATH).catch(() => DEFAULT_REMOTE_DOCKER_SOCKET_PATH);
|
|
164
|
+
resolvedDockerSocketPathCache.set(opts, pendingPath);
|
|
165
|
+
return pendingPath;
|
|
166
|
+
}
|
|
167
|
+
async function openStreamlocalUpstream(opts) {
|
|
168
|
+
const socketPath = await resolveRemoteDockerSocketPath(opts);
|
|
169
|
+
if (opts.executor?.forwardUnixSocket) {
|
|
170
|
+
return opts.executor.forwardUnixSocket(socketPath);
|
|
171
|
+
}
|
|
172
|
+
const client = await connectSshClient(toSshConfig(opts));
|
|
173
|
+
try {
|
|
174
|
+
const channel = await openSshUnixSocket(client, socketPath);
|
|
175
|
+
channel.once("close", () => client.end());
|
|
176
|
+
channel.on("error", () => client.end());
|
|
177
|
+
return channel;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
client.end();
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function createDockerSshBridge(opts) {
|
|
184
|
+
const clients = /* @__PURE__ */ new Set();
|
|
185
|
+
let upstreamMode = null;
|
|
186
|
+
let modeDecision = null;
|
|
187
|
+
let dialClient = null;
|
|
188
|
+
const openDialStdioUpstream = async () => {
|
|
189
|
+
if (opts.executor?.openDockerDialStdio) {
|
|
190
|
+
const stream = await opts.executor.openDockerDialStdio();
|
|
191
|
+
stream.once(
|
|
192
|
+
"error",
|
|
193
|
+
(e) => console.warn(`[docker-ssh] dial-stdio channel error: ${safeErrorMessage(e)}`)
|
|
194
|
+
);
|
|
195
|
+
return stream;
|
|
196
|
+
}
|
|
197
|
+
if (!dialClient) {
|
|
198
|
+
const client = await connectSshClient(toSshConfig(opts));
|
|
199
|
+
client.once("close", () => {
|
|
200
|
+
if (dialClient === client) dialClient = null;
|
|
201
|
+
});
|
|
202
|
+
dialClient = client;
|
|
203
|
+
}
|
|
204
|
+
return openSshExecChannel(dialClient, DOCKER_DIAL_STDIO_COMMAND);
|
|
205
|
+
};
|
|
206
|
+
const decideMode = async () => {
|
|
207
|
+
let probe = null;
|
|
208
|
+
try {
|
|
209
|
+
probe = await withTimeout(
|
|
210
|
+
openStreamlocalUpstream(opts),
|
|
211
|
+
STREAMLOCAL_PROBE_TIMEOUT_MS,
|
|
212
|
+
`streamlocal open timed out after ${STREAMLOCAL_PROBE_TIMEOUT_MS / 1e3}s`
|
|
213
|
+
);
|
|
214
|
+
const stream = probe;
|
|
215
|
+
const flowed = await new Promise((resolve) => {
|
|
216
|
+
const timer = setTimeout(() => resolve(false), STREAMLOCAL_PROBE_TIMEOUT_MS);
|
|
217
|
+
const settle = (ok) => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
resolve(ok);
|
|
220
|
+
};
|
|
221
|
+
stream.once("data", () => settle(true));
|
|
222
|
+
stream.once("error", () => settle(false));
|
|
223
|
+
stream.once("close", () => settle(false));
|
|
224
|
+
try {
|
|
225
|
+
stream.write("GET /_ping HTTP/1.0\r\nHost: localhost\r\n\r\n");
|
|
226
|
+
} catch {
|
|
227
|
+
settle(false);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
if (flowed) {
|
|
231
|
+
console.log(`[docker-ssh] upstream=streamlocal (${opts.host ?? "?"})`);
|
|
232
|
+
return "streamlocal";
|
|
233
|
+
}
|
|
234
|
+
console.warn(
|
|
235
|
+
`[docker-ssh] upstream=dial-stdio (${opts.host ?? "?"}) \u2014 streamlocal opened but no data flowed (Bun/ssh2)`
|
|
236
|
+
);
|
|
237
|
+
return "dialstdio";
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.warn(
|
|
240
|
+
`[docker-ssh] upstream=dial-stdio (${opts.host ?? "?"}) \u2014 streamlocal unavailable: ${safeErrorMessage(err)}`
|
|
241
|
+
);
|
|
242
|
+
return "dialstdio";
|
|
243
|
+
} finally {
|
|
244
|
+
probe?.destroy();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const ensureMode = () => {
|
|
248
|
+
if (upstreamMode) return Promise.resolve(upstreamMode);
|
|
249
|
+
modeDecision ??= decideMode().then((mode) => upstreamMode = mode);
|
|
250
|
+
return modeDecision;
|
|
251
|
+
};
|
|
252
|
+
const openUpstream = async () => {
|
|
253
|
+
const mode = await ensureMode();
|
|
254
|
+
return mode === "dialstdio" ? openDialStdioUpstream() : openStreamlocalUpstream(opts);
|
|
255
|
+
};
|
|
256
|
+
const server = net.createServer((client) => {
|
|
257
|
+
clients.add(client);
|
|
258
|
+
client.setNoDelay(true);
|
|
259
|
+
client.once("close", () => clients.delete(client));
|
|
260
|
+
openUpstream().then((upstream) => {
|
|
261
|
+
const teardown = () => {
|
|
262
|
+
client.destroy();
|
|
263
|
+
upstream.destroy();
|
|
264
|
+
};
|
|
265
|
+
client.on("error", teardown);
|
|
266
|
+
upstream.on("error", teardown);
|
|
267
|
+
client.once("close", () => upstream.destroy());
|
|
268
|
+
upstream.once("close", () => client.destroy());
|
|
269
|
+
client.pipe(upstream);
|
|
270
|
+
upstream.pipe(client);
|
|
271
|
+
}).catch((error) => {
|
|
272
|
+
console.warn(`[docker-ssh] bridge upstream open failed: ${safeErrorMessage(error)}`);
|
|
273
|
+
client.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
start: () => new Promise((resolve, reject) => {
|
|
278
|
+
const onError = (error) => reject(error);
|
|
279
|
+
server.once("error", onError);
|
|
280
|
+
server.listen(0, "127.0.0.1", () => {
|
|
281
|
+
server.removeListener("error", onError);
|
|
282
|
+
const address = server.address();
|
|
283
|
+
if (address === null || typeof address === "string") {
|
|
284
|
+
reject(new Error("Docker SSH bridge failed to bind a loopback TCP port."));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
resolve({ host: "127.0.0.1", port: address.port });
|
|
288
|
+
});
|
|
289
|
+
}),
|
|
290
|
+
probe: async () => {
|
|
291
|
+
await ensureMode();
|
|
292
|
+
},
|
|
293
|
+
close: () => {
|
|
294
|
+
for (const client of clients) {
|
|
295
|
+
client.destroy();
|
|
296
|
+
}
|
|
297
|
+
clients.clear();
|
|
298
|
+
dialClient?.end();
|
|
299
|
+
dialClient = null;
|
|
300
|
+
server.close();
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ../../packages/adapters/src/runtime/docker-transport.ts
|
|
306
|
+
function resolveDockerTransport(opts) {
|
|
307
|
+
if (!opts || !opts.transport || opts.transport === "socket") {
|
|
308
|
+
return {
|
|
309
|
+
kind: "socket",
|
|
310
|
+
description: "local Docker daemon via socket",
|
|
311
|
+
unreachableHint: "Check that the local Docker daemon is running.",
|
|
312
|
+
establish: async () => ({ socketPath: "/var/run/docker.sock" }),
|
|
313
|
+
close: async () => {
|
|
314
|
+
},
|
|
315
|
+
preflight: async () => {
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (opts.transport === "ssh") {
|
|
320
|
+
if (!opts.privateKey && !opts.sshAgent && !opts.password) {
|
|
321
|
+
throw new Error("SSH transport requires one of privateKey, sshAgent, or password.");
|
|
322
|
+
}
|
|
323
|
+
let bridge = null;
|
|
324
|
+
return {
|
|
325
|
+
kind: "ssh",
|
|
326
|
+
description: `remote Docker daemon via SSH (${opts.host ?? "unknown-host"})`,
|
|
327
|
+
unreachableHint: "Check that SSH credentials are correct, Docker is running on the server, and the SSH user can access the Docker daemon (in the `docker` group or root). The transport uses SSH socket forwarding where available and falls back to `docker system dial-stdio` over an exec channel otherwise.",
|
|
328
|
+
establish: async () => {
|
|
329
|
+
bridge = createDockerSshBridge(opts);
|
|
330
|
+
const { host, port } = await bridge.start();
|
|
331
|
+
return {
|
|
332
|
+
protocol: "http",
|
|
333
|
+
host,
|
|
334
|
+
port,
|
|
335
|
+
timeout: opts.timeout ?? 6e5
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
close: async () => {
|
|
339
|
+
bridge?.close();
|
|
340
|
+
bridge = null;
|
|
341
|
+
},
|
|
342
|
+
// Decide the upstream transport (streamlocal vs dial-stdio) up front so
|
|
343
|
+
// the daemon `ping()` in assertReachable runs over the working one. The
|
|
344
|
+
// ping itself is the real reachability check.
|
|
345
|
+
preflight: async () => {
|
|
346
|
+
await bridge?.probe();
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (!opts.ca || !opts.cert || !opts.key) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
"TCP transport requires ca, cert, and key for mutual TLS. Plaintext TCP connections are not supported for security reasons."
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
kind: "tcp",
|
|
357
|
+
description: `remote Docker daemon via TLS (${opts.host ?? "unknown-host"}:${opts.port ?? 2376})`,
|
|
358
|
+
unreachableHint: "Check that the remote Docker daemon is reachable and the TLS certificates are valid.",
|
|
359
|
+
establish: async () => ({
|
|
360
|
+
protocol: "https",
|
|
361
|
+
host: opts.host,
|
|
362
|
+
port: opts.port ?? 2376,
|
|
363
|
+
ca: opts.ca,
|
|
364
|
+
cert: opts.cert,
|
|
365
|
+
key: opts.key,
|
|
366
|
+
timeout: opts.timeout ?? 3e4
|
|
367
|
+
}),
|
|
368
|
+
close: async () => {
|
|
369
|
+
},
|
|
370
|
+
preflight: async () => {
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ../../packages/adapters/src/runtime/docker.ts
|
|
376
|
+
var isDockerNotFoundError = isRuntimeNotFoundError;
|
|
377
|
+
function clampShellWindow(value, fallback, min, max) {
|
|
378
|
+
const n = Number.isFinite(value) ? Number(value) : fallback;
|
|
379
|
+
if (n < min) return min;
|
|
380
|
+
if (n > max) return max;
|
|
381
|
+
return Math.floor(n);
|
|
382
|
+
}
|
|
383
|
+
var RESTART_POLICIES = {
|
|
384
|
+
always: { Name: "always", MaximumRetryCount: 0 },
|
|
385
|
+
"on-failure": { Name: "on-failure", MaximumRetryCount: 5 },
|
|
386
|
+
"unless-stopped": { Name: "unless-stopped", MaximumRetryCount: 0 },
|
|
387
|
+
no: { Name: "no", MaximumRetryCount: 0 }
|
|
388
|
+
};
|
|
389
|
+
var DOCKER_BUILD_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
390
|
+
function resolveRestartPolicy(policy) {
|
|
391
|
+
return RESTART_POLICIES[policy ?? "always"] ?? RESTART_POLICIES.always;
|
|
392
|
+
}
|
|
393
|
+
function normalizeDockerMount(m) {
|
|
394
|
+
return {
|
|
395
|
+
type: m.Type ?? "volume",
|
|
396
|
+
...m.Name ? { name: m.Name } : {},
|
|
397
|
+
...m.Source ? { source: m.Source } : {},
|
|
398
|
+
destination: m.Destination ?? "",
|
|
399
|
+
rw: m.RW !== false
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function toStringArray(v) {
|
|
403
|
+
if (v == null) return void 0;
|
|
404
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
405
|
+
return arr.length > 0 ? arr : void 0;
|
|
406
|
+
}
|
|
407
|
+
function normalizeInspectPorts(data) {
|
|
408
|
+
const out = [];
|
|
409
|
+
const seen = /* @__PURE__ */ new Set();
|
|
410
|
+
const parseKey = (key) => {
|
|
411
|
+
const [portStr, proto] = key.split("/");
|
|
412
|
+
const port = Number(portStr);
|
|
413
|
+
return Number.isFinite(port) ? { port, proto: proto || "tcp" } : null;
|
|
414
|
+
};
|
|
415
|
+
const nsPorts = data.NetworkSettings?.Ports ?? {};
|
|
416
|
+
for (const [key, bindings] of Object.entries(nsPorts)) {
|
|
417
|
+
const parsed = parseKey(key);
|
|
418
|
+
if (!parsed) continue;
|
|
419
|
+
seen.add(key);
|
|
420
|
+
if (bindings && bindings.length > 0) {
|
|
421
|
+
for (const b of bindings) {
|
|
422
|
+
out.push({
|
|
423
|
+
privatePort: parsed.port,
|
|
424
|
+
...b.HostPort ? { publicPort: Number(b.HostPort) } : {},
|
|
425
|
+
type: parsed.proto,
|
|
426
|
+
...b.HostIp ? { ip: b.HostIp } : {}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
out.push({ privatePort: parsed.port, type: parsed.proto });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const hostBindings = data.HostConfig?.PortBindings ?? {};
|
|
434
|
+
for (const [key, bindings] of Object.entries(hostBindings)) {
|
|
435
|
+
if (seen.has(key)) continue;
|
|
436
|
+
const parsed = parseKey(key);
|
|
437
|
+
if (!parsed) continue;
|
|
438
|
+
seen.add(key);
|
|
439
|
+
if (bindings && bindings.length > 0) {
|
|
440
|
+
for (const b of bindings) {
|
|
441
|
+
out.push({
|
|
442
|
+
privatePort: parsed.port,
|
|
443
|
+
...b.HostPort ? { publicPort: Number(b.HostPort) } : {},
|
|
444
|
+
type: parsed.proto,
|
|
445
|
+
...b.HostIp ? { ip: b.HostIp } : {}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
out.push({ privatePort: parsed.port, type: parsed.proto });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const exposed = data.Config?.ExposedPorts ?? {};
|
|
453
|
+
for (const key of Object.keys(exposed)) {
|
|
454
|
+
if (seen.has(key)) continue;
|
|
455
|
+
const parsed = parseKey(key);
|
|
456
|
+
if (parsed) out.push({ privatePort: parsed.port, type: parsed.proto });
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
function parsePortBindings(portSpecs) {
|
|
461
|
+
const exposedPorts = {};
|
|
462
|
+
const portBindings = {};
|
|
463
|
+
for (const spec of portSpecs) {
|
|
464
|
+
const slashIdx = spec.lastIndexOf("/");
|
|
465
|
+
const rawProto = slashIdx >= 0 ? spec.slice(slashIdx + 1).toLowerCase() : "";
|
|
466
|
+
const protocol = rawProto === "udp" || rawProto === "sctp" ? rawProto : "tcp";
|
|
467
|
+
const mapping = slashIdx >= 0 ? spec.slice(0, slashIdx) : spec;
|
|
468
|
+
const parts = mapping.split(":");
|
|
469
|
+
if (parts.length === 1) {
|
|
470
|
+
const key = `${parts[0]}/${protocol}`;
|
|
471
|
+
exposedPorts[key] = {};
|
|
472
|
+
portBindings[key] = [{ HostPort: "" }];
|
|
473
|
+
} else {
|
|
474
|
+
const containerPort = parts[parts.length - 1];
|
|
475
|
+
const hostPort = parts[parts.length - 2];
|
|
476
|
+
const hostIp = parts.length > 2 ? parts.slice(0, -2).join(":") : void 0;
|
|
477
|
+
const key = `${containerPort}/${protocol}`;
|
|
478
|
+
exposedPorts[key] = {};
|
|
479
|
+
portBindings[key] = [hostIp ? { HostIp: hostIp, HostPort: hostPort } : { HostPort: hostPort }];
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return { exposedPorts, portBindings };
|
|
483
|
+
}
|
|
484
|
+
var DURATION_UNITS_NS = {
|
|
485
|
+
ns: 1,
|
|
486
|
+
us: 1e3,
|
|
487
|
+
"\xB5s": 1e3,
|
|
488
|
+
ms: 1e6,
|
|
489
|
+
s: 1e9,
|
|
490
|
+
m: 6e10,
|
|
491
|
+
h: 36e11
|
|
492
|
+
};
|
|
493
|
+
function parseDurationNs(value) {
|
|
494
|
+
if (value == null) return void 0;
|
|
495
|
+
const str = String(value).trim();
|
|
496
|
+
if (!str) return void 0;
|
|
497
|
+
if (/^\d+(\.\d+)?$/.test(str)) return Math.round(parseFloat(str) * 1e9);
|
|
498
|
+
const re = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g;
|
|
499
|
+
let total = 0;
|
|
500
|
+
let matched = false;
|
|
501
|
+
let m;
|
|
502
|
+
while ((m = re.exec(str)) !== null) {
|
|
503
|
+
matched = true;
|
|
504
|
+
total += parseFloat(m[1]) * DURATION_UNITS_NS[m[2]];
|
|
505
|
+
}
|
|
506
|
+
return matched ? Math.round(total) : void 0;
|
|
507
|
+
}
|
|
508
|
+
function toDockerHealthcheck(hc) {
|
|
509
|
+
if (!hc) return void 0;
|
|
510
|
+
if (hc.disable) return { Test: ["NONE"] };
|
|
511
|
+
let Test;
|
|
512
|
+
if (typeof hc.test === "string" && hc.test.trim()) {
|
|
513
|
+
Test = ["CMD-SHELL", hc.test];
|
|
514
|
+
} else if (Array.isArray(hc.test) && hc.test.length > 0) {
|
|
515
|
+
Test = ["CMD", ...hc.test];
|
|
516
|
+
}
|
|
517
|
+
if (!Test) return void 0;
|
|
518
|
+
const Interval = parseDurationNs(hc.interval);
|
|
519
|
+
const Timeout = parseDurationNs(hc.timeout);
|
|
520
|
+
const StartPeriod = parseDurationNs(hc.startPeriod);
|
|
521
|
+
return {
|
|
522
|
+
Test,
|
|
523
|
+
...Interval !== void 0 && { Interval },
|
|
524
|
+
...Timeout !== void 0 && { Timeout },
|
|
525
|
+
...typeof hc.retries === "number" && { Retries: hc.retries },
|
|
526
|
+
...StartPeriod !== void 0 && { StartPeriod }
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function hasDockerFrameHeader(buf, offset = 0) {
|
|
530
|
+
return buf.length >= offset + 8 && (buf[offset] === 1 || buf[offset] === 2) && buf[offset + 1] === 0 && buf[offset + 2] === 0 && buf[offset + 3] === 0;
|
|
531
|
+
}
|
|
532
|
+
function stripDockerHeaders(buf) {
|
|
533
|
+
const lines = [];
|
|
534
|
+
let offset = 0;
|
|
535
|
+
while (offset < buf.length) {
|
|
536
|
+
if (hasDockerFrameHeader(buf, offset)) {
|
|
537
|
+
const size = buf.readUInt32BE(offset + 4);
|
|
538
|
+
lines.push(buf.subarray(offset + 8, offset + 8 + size).toString("utf-8"));
|
|
539
|
+
offset += 8 + size;
|
|
540
|
+
} else {
|
|
541
|
+
lines.push(buf.subarray(offset).toString("utf-8"));
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return lines.join("");
|
|
546
|
+
}
|
|
547
|
+
function stripDockerChunkHeader(chunk) {
|
|
548
|
+
return hasDockerFrameHeader(chunk) ? chunk.subarray(8) : chunk;
|
|
549
|
+
}
|
|
550
|
+
function parseTimestampedLine(line) {
|
|
551
|
+
const spaceIdx = line.indexOf(" ");
|
|
552
|
+
return {
|
|
553
|
+
timestamp: spaceIdx > 0 ? line.slice(0, spaceIdx) : (/* @__PURE__ */ new Date()).toISOString(),
|
|
554
|
+
message: spaceIdx > 0 ? line.slice(spaceIdx + 1) : line
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function firstNetworkIp(networks) {
|
|
558
|
+
for (const net2 of Object.values(networks ?? {})) {
|
|
559
|
+
if (net2?.IPAddress) return net2.IPAddress;
|
|
560
|
+
}
|
|
561
|
+
return void 0;
|
|
562
|
+
}
|
|
563
|
+
function extractNetworkInfo(data) {
|
|
564
|
+
let ip;
|
|
565
|
+
for (const net2 of Object.values(data.NetworkSettings.Networks ?? {})) {
|
|
566
|
+
if (net2.IPAddress) {
|
|
567
|
+
ip = net2.IPAddress;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
let hostPort;
|
|
572
|
+
for (const bindings of Object.values(data.NetworkSettings.Ports ?? {})) {
|
|
573
|
+
if (bindings?.[0]?.HostPort) {
|
|
574
|
+
hostPort = parseInt(bindings[0].HostPort, 10);
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return { ip, hostPort };
|
|
579
|
+
}
|
|
580
|
+
function parseLoadedImageRef(output) {
|
|
581
|
+
const matches = [...(output || "").matchAll(/Loaded image(?: ID)?:\s*(\S+)/gi)];
|
|
582
|
+
return matches.length ? matches[matches.length - 1][1].trim() : void 0;
|
|
583
|
+
}
|
|
584
|
+
var DockerRuntime = class _DockerRuntime {
|
|
585
|
+
name = "docker";
|
|
586
|
+
capabilities = /* @__PURE__ */ new Set([
|
|
587
|
+
"build",
|
|
588
|
+
"deploy",
|
|
589
|
+
"multiServiceDeploy",
|
|
590
|
+
"stop",
|
|
591
|
+
"start",
|
|
592
|
+
"restart",
|
|
593
|
+
"destroy",
|
|
594
|
+
"containerInfo",
|
|
595
|
+
"runtimeLogs",
|
|
596
|
+
"streamLogs",
|
|
597
|
+
"usage",
|
|
598
|
+
"containerIp",
|
|
599
|
+
"rollback",
|
|
600
|
+
"serviceShell",
|
|
601
|
+
"projectContainerSweep",
|
|
602
|
+
"deploymentContainerQuery",
|
|
603
|
+
"hostContainerQuery"
|
|
604
|
+
]);
|
|
605
|
+
/** Docker honors every extended compose key we currently support. */
|
|
606
|
+
unsupportedComposeKeys = /* @__PURE__ */ new Set();
|
|
607
|
+
_docker;
|
|
608
|
+
/** Underlying dockerode instance - exposed for advanced usage */
|
|
609
|
+
get docker() {
|
|
610
|
+
return this._docker;
|
|
611
|
+
}
|
|
612
|
+
/** Connection config this runtime was created with */
|
|
613
|
+
connectionOptions;
|
|
614
|
+
/** Resolved transport - single switch point for socket / ssh / tcp */
|
|
615
|
+
transport;
|
|
616
|
+
systemManager;
|
|
617
|
+
provisionLock;
|
|
618
|
+
constructor(opts, systemManager, provisionLock) {
|
|
619
|
+
this.connectionOptions = opts;
|
|
620
|
+
this.transport = resolveDockerTransport(opts);
|
|
621
|
+
this.systemManager = systemManager ?? null;
|
|
622
|
+
this.provisionLock = provisionLock;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Build a runtime and stand up its transport. Async because the SSH
|
|
626
|
+
* transport binds a loopback bridge whose port is only known after listen();
|
|
627
|
+
* socket/TCP transports resolve their options synchronously.
|
|
628
|
+
*/
|
|
629
|
+
static async create(opts, systemManager, provisionLock) {
|
|
630
|
+
const runtime = new _DockerRuntime(opts, systemManager, provisionLock);
|
|
631
|
+
runtime._docker = new Dockerode(await runtime.transport.establish());
|
|
632
|
+
return runtime;
|
|
633
|
+
}
|
|
634
|
+
supports(cap) {
|
|
635
|
+
return this.capabilities.has(cap);
|
|
636
|
+
}
|
|
637
|
+
async dispose() {
|
|
638
|
+
await this.transport.close();
|
|
639
|
+
}
|
|
640
|
+
// ─── Health check ──────────────────────────────────────────────────
|
|
641
|
+
/** Ping the Docker daemon - useful for connection testing */
|
|
642
|
+
async ping() {
|
|
643
|
+
try {
|
|
644
|
+
await this.assertReachable();
|
|
645
|
+
return true;
|
|
646
|
+
} catch (err) {
|
|
647
|
+
console.warn(`[docker] daemon unreachable: ${safeErrorMessage(err)}`);
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Assert the Docker daemon is reachable, RETHROWING the underlying error
|
|
653
|
+
* instead of collapsing it to false. For the SSH transport, `preflight()`
|
|
654
|
+
* decides the upstream transport (SSH socket forwarding where it works, else
|
|
655
|
+
* `docker system dial-stdio` over an exec channel — the streamlocal-free path
|
|
656
|
+
* the Bun-compiled desktop needs), then `ping()` is the real end-to-end check
|
|
657
|
+
* over that transport. Use on user-facing paths (e.g. the migration scan) so
|
|
658
|
+
* the real cause reaches the user instead of a generic "not reachable".
|
|
659
|
+
*/
|
|
660
|
+
async assertReachable() {
|
|
661
|
+
await this.ensureDockerFeature();
|
|
662
|
+
await this.transport.preflight();
|
|
663
|
+
await this.docker.ping();
|
|
664
|
+
}
|
|
665
|
+
async ensureDockerFeature(logger) {
|
|
666
|
+
if (!this.systemManager) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
await this.systemManager.ensureFeature("deploy", (entry) => {
|
|
670
|
+
logger?.log(entry.message, entry.level);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
/** Get Docker daemon info (version, platform, etc.) */
|
|
674
|
+
async info() {
|
|
675
|
+
return this.docker.info();
|
|
676
|
+
}
|
|
677
|
+
// ── Image naming ────────────────────────────────────────────────────────
|
|
678
|
+
/** Canonical image tag for a build session. */
|
|
679
|
+
imageTag(slug, sessionId) {
|
|
680
|
+
const name = slug ? `openship/${slug}` : `openship/build`;
|
|
681
|
+
return `${name}:${sessionId}`;
|
|
682
|
+
}
|
|
683
|
+
/** Labels applied to both build images and deploy containers. */
|
|
684
|
+
labels(config) {
|
|
685
|
+
const l = {
|
|
686
|
+
"openship.project": config.projectId
|
|
687
|
+
};
|
|
688
|
+
if (config.deploymentId) l["openship.deployment"] = config.deploymentId;
|
|
689
|
+
if (config.sessionId) l["openship.build"] = config.sessionId;
|
|
690
|
+
return l;
|
|
691
|
+
}
|
|
692
|
+
// ── Build lifecycle ────────────────────────────────────────────────────
|
|
693
|
+
/**
|
|
694
|
+
* Sum the byte size of a directory tree. Best-effort - used only for a
|
|
695
|
+
* human-readable "X MB context streamed" log line. Returns 0 if the
|
|
696
|
+
* walk hits an error rather than failing the build.
|
|
697
|
+
*/
|
|
698
|
+
async estimateContextSize(dir) {
|
|
699
|
+
const { stat, readdir } = await import("fs/promises");
|
|
700
|
+
const { join } = await import("path");
|
|
701
|
+
let total = 0;
|
|
702
|
+
const stack = [dir];
|
|
703
|
+
while (stack.length) {
|
|
704
|
+
const current = stack.pop();
|
|
705
|
+
let entries;
|
|
706
|
+
try {
|
|
707
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
708
|
+
} catch {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
for (const entry of entries) {
|
|
712
|
+
const full = join(current, entry.name);
|
|
713
|
+
if (entry.isDirectory()) {
|
|
714
|
+
stack.push(full);
|
|
715
|
+
} else if (entry.isFile()) {
|
|
716
|
+
try {
|
|
717
|
+
const s = await stat(full);
|
|
718
|
+
total += s.size;
|
|
719
|
+
} catch {
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return total;
|
|
725
|
+
}
|
|
726
|
+
emitDockerStep(logger, step, status, message) {
|
|
727
|
+
const label = status === "running" ? "\u2192" : status === "completed" ? "\u2713" : "\u21B7";
|
|
728
|
+
logger.log(`[${step}] ${label} ${message}`);
|
|
729
|
+
logger.step(step, status, message);
|
|
730
|
+
}
|
|
731
|
+
handleBuildEvent(event, logger) {
|
|
732
|
+
const errorMessage = event.errorDetail?.message ?? event.error;
|
|
733
|
+
if (errorMessage) {
|
|
734
|
+
logger.log(errorMessage, "error");
|
|
735
|
+
return errorMessage;
|
|
736
|
+
}
|
|
737
|
+
if (event.stream) {
|
|
738
|
+
const line = event.stream.trim();
|
|
739
|
+
if (!line) return null;
|
|
740
|
+
const marker = line.match(
|
|
741
|
+
/^\[openship-build\]\s+step=(clone|install|build)\s+status=(running|completed|skipped)$/
|
|
742
|
+
);
|
|
743
|
+
if (marker) {
|
|
744
|
+
const [, step, status] = marker;
|
|
745
|
+
this.emitDockerStep(
|
|
746
|
+
logger,
|
|
747
|
+
step,
|
|
748
|
+
status,
|
|
749
|
+
line
|
|
750
|
+
);
|
|
751
|
+
if (status === "completed" && step === "install") {
|
|
752
|
+
logger.log("Finalizing image (layer commit + tag)...");
|
|
753
|
+
}
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
if (this.isLowSignalDockerLine(line)) {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
logger.log(line, parseLogLevel(line));
|
|
760
|
+
return this.extractBuildFailureHint(line);
|
|
761
|
+
}
|
|
762
|
+
if (event.status) {
|
|
763
|
+
const parts = [event.id, event.status, event.progress].filter((p) => Boolean(p?.trim())).map((p) => p.trim());
|
|
764
|
+
if (parts.length) logger.log(parts.join(" "));
|
|
765
|
+
}
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
// Only the truly-redundant lines get filtered. We KEEP "Step N/M : ..."
|
|
769
|
+
// because that's the user's best progress signal during a long build -
|
|
770
|
+
// it shows which Dockerfile instruction is currently executing.
|
|
771
|
+
//
|
|
772
|
+
// Removed (= now passes through to terminal):
|
|
773
|
+
// - "Step N/M : ..." → high-signal, shows progress
|
|
774
|
+
// - "Successfully built / tagged" → confirms success
|
|
775
|
+
//
|
|
776
|
+
// Still filtered (= noise):
|
|
777
|
+
// - "---> hash" → opaque layer hash, no signal for users
|
|
778
|
+
// - "Running in ..." → intermediate container id, no signal
|
|
779
|
+
// - "Removing intermediate container ..." → cleanup chatter
|
|
780
|
+
static DOCKER_BUILDER_NOISE = [
|
|
781
|
+
/^--->/i,
|
|
782
|
+
// ---> abc123def
|
|
783
|
+
/^Running in\s+[a-f0-9]{6,}$/i,
|
|
784
|
+
/^Removing intermediate container\s+[a-f0-9]{6,}$/i
|
|
785
|
+
];
|
|
786
|
+
isLowSignalDockerLine(line) {
|
|
787
|
+
return _DockerRuntime.DOCKER_BUILDER_NOISE.some((p) => p.test(line));
|
|
788
|
+
}
|
|
789
|
+
extractBuildFailureHint(line) {
|
|
790
|
+
if (/returned a non-zero code:\s*\d+/i.test(line)) {
|
|
791
|
+
return line;
|
|
792
|
+
}
|
|
793
|
+
if (/\/workspace\/package\.json/i.test(line) && /ENOENT/i.test(line)) {
|
|
794
|
+
return "Docker build ran from /workspace but package.json was not found there. The configured rootDirectory is likely empty or incorrect.";
|
|
795
|
+
}
|
|
796
|
+
if (/failed to solve|executor failed running|error: build/i.test(line)) {
|
|
797
|
+
return line;
|
|
798
|
+
}
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
formatDockerConnectivityError(error) {
|
|
802
|
+
const message = safeErrorMessage(error);
|
|
803
|
+
if (/^Cannot reach Docker daemon:/i.test(message)) {
|
|
804
|
+
return message;
|
|
805
|
+
}
|
|
806
|
+
return `Cannot reach Docker daemon: ${message}. ${this.transport.unreachableHint}`;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* SSH transport build path. Bypasses dockerode's HTTP-over-SSH upload
|
|
810
|
+
* (which is ~1-2 MB/s and silent) in favor of two well-trodden pieces:
|
|
811
|
+
*
|
|
812
|
+
* 1. `transferLocalDirectory(...)` - defaults to rsync over the
|
|
813
|
+
* SYSTEM `ssh` binary (NOT the Node `ssh2` library), with native
|
|
814
|
+
* `--progress` output streamed straight from rsync. ~10-30 MB/s
|
|
815
|
+
* typical. Lands the context at `/tmp/openship-build-<sessionId>`
|
|
816
|
+
* on remote. Falls back to tar through the ssh2 channel only if
|
|
817
|
+
* rsync isn't installed on either side.
|
|
818
|
+
*
|
|
819
|
+
* 2. `executor.streamExec("docker build ...")` - runs native docker
|
|
820
|
+
* CLI on the remote. Its raw stdout/stderr streams back unfiltered
|
|
821
|
+
* so the user sees real "Step N/M : ...", layer hashes, install
|
|
822
|
+
* output, etc. Same logs you'd see SSHing in and running it by hand.
|
|
823
|
+
*
|
|
824
|
+
* Container lifecycle (deploy, stop, logs, etc.) still uses dockerode -
|
|
825
|
+
* only the slow build upload moves to this path.
|
|
826
|
+
*/
|
|
827
|
+
/**
|
|
828
|
+
* Ship a prepared context dir to the remote host (rsync via system ssh with
|
|
829
|
+
* native --progress; tar/ssh2 fallback). Reused by both the single-image SSH
|
|
830
|
+
* build and the batch build (which transfers the shared context ONCE).
|
|
831
|
+
*/
|
|
832
|
+
async transferBuildContext(contextDir, remoteContextDir, log) {
|
|
833
|
+
const executor = this.connectionOptions?.executor;
|
|
834
|
+
if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
|
|
835
|
+
log.log(`Streaming build context to ${remoteContextDir}...`);
|
|
836
|
+
await executor.exec(`rm -rf ${sq(remoteContextDir)} && mkdir -p ${sq(remoteContextDir)}`);
|
|
837
|
+
await transferLocalDirectory(
|
|
838
|
+
contextDir,
|
|
839
|
+
{ kind: "executor", executor, path: remoteContextDir },
|
|
840
|
+
log,
|
|
841
|
+
{ excludes: [] }
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Run native `docker build` on the remote host against an already-transferred
|
|
846
|
+
* context dir. One image; `dockerfileName` selects which Dockerfile in the
|
|
847
|
+
* shared tree to use (so N services can build from one transferred context).
|
|
848
|
+
*/
|
|
849
|
+
async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log) {
|
|
850
|
+
const executor = this.connectionOptions?.executor;
|
|
851
|
+
if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
|
|
852
|
+
const buildArgs = Object.entries({
|
|
853
|
+
...config.envVars,
|
|
854
|
+
NODE_ENV: "production"
|
|
855
|
+
}).filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)).map(([k, v]) => `--build-arg ${sq(`${k}=${v}`)}`).join(" ");
|
|
856
|
+
const labelArgs = Object.entries(
|
|
857
|
+
this.labels({ projectId: config.projectId, sessionId: config.sessionId })
|
|
858
|
+
).map(([k, v]) => `--label ${sq(`${k}=${v}`)}`).join(" ");
|
|
859
|
+
const dockerfileFlag = dockerfileName && dockerfileName !== "Dockerfile" ? ` -f ${sq(dockerfileName)}` : "";
|
|
860
|
+
const buildCmd = `cd ${sq(remoteContextDir)} && docker build --progress=plain -t ${sq(tag)}${dockerfileFlag} ${labelArgs} ${buildArgs} --force-rm .`;
|
|
861
|
+
log.log(`Running on remote: ${buildCmd}`);
|
|
862
|
+
log.log("\u2500\u2500\u2500 docker build output \u2500\u2500\u2500");
|
|
863
|
+
this.emitDockerStep(log, "install", "running", "Running install inside container (docker build)");
|
|
864
|
+
const { code } = await executor.streamExec(buildCmd, (entry) => {
|
|
865
|
+
log.log(entry.message, parseLogLevel(entry.message));
|
|
866
|
+
});
|
|
867
|
+
log.log("\u2500\u2500\u2500 end docker build output \u2500\u2500\u2500");
|
|
868
|
+
if (code !== 0) throw new Error(`docker build exited with code ${code}`);
|
|
869
|
+
this.emitDockerStep(log, "install", "completed", "Image build finished");
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Clone the repo directly ON the remote host into `remoteContextDir` — the
|
|
873
|
+
* clone-on-server alternative to transferBuildContext (which clones on the
|
|
874
|
+
* orchestrator and rsyncs the tree). Runs `git clone` in a remote host shell,
|
|
875
|
+
* mirroring the bare runtime (build-pipeline.ts): the credential-helper relay
|
|
876
|
+
* (`config.gitCredentialHelperPath` — plain URL, nothing persisted) when set,
|
|
877
|
+
* else `injectGitToken(...)`. Strips `.git` so it never ships into the image.
|
|
878
|
+
*/
|
|
879
|
+
async cloneSourceOnRemote(config, remoteContextDir, log) {
|
|
880
|
+
const executor = this.connectionOptions?.executor;
|
|
881
|
+
if (!executor) throw new Error("Clone-on-server requires an SSH executor on connectionOptions");
|
|
882
|
+
const useHelper = !!config.gitCredentialHelperPath;
|
|
883
|
+
if (!useHelper && !config.gitSsh && !config.gitAmbient) {
|
|
884
|
+
const ref = config.commitSha || config.branch;
|
|
885
|
+
const tarUrl = githubTarballUrl(config.repoUrl, ref);
|
|
886
|
+
if (tarUrl) {
|
|
887
|
+
try {
|
|
888
|
+
log.log(`Fetching ${config.repoUrl} tarball on the server \u2192 ${remoteContextDir}...
|
|
889
|
+
`);
|
|
890
|
+
await downloadTarballOnRemote(executor, {
|
|
891
|
+
url: tarUrl,
|
|
892
|
+
token: config.gitToken,
|
|
893
|
+
destDir: remoteContextDir,
|
|
894
|
+
onLog: (entry) => log.log(entry.message, parseLogLevel(entry.message))
|
|
895
|
+
});
|
|
896
|
+
await executor.exec(`rm -rf ${sq(`${remoteContextDir}/.git`)}`).catch(() => {
|
|
897
|
+
});
|
|
898
|
+
return;
|
|
899
|
+
} catch (err) {
|
|
900
|
+
log.log(
|
|
901
|
+
`Tarball download failed (${safeErrorMessage(err)}); falling back to git clone.
|
|
902
|
+
`,
|
|
903
|
+
"warn"
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
let sshMaterial;
|
|
909
|
+
if (config.gitSsh) {
|
|
910
|
+
sshMaterial = await materializeGitSsh(
|
|
911
|
+
shellGitSshWriter({
|
|
912
|
+
exec: (cmd) => executor.exec(cmd),
|
|
913
|
+
writeSecret: (path, content) => executor.writeFile(path, content)
|
|
914
|
+
}),
|
|
915
|
+
`${remoteContextDir}.gitssh`,
|
|
916
|
+
config.gitSsh
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const { cloneUrl, gitEnv: GIT_ENV, credFlag: CRED } = assembleGitClone({
|
|
920
|
+
repoUrl: config.repoUrl,
|
|
921
|
+
gitToken: config.gitToken,
|
|
922
|
+
gitCredentialHelperPath: config.gitCredentialHelperPath,
|
|
923
|
+
ssh: sshMaterial,
|
|
924
|
+
ambient: config.gitAmbient
|
|
925
|
+
});
|
|
926
|
+
const dir = sq(remoteContextDir);
|
|
927
|
+
const authLabel = config.gitSsh ? "ssh key" : useHelper ? "forwarded credentials" : config.gitAmbient ? `the server's own git credentials (${config.gitAmbient.via})` : "token";
|
|
928
|
+
log.log(`Cloning ${config.repoUrl} on the server \u2192 ${remoteContextDir} (${authLabel})...
|
|
929
|
+
`);
|
|
930
|
+
await executor.exec(`rm -rf ${dir} && mkdir -p ${dir}`);
|
|
931
|
+
const run = async (cmd) => {
|
|
932
|
+
const { code } = await executor.streamExec(
|
|
933
|
+
cmd,
|
|
934
|
+
(entry) => log.log(entry.message, parseLogLevel(entry.message))
|
|
935
|
+
);
|
|
936
|
+
if (code !== 0) throw new Error(`git clone on server exited with code ${code}`);
|
|
937
|
+
};
|
|
938
|
+
try {
|
|
939
|
+
if (config.commitSha) {
|
|
940
|
+
try {
|
|
941
|
+
await run(
|
|
942
|
+
`${GIT_ENV} git ${CRED} clone --progress --depth 50 --branch ${sq(config.branch)} ${sq(cloneUrl)} ${dir} && cd ${dir} && git ${CRED} -c advice.detachedHead=false checkout ${sq(config.commitSha)}`
|
|
943
|
+
);
|
|
944
|
+
} catch {
|
|
945
|
+
log.log(`Commit ${config.commitSha} not in the shallow clone; unshallowing and retrying.
|
|
946
|
+
`, "warn");
|
|
947
|
+
await run(
|
|
948
|
+
`cd ${dir} && ${GIT_ENV} git ${CRED} fetch --progress --unshallow && git ${CRED} -c advice.detachedHead=false checkout ${sq(config.commitSha)}`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
} else {
|
|
952
|
+
await run(
|
|
953
|
+
`${GIT_ENV} git ${CRED} clone --progress --depth 1 --branch ${sq(config.branch)} ${sq(cloneUrl)} ${dir}`
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
await executor.exec(`rm -rf ${sq(`${remoteContextDir}/.git`)}`).catch(() => {
|
|
957
|
+
});
|
|
958
|
+
} finally {
|
|
959
|
+
await sshMaterial?.cleanup();
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Resolve the Dockerfile for a build whose source lives on the REMOTE host
|
|
964
|
+
* (clone-on-server). Mirrors resolveServiceDockerfile but probes the remote
|
|
965
|
+
* tree with `test -f` instead of the local FS: a repository Dockerfile
|
|
966
|
+
* candidate is used when present; otherwise a Dockerfile is generated locally
|
|
967
|
+
* (pure fn) and written to the remote tree. Returns the dockerfile path
|
|
968
|
+
* relative to `remoteContextDir`.
|
|
969
|
+
*/
|
|
970
|
+
async resolveRemoteDockerfile(config, remoteContextDir, generatedName, requireRepositoryDockerfile) {
|
|
971
|
+
const executor = this.connectionOptions?.executor;
|
|
972
|
+
if (!executor) throw new Error("Clone-on-server requires an SSH executor on connectionOptions");
|
|
973
|
+
for (const candidate of resolveDockerfileCandidates(config.rootDirectory, config.dockerfilePath)) {
|
|
974
|
+
const out = await executor.exec(`test -f ${sq(`${remoteContextDir}/${candidate}`)} && echo yes || true`).catch(() => "");
|
|
975
|
+
if (out.trim() === "yes") return candidate;
|
|
976
|
+
}
|
|
977
|
+
if (requireRepositoryDockerfile) {
|
|
978
|
+
const expected = config.dockerfilePath?.trim() || "Dockerfile";
|
|
979
|
+
throw new Error(
|
|
980
|
+
`No Dockerfile found in the cloned repo. Expected ${expected}${config.rootDirectory ? ` under ${config.rootDirectory}` : ""}.`
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
await executor.writeFile(`${remoteContextDir}/${generatedName}`, generateDockerfile(config));
|
|
984
|
+
return generatedName;
|
|
985
|
+
}
|
|
986
|
+
async buildViaSshTarPipe(config, buildContext, tag, log) {
|
|
987
|
+
const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
|
|
988
|
+
try {
|
|
989
|
+
await this.transferBuildContext(buildContext.contextDir, remoteContextDir, log);
|
|
990
|
+
await this.buildImageOnRemote(config, remoteContextDir, buildContext.dockerfileName, tag, log);
|
|
991
|
+
} finally {
|
|
992
|
+
this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
|
|
993
|
+
});
|
|
994
|
+
await buildContext.cleanup();
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Dockerode build path. Used for local socket and TCP transports, plus
|
|
999
|
+
* SSH transports that didn't get an executor wired in (shouldn't happen
|
|
1000
|
+
* in normal operation but kept as a safety net).
|
|
1001
|
+
*
|
|
1002
|
+
* This path is slower for SSH (HTTP-over-SSH upload has no streaming),
|
|
1003
|
+
* but it's correct for local/TCP where there's no separate SSH
|
|
1004
|
+
* connection to piggyback on.
|
|
1005
|
+
*/
|
|
1006
|
+
async buildViaDockerode(config, buildContext, tag, log) {
|
|
1007
|
+
log.log(`Streaming build context to Docker daemon - image tag: ${tag}`);
|
|
1008
|
+
let stream;
|
|
1009
|
+
try {
|
|
1010
|
+
stream = await this.docker.buildImage(
|
|
1011
|
+
{ context: buildContext.contextDir, src: buildContext.contextEntries },
|
|
1012
|
+
{
|
|
1013
|
+
t: tag,
|
|
1014
|
+
dockerfile: buildContext.dockerfileName,
|
|
1015
|
+
labels: this.labels({ projectId: config.projectId, sessionId: config.sessionId }),
|
|
1016
|
+
buildargs: {
|
|
1017
|
+
...config.envVars,
|
|
1018
|
+
NODE_ENV: "production"
|
|
1019
|
+
},
|
|
1020
|
+
forcerm: true
|
|
1021
|
+
}
|
|
1022
|
+
);
|
|
1023
|
+
} finally {
|
|
1024
|
+
await buildContext.cleanup();
|
|
1025
|
+
}
|
|
1026
|
+
log.log("Connected to Docker daemon. Build output follows:");
|
|
1027
|
+
await this.streamDockerodeBuild(stream, log);
|
|
1028
|
+
log.log("Docker daemon finished streaming build output. Finalizing image...\n");
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Consume a dockerode build stream: stream progress to the log, enforce the
|
|
1032
|
+
* idle timeout + keepalive, and throw on a fatal build event. Shared by the
|
|
1033
|
+
* single-image dockerode path and the local batch build (buildImages).
|
|
1034
|
+
*/
|
|
1035
|
+
async streamDockerodeBuild(stream, log) {
|
|
1036
|
+
let fatalBuildError = null;
|
|
1037
|
+
await new Promise((resolve, reject) => {
|
|
1038
|
+
let settled = false;
|
|
1039
|
+
let idleTimer = null;
|
|
1040
|
+
let keepaliveTimer = null;
|
|
1041
|
+
let idleMinutes = 0;
|
|
1042
|
+
const clearTimers = () => {
|
|
1043
|
+
if (idleTimer) {
|
|
1044
|
+
clearTimeout(idleTimer);
|
|
1045
|
+
idleTimer = null;
|
|
1046
|
+
}
|
|
1047
|
+
if (keepaliveTimer) {
|
|
1048
|
+
clearInterval(keepaliveTimer);
|
|
1049
|
+
keepaliveTimer = null;
|
|
1050
|
+
}
|
|
1051
|
+
idleMinutes = 0;
|
|
1052
|
+
};
|
|
1053
|
+
const fail = (error) => {
|
|
1054
|
+
if (settled) return;
|
|
1055
|
+
settled = true;
|
|
1056
|
+
clearTimers();
|
|
1057
|
+
stream.destroy?.(error);
|
|
1058
|
+
reject(error);
|
|
1059
|
+
};
|
|
1060
|
+
const succeed = () => {
|
|
1061
|
+
if (settled) return;
|
|
1062
|
+
settled = true;
|
|
1063
|
+
clearTimers();
|
|
1064
|
+
resolve();
|
|
1065
|
+
};
|
|
1066
|
+
const resetIdleTimer = () => {
|
|
1067
|
+
clearTimers();
|
|
1068
|
+
keepaliveTimer = setInterval(() => {
|
|
1069
|
+
idleMinutes += 1;
|
|
1070
|
+
log.log(`Still building... (no output for ${idleMinutes}m)`);
|
|
1071
|
+
}, 6e4);
|
|
1072
|
+
if (keepaliveTimer.unref) keepaliveTimer.unref();
|
|
1073
|
+
idleTimer = setTimeout(() => {
|
|
1074
|
+
fail(new Error(
|
|
1075
|
+
"Docker build produced no output for 30 minutes. This usually means the remote server cannot reach the package registry, has broken DNS, or the Docker daemon stalled during the build."
|
|
1076
|
+
));
|
|
1077
|
+
}, DOCKER_BUILD_IDLE_TIMEOUT_MS);
|
|
1078
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
1079
|
+
};
|
|
1080
|
+
resetIdleTimer();
|
|
1081
|
+
this.docker.modem.followProgress(
|
|
1082
|
+
stream,
|
|
1083
|
+
(err) => {
|
|
1084
|
+
if (err) {
|
|
1085
|
+
fail(err);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
succeed();
|
|
1089
|
+
},
|
|
1090
|
+
(event) => {
|
|
1091
|
+
resetIdleTimer();
|
|
1092
|
+
fatalBuildError ??= this.handleBuildEvent(event, log);
|
|
1093
|
+
}
|
|
1094
|
+
);
|
|
1095
|
+
});
|
|
1096
|
+
if (fatalBuildError) {
|
|
1097
|
+
throw new Error(fatalBuildError);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Confirm a just-built image actually exists before handing its tag back
|
|
1102
|
+
* as the deploy artifact.
|
|
1103
|
+
*
|
|
1104
|
+
* For SSH-transport builds, verify over the SAME pooled SSH exec channel
|
|
1105
|
+
* the `docker build` command itself just ran on — not `this.docker`
|
|
1106
|
+
* (dockerode), which for SSH transport talks over a SEPARATE, independently
|
|
1107
|
+
* -tunneled streamlocal bridge connection (see docker-ssh-agent.ts). That
|
|
1108
|
+
* second connection has proven unreliable in practice: it can hang
|
|
1109
|
+
* indefinitely or report the image missing even though `docker images` on
|
|
1110
|
+
* the box confirms it built successfully seconds earlier. Reusing the
|
|
1111
|
+
* already-proven-live exec channel avoids standing up a second, flakier
|
|
1112
|
+
* connection just to ask a question the first connection already knows
|
|
1113
|
+
* the answer to.
|
|
1114
|
+
*
|
|
1115
|
+
* For local-socket/TCP transports there is no separate bridge — `this.docker`
|
|
1116
|
+
* talks directly to the daemon — so the original dockerode check is fine
|
|
1117
|
+
* and stays as the fallback.
|
|
1118
|
+
*/
|
|
1119
|
+
async verifyImageBuilt(tag) {
|
|
1120
|
+
const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
|
|
1121
|
+
try {
|
|
1122
|
+
if (executor) {
|
|
1123
|
+
await executor.exec(`docker image inspect ${sq(tag)} >/dev/null`);
|
|
1124
|
+
} else {
|
|
1125
|
+
await this.docker.getImage(tag).inspect();
|
|
1126
|
+
}
|
|
1127
|
+
} catch (cause) {
|
|
1128
|
+
throw new Error(`Docker build finished but the image ${tag} was not created`, { cause });
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
async build(config, logger) {
|
|
1132
|
+
const log = logger ?? new BuildLogger();
|
|
1133
|
+
const startTime = Date.now();
|
|
1134
|
+
const tag = this.imageTag(config.slug, config.sessionId);
|
|
1135
|
+
try {
|
|
1136
|
+
log.log(`Build strategy: docker (${this.transport.description})
|
|
1137
|
+
`);
|
|
1138
|
+
try {
|
|
1139
|
+
await this.ensureDockerFeature(log);
|
|
1140
|
+
} catch (featureErr) {
|
|
1141
|
+
throw new Error(this.formatDockerConnectivityError(featureErr));
|
|
1142
|
+
}
|
|
1143
|
+
const sshExecutor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
|
|
1144
|
+
if (sshExecutor && config.cloneOnServer) {
|
|
1145
|
+
const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
|
|
1146
|
+
try {
|
|
1147
|
+
this.emitDockerStep(log, "clone", "running", "Cloning source on the server...");
|
|
1148
|
+
await this.cloneSourceOnRemote(config, remoteContextDir, log);
|
|
1149
|
+
this.emitDockerStep(log, "clone", "completed", "Source cloned on the server");
|
|
1150
|
+
const dockerfileName = await this.resolveRemoteDockerfile(
|
|
1151
|
+
config,
|
|
1152
|
+
remoteContextDir,
|
|
1153
|
+
"Dockerfile.openship",
|
|
1154
|
+
config.stack === "docker"
|
|
1155
|
+
);
|
|
1156
|
+
await this.buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log);
|
|
1157
|
+
} finally {
|
|
1158
|
+
sshExecutor.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
await this.verifyImageBuilt(tag);
|
|
1162
|
+
log.log(`Image ${tag} is ready.
|
|
1163
|
+
`);
|
|
1164
|
+
log.step("build", "completed", `Finalizing image ${tag}`);
|
|
1165
|
+
return { sessionId: config.sessionId, status: "deploying", imageRef: tag, durationMs: Date.now() - startTime };
|
|
1166
|
+
}
|
|
1167
|
+
this.emitDockerStep(log, "clone", "running", "Preparing Docker build context...");
|
|
1168
|
+
const buildContext = await createDockerBuildContext(config, {
|
|
1169
|
+
requireRepositoryDockerfile: config.stack === "docker",
|
|
1170
|
+
onLog: log.callback
|
|
1171
|
+
});
|
|
1172
|
+
try {
|
|
1173
|
+
const sizeBytes = await this.estimateContextSize(buildContext.contextDir);
|
|
1174
|
+
const sizeMB = (sizeBytes / 1024 / 1024).toFixed(1);
|
|
1175
|
+
this.emitDockerStep(
|
|
1176
|
+
log,
|
|
1177
|
+
"clone",
|
|
1178
|
+
"completed",
|
|
1179
|
+
`Docker build context ready (${sizeMB} MB)`
|
|
1180
|
+
);
|
|
1181
|
+
} catch {
|
|
1182
|
+
this.emitDockerStep(log, "clone", "completed", "Docker build context ready");
|
|
1183
|
+
}
|
|
1184
|
+
if (buildContext.rootDirectory) {
|
|
1185
|
+
log.log(`Using Docker build root: ${buildContext.rootDirectory}`);
|
|
1186
|
+
}
|
|
1187
|
+
if (buildContext.usesRepositoryDockerfile) {
|
|
1188
|
+
this.emitDockerStep(
|
|
1189
|
+
log,
|
|
1190
|
+
"install",
|
|
1191
|
+
"skipped",
|
|
1192
|
+
"Repository Dockerfile owns dependency installation"
|
|
1193
|
+
);
|
|
1194
|
+
this.emitDockerStep(
|
|
1195
|
+
log,
|
|
1196
|
+
"build",
|
|
1197
|
+
"running",
|
|
1198
|
+
"Building image from repository Dockerfile..."
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
if (!buildContext.usesRepositoryDockerfile && !config.installCommand) {
|
|
1202
|
+
this.emitDockerStep(log, "install", "skipped", "No install command configured");
|
|
1203
|
+
}
|
|
1204
|
+
if (!buildContext.usesRepositoryDockerfile && !config.buildCommand) {
|
|
1205
|
+
this.emitDockerStep(log, "build", "skipped", "No build command configured");
|
|
1206
|
+
}
|
|
1207
|
+
if (sshExecutor) {
|
|
1208
|
+
await this.buildViaSshTarPipe(config, buildContext, tag, log);
|
|
1209
|
+
} else {
|
|
1210
|
+
await this.buildViaDockerode(config, buildContext, tag, log);
|
|
1211
|
+
}
|
|
1212
|
+
await this.verifyImageBuilt(tag);
|
|
1213
|
+
log.log(`Image ${tag} is ready.
|
|
1214
|
+
`);
|
|
1215
|
+
log.log(`[build] \u2713 Image ${tag} ready`);
|
|
1216
|
+
log.step("build", "completed", `Finalizing image ${tag}`);
|
|
1217
|
+
const durationMs = Date.now() - startTime;
|
|
1218
|
+
return { sessionId: config.sessionId, status: "deploying", imageRef: tag, durationMs };
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
const msg = safeErrorMessage(err);
|
|
1221
|
+
log.step("build", "failed", `Docker build failed: ${msg}`);
|
|
1222
|
+
return { sessionId: config.sessionId, status: "failed", durationMs: Date.now() - startTime, errorMessage: `Docker build failed: ${msg}` };
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Build a STATIC app in a Docker sandbox, then extract its doc-root onto a
|
|
1227
|
+
* host directory the edge serves via nginx `root`. Reuses `build()` — so
|
|
1228
|
+
* Docker is auto-ensured (ensureDockerFeature) and the static nginx image is
|
|
1229
|
+
* produced (`generateStaticDockerfile` COPYs the output to the fixed
|
|
1230
|
+
* `/usr/share/nginx/html`) — then extracts those files and discards the image.
|
|
1231
|
+
* Returns the host dir as `imageRef`, matching BareRuntime.build's host-dir
|
|
1232
|
+
* contract so the existing file-backed serve path (deployStatic /
|
|
1233
|
+
* resolveStaticRoot) consumes it unchanged. Never leaves a long-lived
|
|
1234
|
+
* container: the extraction container is created (not started) and removed.
|
|
1235
|
+
*/
|
|
1236
|
+
async buildStaticToHost(config, hostOutDir, logger) {
|
|
1237
|
+
const log = logger ?? new BuildLogger();
|
|
1238
|
+
const buildResult = await this.build({ ...config, isStatic: true }, log);
|
|
1239
|
+
if (buildResult.status !== "deploying" || !buildResult.imageRef) {
|
|
1240
|
+
return buildResult;
|
|
1241
|
+
}
|
|
1242
|
+
const tag = buildResult.imageRef;
|
|
1243
|
+
const extractStart = Date.now();
|
|
1244
|
+
const DOC_ROOT = "/usr/share/nginx/html";
|
|
1245
|
+
try {
|
|
1246
|
+
const sshExecutor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
|
|
1247
|
+
log.log("Extracting static files from the build sandbox...\n");
|
|
1248
|
+
if (sshExecutor) {
|
|
1249
|
+
const cid = (await sshExecutor.exec(`docker create ${sq(tag)}`)).trim();
|
|
1250
|
+
try {
|
|
1251
|
+
await sshExecutor.exec(`mkdir -p ${sq(hostOutDir)}`);
|
|
1252
|
+
await sshExecutor.exec(`docker cp ${sq(`${cid}:${DOC_ROOT}/.`)} ${sq(hostOutDir)}`);
|
|
1253
|
+
} finally {
|
|
1254
|
+
await sshExecutor.exec(`docker rm ${sq(cid)}`).catch(() => {
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
} else {
|
|
1258
|
+
const { mkdir } = await import("fs/promises");
|
|
1259
|
+
const { spawn } = await import("child_process");
|
|
1260
|
+
await mkdir(hostOutDir, { recursive: true });
|
|
1261
|
+
const container = await this.docker.createContainer({ Image: tag });
|
|
1262
|
+
try {
|
|
1263
|
+
const tarStream = await container.getArchive({ path: DOC_ROOT });
|
|
1264
|
+
await new Promise((resolve, reject) => {
|
|
1265
|
+
const extract = spawn("tar", ["-x", "--strip-components=1", "-C", hostOutDir]);
|
|
1266
|
+
let errBuf = "";
|
|
1267
|
+
extract.stderr.on("data", (d) => errBuf += d.toString());
|
|
1268
|
+
extract.on("error", reject);
|
|
1269
|
+
tarStream.on("error", reject);
|
|
1270
|
+
tarStream.pipe(extract.stdin);
|
|
1271
|
+
extract.on(
|
|
1272
|
+
"close",
|
|
1273
|
+
(code) => code === 0 ? resolve() : reject(new Error(`static extract failed (tar ${code}): ${errBuf.trim().slice(-500)}`))
|
|
1274
|
+
);
|
|
1275
|
+
});
|
|
1276
|
+
} finally {
|
|
1277
|
+
await container.remove({ force: true }).catch(() => {
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
log.log(`Static files ready at ${hostOutDir}
|
|
1282
|
+
`);
|
|
1283
|
+
return {
|
|
1284
|
+
sessionId: config.sessionId,
|
|
1285
|
+
status: "deploying",
|
|
1286
|
+
imageRef: hostOutDir,
|
|
1287
|
+
durationMs: (buildResult.durationMs ?? 0) + (Date.now() - extractStart)
|
|
1288
|
+
};
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
const msg = safeErrorMessage(err);
|
|
1291
|
+
log.step("build", "failed", `Static extract failed: ${msg}`);
|
|
1292
|
+
return {
|
|
1293
|
+
sessionId: config.sessionId,
|
|
1294
|
+
status: "failed",
|
|
1295
|
+
durationMs: (buildResult.durationMs ?? 0) + (Date.now() - extractStart),
|
|
1296
|
+
errorMessage: `Static extract failed: ${msg}`
|
|
1297
|
+
};
|
|
1298
|
+
} finally {
|
|
1299
|
+
await this.removeImage(tag).catch(() => {
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Batch build: clone + prune the shared source ONCE, then build every image
|
|
1305
|
+
* from that single tree. For SSH the context is transferred ONCE and each
|
|
1306
|
+
* image builds on the remote against it. Eliminates the per-service re-clone
|
|
1307
|
+
* / re-transfer that N separate build() calls incur — every service builds on
|
|
1308
|
+
* the SAME daemon, so the source only needs to arrive once.
|
|
1309
|
+
*
|
|
1310
|
+
* Prepare-phase logs (clone/transfer) go to `prepareLogger`; each image's
|
|
1311
|
+
* build output goes to its own `spec.logger`.
|
|
1312
|
+
*/
|
|
1313
|
+
async buildImages(specs, prepareLogger) {
|
|
1314
|
+
if (specs.length === 0) return [];
|
|
1315
|
+
try {
|
|
1316
|
+
await this.ensureDockerFeature(prepareLogger);
|
|
1317
|
+
} catch (featureErr) {
|
|
1318
|
+
throw new Error(this.formatDockerConnectivityError(featureErr));
|
|
1319
|
+
}
|
|
1320
|
+
const source = specs[0].config;
|
|
1321
|
+
const isSsh = this.transport.kind === "ssh" && !!this.connectionOptions?.executor;
|
|
1322
|
+
const cloneOnServer = isSsh && !!source.cloneOnServer;
|
|
1323
|
+
const remoteContextDir = `/tmp/openship-build-${source.sessionId}`;
|
|
1324
|
+
let tree = null;
|
|
1325
|
+
if (cloneOnServer) {
|
|
1326
|
+
prepareLogger.step("clone", "running", "Cloning source on the server...");
|
|
1327
|
+
await this.cloneSourceOnRemote(source, remoteContextDir, prepareLogger);
|
|
1328
|
+
prepareLogger.step("clone", "completed", "Source cloned on the server");
|
|
1329
|
+
} else {
|
|
1330
|
+
prepareLogger.step("clone", "running", "Preparing shared build context...");
|
|
1331
|
+
tree = await prepareSourceTree(source, { onLog: prepareLogger.callback });
|
|
1332
|
+
}
|
|
1333
|
+
try {
|
|
1334
|
+
const resolvedList = await Promise.all(
|
|
1335
|
+
specs.map(async (spec) => {
|
|
1336
|
+
const generatedName = `Dockerfile.openship.${spec.config.sessionId}`;
|
|
1337
|
+
const requireRepo = spec.requireRepositoryDockerfile ?? spec.config.stack === "docker";
|
|
1338
|
+
try {
|
|
1339
|
+
if (cloneOnServer) {
|
|
1340
|
+
const dockerfileName = await this.resolveRemoteDockerfile(
|
|
1341
|
+
spec.config,
|
|
1342
|
+
remoteContextDir,
|
|
1343
|
+
generatedName,
|
|
1344
|
+
requireRepo
|
|
1345
|
+
);
|
|
1346
|
+
return {
|
|
1347
|
+
spec,
|
|
1348
|
+
dockerfileName,
|
|
1349
|
+
contextEntries: null,
|
|
1350
|
+
error: null
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
const resolved = await resolveServiceDockerfile(tree.contextDir, spec.config, {
|
|
1354
|
+
requireRepositoryDockerfile: requireRepo,
|
|
1355
|
+
generatedName
|
|
1356
|
+
});
|
|
1357
|
+
return {
|
|
1358
|
+
spec,
|
|
1359
|
+
dockerfileName: resolved.dockerfileName,
|
|
1360
|
+
contextEntries: resolved.contextEntries,
|
|
1361
|
+
error: null
|
|
1362
|
+
};
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
return {
|
|
1365
|
+
spec,
|
|
1366
|
+
dockerfileName: null,
|
|
1367
|
+
contextEntries: null,
|
|
1368
|
+
error: safeErrorMessage(err)
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
})
|
|
1372
|
+
);
|
|
1373
|
+
if (!cloneOnServer && tree) {
|
|
1374
|
+
try {
|
|
1375
|
+
const sizeBytes = await this.estimateContextSize(tree.contextDir);
|
|
1376
|
+
prepareLogger.step(
|
|
1377
|
+
"clone",
|
|
1378
|
+
"completed",
|
|
1379
|
+
`Shared build context ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB)`
|
|
1380
|
+
);
|
|
1381
|
+
} catch {
|
|
1382
|
+
prepareLogger.step("clone", "completed", "Shared build context ready");
|
|
1383
|
+
}
|
|
1384
|
+
if (isSsh) {
|
|
1385
|
+
await this.transferBuildContext(tree.contextDir, remoteContextDir, prepareLogger);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
const results = [];
|
|
1389
|
+
for (const { spec, dockerfileName, contextEntries, error } of resolvedList) {
|
|
1390
|
+
const startedAt = Date.now();
|
|
1391
|
+
const tag = this.imageTag(spec.config.slug, spec.config.sessionId);
|
|
1392
|
+
if (error || !dockerfileName) {
|
|
1393
|
+
const result = {
|
|
1394
|
+
sessionId: spec.config.sessionId,
|
|
1395
|
+
status: "failed",
|
|
1396
|
+
durationMs: Date.now() - startedAt,
|
|
1397
|
+
errorMessage: error ?? "Failed to resolve Dockerfile"
|
|
1398
|
+
};
|
|
1399
|
+
spec.onResult?.(result);
|
|
1400
|
+
results.push({ serviceName: spec.serviceName, result });
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
spec.onStart?.();
|
|
1404
|
+
try {
|
|
1405
|
+
if (isSsh) {
|
|
1406
|
+
await this.buildImageOnRemote(
|
|
1407
|
+
spec.config,
|
|
1408
|
+
remoteContextDir,
|
|
1409
|
+
dockerfileName,
|
|
1410
|
+
tag,
|
|
1411
|
+
spec.logger
|
|
1412
|
+
);
|
|
1413
|
+
} else {
|
|
1414
|
+
const stream = await this.docker.buildImage(
|
|
1415
|
+
{ context: tree.contextDir, src: contextEntries ?? [] },
|
|
1416
|
+
{
|
|
1417
|
+
t: tag,
|
|
1418
|
+
dockerfile: dockerfileName,
|
|
1419
|
+
labels: this.labels({
|
|
1420
|
+
projectId: spec.config.projectId,
|
|
1421
|
+
sessionId: spec.config.sessionId
|
|
1422
|
+
}),
|
|
1423
|
+
buildargs: { ...spec.config.envVars, NODE_ENV: "production" },
|
|
1424
|
+
forcerm: true
|
|
1425
|
+
}
|
|
1426
|
+
);
|
|
1427
|
+
await this.streamDockerodeBuild(stream, spec.logger);
|
|
1428
|
+
}
|
|
1429
|
+
await this.verifyImageBuilt(tag);
|
|
1430
|
+
spec.logger.log(`Image ${tag} is ready.
|
|
1431
|
+
`);
|
|
1432
|
+
const result = {
|
|
1433
|
+
sessionId: spec.config.sessionId,
|
|
1434
|
+
status: "deploying",
|
|
1435
|
+
imageRef: tag,
|
|
1436
|
+
durationMs: Date.now() - startedAt
|
|
1437
|
+
};
|
|
1438
|
+
spec.onResult?.(result);
|
|
1439
|
+
results.push({ serviceName: spec.serviceName, result });
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
const msg = safeErrorMessage(err);
|
|
1442
|
+
spec.logger.log(`Docker build failed: ${msg}
|
|
1443
|
+
`, "error");
|
|
1444
|
+
const result = {
|
|
1445
|
+
sessionId: spec.config.sessionId,
|
|
1446
|
+
status: "failed",
|
|
1447
|
+
durationMs: Date.now() - startedAt,
|
|
1448
|
+
errorMessage: `Docker build failed: ${msg}`
|
|
1449
|
+
};
|
|
1450
|
+
spec.onResult?.(result);
|
|
1451
|
+
results.push({ serviceName: spec.serviceName, result });
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return results;
|
|
1455
|
+
} finally {
|
|
1456
|
+
if (isSsh) {
|
|
1457
|
+
this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
if (tree) await tree.cleanup();
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
async cancelBuild(sessionId) {
|
|
1464
|
+
const containers = await this.docker.listContainers({
|
|
1465
|
+
all: true,
|
|
1466
|
+
filters: { label: [`openship.build=${sessionId}`] }
|
|
1467
|
+
});
|
|
1468
|
+
for (const c of containers) {
|
|
1469
|
+
try {
|
|
1470
|
+
await this.docker.getContainer(c.Id).remove({ force: true });
|
|
1471
|
+
} catch {
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
async getBuildLogs(sessionId) {
|
|
1476
|
+
void sessionId;
|
|
1477
|
+
return [];
|
|
1478
|
+
}
|
|
1479
|
+
// ── Deploy lifecycle ───────────────────────────────────────────────────
|
|
1480
|
+
async deploy(config, onLog) {
|
|
1481
|
+
const log = onLog ?? (() => {
|
|
1482
|
+
});
|
|
1483
|
+
const imageRef = config.imageRef;
|
|
1484
|
+
if (!imageRef) {
|
|
1485
|
+
throw new Error("Docker deploy requires an imageRef (built image tag)");
|
|
1486
|
+
}
|
|
1487
|
+
const containerName = `openship-${config.runtimeName || config.projectId}-${config.deploymentId}`;
|
|
1488
|
+
const env = [
|
|
1489
|
+
`PORT=${config.port}`,
|
|
1490
|
+
`NODE_ENV=${config.environment === "production" ? "production" : "development"}`,
|
|
1491
|
+
...Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`)
|
|
1492
|
+
];
|
|
1493
|
+
const cmd = config.startCommand ? ["sh", "-c", config.startCommand] : void 0;
|
|
1494
|
+
const restartPolicy = resolveRestartPolicy(config.restartPolicy);
|
|
1495
|
+
log({
|
|
1496
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1497
|
+
message: `Creating container ${containerName} from ${imageRef}...
|
|
1498
|
+
`,
|
|
1499
|
+
level: "info"
|
|
1500
|
+
});
|
|
1501
|
+
const container = await this.docker.createContainer({
|
|
1502
|
+
name: containerName,
|
|
1503
|
+
Image: imageRef,
|
|
1504
|
+
Cmd: cmd,
|
|
1505
|
+
Env: env,
|
|
1506
|
+
Labels: this.labels({
|
|
1507
|
+
deploymentId: config.deploymentId,
|
|
1508
|
+
projectId: config.projectId
|
|
1509
|
+
}),
|
|
1510
|
+
ExposedPorts: { [`${config.port}/tcp`]: {} },
|
|
1511
|
+
HostConfig: {
|
|
1512
|
+
RestartPolicy: restartPolicy,
|
|
1513
|
+
Memory: config.resources.memoryMb * 1024 * 1024,
|
|
1514
|
+
CpuShares: Math.round(config.resources.cpuCores * 1024),
|
|
1515
|
+
// Publish on the LOOPBACK interface only — the edge (host process, or a
|
|
1516
|
+
// host-net OpenResty container) reaches it at 127.0.0.1:<hostPort>, and
|
|
1517
|
+
// it never faces the network. Binding 0.0.0.0 here would expose every
|
|
1518
|
+
// app directly, bypassing the edge's SSL/rate-limit/rules (and Docker's
|
|
1519
|
+
// iptables bypass ufw). A pinned `config.hostPort` (loopback-port route
|
|
1520
|
+
// strategy) is stable across redeploys; otherwise a random loopback port.
|
|
1521
|
+
PortBindings: {
|
|
1522
|
+
[`${config.port}/tcp`]: [
|
|
1523
|
+
{ HostIp: "127.0.0.1", HostPort: config.hostPort ? String(config.hostPort) : "" }
|
|
1524
|
+
]
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
await container.start();
|
|
1529
|
+
log({
|
|
1530
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1531
|
+
message: `Container ${container.id.slice(0, 12)} started.
|
|
1532
|
+
`,
|
|
1533
|
+
level: "info"
|
|
1534
|
+
});
|
|
1535
|
+
return {
|
|
1536
|
+
deploymentId: config.deploymentId,
|
|
1537
|
+
containerId: container.id,
|
|
1538
|
+
status: "running"
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
async stop(containerId) {
|
|
1542
|
+
const container = this.docker.getContainer(containerId);
|
|
1543
|
+
await container.stop();
|
|
1544
|
+
}
|
|
1545
|
+
async start(containerId) {
|
|
1546
|
+
const container = this.docker.getContainer(containerId);
|
|
1547
|
+
await container.start();
|
|
1548
|
+
}
|
|
1549
|
+
async restart(containerId) {
|
|
1550
|
+
const container = this.docker.getContainer(containerId);
|
|
1551
|
+
await container.restart();
|
|
1552
|
+
}
|
|
1553
|
+
async removeImage(imageRef) {
|
|
1554
|
+
const image = this.docker.getImage(imageRef);
|
|
1555
|
+
try {
|
|
1556
|
+
await image.remove({ force: true });
|
|
1557
|
+
} catch (err) {
|
|
1558
|
+
if (!isDockerNotFoundError(err)) throw err;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
async destroy(containerId) {
|
|
1562
|
+
if (containerId.startsWith("/")) {
|
|
1563
|
+
const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
|
|
1564
|
+
if (executor) {
|
|
1565
|
+
await executor.exec(`rm -rf ${sq(containerId)}`).catch(() => {
|
|
1566
|
+
});
|
|
1567
|
+
} else {
|
|
1568
|
+
const { rm } = await import("fs/promises");
|
|
1569
|
+
await rm(containerId, { recursive: true, force: true }).catch(() => {
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const container = this.docker.getContainer(containerId);
|
|
1575
|
+
try {
|
|
1576
|
+
await container.remove({ force: true });
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
if (!isDockerNotFoundError(err)) throw err;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Every container (running OR stopped) labeled for this project. Lets
|
|
1583
|
+
* project teardown reclaim orphans that have no DB row — e.g. a deploy
|
|
1584
|
+
* that started a container then failed during routing, or rows lost to
|
|
1585
|
+
* a crash. The `openship.project` label is stamped at create time
|
|
1586
|
+
* (see `labels()`), so this is authoritative for THIS docker host.
|
|
1587
|
+
*/
|
|
1588
|
+
async listProjectContainerIds(projectId) {
|
|
1589
|
+
const containers = await this.docker.listContainers({
|
|
1590
|
+
all: true,
|
|
1591
|
+
filters: { label: [`openship.project=${projectId}`] }
|
|
1592
|
+
});
|
|
1593
|
+
return containers.map((c) => c.Id);
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Built images that belong to this project — the label `openship.project=<id>`
|
|
1597
|
+
* that `labels()` stamps on every FINAL build image. Base/third-party images
|
|
1598
|
+
* (postgres, redis, …) are PULLED, never labeled, so they can never appear
|
|
1599
|
+
* here — that label filter is the primary guardrail for the image GC, which
|
|
1600
|
+
* reconciles this on-host set against the DB keep-set. Includes untagged
|
|
1601
|
+
* (dangling) superseded finals: labels persist after the tag is removed.
|
|
1602
|
+
*/
|
|
1603
|
+
async listProjectImages(projectId) {
|
|
1604
|
+
const images = await this.docker.listImages({
|
|
1605
|
+
filters: { label: [`openship.project=${projectId}`] }
|
|
1606
|
+
});
|
|
1607
|
+
return images.map((img) => ({
|
|
1608
|
+
id: img.Id,
|
|
1609
|
+
repoTags: (img.RepoTags ?? []).filter((t) => t && t !== "<none>:<none>"),
|
|
1610
|
+
buildId: img.Labels?.["openship.build"],
|
|
1611
|
+
deploymentId: img.Labels?.["openship.deployment"],
|
|
1612
|
+
size: img.Size ?? 0
|
|
1613
|
+
}));
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Reclaim dangling (untagged) images carrying THIS project's label — the
|
|
1617
|
+
* superseded final-stage layers a rebuild leaves behind. NEVER a bare
|
|
1618
|
+
* `docker image prune`: the label filter guarantees base/other-project/other-
|
|
1619
|
+
* tool dangling layers are untouched. (Unlabeled multi-stage intermediate
|
|
1620
|
+
* layers under the classic builder aren't caught — BuildKit avoids them.)
|
|
1621
|
+
*/
|
|
1622
|
+
async pruneProjectDanglingImages(projectId) {
|
|
1623
|
+
try {
|
|
1624
|
+
await this.docker.pruneImages({
|
|
1625
|
+
filters: { dangling: ["true"], label: [`openship.project=${projectId}`] }
|
|
1626
|
+
});
|
|
1627
|
+
} catch {
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Containers labeled for this deployment, with live state — the reconcile
|
|
1632
|
+
* read-back. `State` is dockerode's `running | exited | paused | ...`; map it
|
|
1633
|
+
* to ContainerStatus the same way getContainerInfo does. Absence is conveyed
|
|
1634
|
+
* by an EMPTY list (no container carries the label), which the reconciler
|
|
1635
|
+
* reads as drift for the expected services.
|
|
1636
|
+
*/
|
|
1637
|
+
async listDeploymentContainers(deploymentId) {
|
|
1638
|
+
const containers = await this.docker.listContainers({
|
|
1639
|
+
all: true,
|
|
1640
|
+
filters: { label: [`openship.deployment=${deploymentId}`] }
|
|
1641
|
+
});
|
|
1642
|
+
const stateMap = {
|
|
1643
|
+
running: "running",
|
|
1644
|
+
healthy: "running",
|
|
1645
|
+
starting: "running",
|
|
1646
|
+
restarting: "running",
|
|
1647
|
+
exited: "stopped",
|
|
1648
|
+
paused: "stopped",
|
|
1649
|
+
created: "stopped",
|
|
1650
|
+
dead: "failed",
|
|
1651
|
+
unhealthy: "failed"
|
|
1652
|
+
};
|
|
1653
|
+
return containers.map((c) => ({
|
|
1654
|
+
containerId: c.Id,
|
|
1655
|
+
status: stateMap[(c.State ?? "").toLowerCase().trim()] ?? "stopped",
|
|
1656
|
+
serviceName: c.Labels?.["openship.service"]
|
|
1657
|
+
}));
|
|
1658
|
+
}
|
|
1659
|
+
// ── Rollback primitives ──────────────────────────────────────────────
|
|
1660
|
+
//
|
|
1661
|
+
// Docker semantics:
|
|
1662
|
+
// makeActive — prefer `start` of the retained container (fast,
|
|
1663
|
+
// preserves PID/state). If the container was GC'd but the image
|
|
1664
|
+
// is still tagged, `run` from imageRef to provision a fresh
|
|
1665
|
+
// container. Stop the previous active as part of the swap.
|
|
1666
|
+
// archive — `docker stop`. Image stays tagged. Container kept
|
|
1667
|
+
// for fast restart on later makeActive.
|
|
1668
|
+
// purge — `docker rm` (force) + `docker rmi`. Past this point
|
|
1669
|
+
// rollback to this deployment is impossible.
|
|
1670
|
+
async makeActive(input) {
|
|
1671
|
+
if (input.from?.containerId) {
|
|
1672
|
+
try {
|
|
1673
|
+
await this.stop(input.from.containerId);
|
|
1674
|
+
} catch {
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
if (input.to.containerId) {
|
|
1678
|
+
try {
|
|
1679
|
+
await this.start(input.to.containerId);
|
|
1680
|
+
return { containerId: input.to.containerId };
|
|
1681
|
+
} catch {
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
if (!input.to.imageRef) {
|
|
1685
|
+
throw new Error(
|
|
1686
|
+
`Cannot make deployment ${input.to.id} active: container is gone and no imageRef is stored. Artifact has been purged.`
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
const container = await this.docker.createContainer({
|
|
1690
|
+
Image: input.to.imageRef,
|
|
1691
|
+
name: `dep-${input.to.id}`,
|
|
1692
|
+
HostConfig: { RestartPolicy: { Name: "unless-stopped" } }
|
|
1693
|
+
});
|
|
1694
|
+
await container.start();
|
|
1695
|
+
return { containerId: container.id };
|
|
1696
|
+
}
|
|
1697
|
+
async archive(deployment) {
|
|
1698
|
+
if (!deployment.containerId) return;
|
|
1699
|
+
try {
|
|
1700
|
+
await this.stop(deployment.containerId);
|
|
1701
|
+
} catch {
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
async purge(deployment) {
|
|
1705
|
+
if (deployment.containerId) {
|
|
1706
|
+
try {
|
|
1707
|
+
await this.destroy(deployment.containerId);
|
|
1708
|
+
} catch {
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (deployment.imageRef) {
|
|
1712
|
+
try {
|
|
1713
|
+
await this.removeImage(deployment.imageRef);
|
|
1714
|
+
} catch {
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Inspect a container and return the names of its **named** volumes -
|
|
1720
|
+
* the ones that survive `container.remove()` and would otherwise leak.
|
|
1721
|
+
* Anonymous volumes are auto-removed with `{ v: true }` and don't need to
|
|
1722
|
+
* be enumerated. Bind mounts and tmpfs are skipped (the user manages them
|
|
1723
|
+
* outside our control). Returns [] if the container is already gone.
|
|
1724
|
+
*/
|
|
1725
|
+
async inspectNamedVolumes(containerId) {
|
|
1726
|
+
try {
|
|
1727
|
+
const container = this.docker.getContainer(containerId);
|
|
1728
|
+
const data = await container.inspect();
|
|
1729
|
+
const mounts = data.Mounts ?? [];
|
|
1730
|
+
return mounts.filter((m) => m.Type === "volume" && typeof m.Name === "string" && m.Name.length > 0).map((m) => m.Name);
|
|
1731
|
+
} catch {
|
|
1732
|
+
return [];
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
/** Remove a named volume by name. Best-effort - already-gone is fine. */
|
|
1736
|
+
async removeVolume(name) {
|
|
1737
|
+
try {
|
|
1738
|
+
const volume = this.docker.getVolume(name);
|
|
1739
|
+
await volume.remove({ force: true });
|
|
1740
|
+
} catch {
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
// ── Docker discovery (label-agnostic) ────────────────────────────────────
|
|
1744
|
+
//
|
|
1745
|
+
// Enumerate the ENTIRE daemon, not just openship-labeled resources. Powers
|
|
1746
|
+
// "migrate an existing Docker deployment": read whatever already runs on a
|
|
1747
|
+
// server (a compose stack or hand-run containers) so it can be adopted as an
|
|
1748
|
+
// Openship project. Strictly read-only.
|
|
1749
|
+
/** Every container on the host (running or stopped), summarized. */
|
|
1750
|
+
async listAllContainers() {
|
|
1751
|
+
const containers = await this.docker.listContainers({ all: true });
|
|
1752
|
+
return containers.map((c) => {
|
|
1753
|
+
const labels = c.Labels ?? {};
|
|
1754
|
+
const ip = firstNetworkIp(c.NetworkSettings?.Networks);
|
|
1755
|
+
return {
|
|
1756
|
+
id: c.Id,
|
|
1757
|
+
names: (c.Names ?? []).map((n) => n.replace(/^\//, "")),
|
|
1758
|
+
image: c.Image,
|
|
1759
|
+
imageId: c.ImageID,
|
|
1760
|
+
state: (c.State ?? "").toLowerCase().trim(),
|
|
1761
|
+
status: c.Status,
|
|
1762
|
+
labels,
|
|
1763
|
+
ports: (c.Ports ?? []).map((p) => ({
|
|
1764
|
+
privatePort: p.PrivatePort,
|
|
1765
|
+
...p.PublicPort ? { publicPort: p.PublicPort } : {},
|
|
1766
|
+
type: p.Type ?? "tcp",
|
|
1767
|
+
...p.IP ? { ip: p.IP } : {}
|
|
1768
|
+
})),
|
|
1769
|
+
mounts: (c.Mounts ?? []).map(normalizeDockerMount),
|
|
1770
|
+
...ip ? { ip } : {},
|
|
1771
|
+
composeProject: labels["com.docker.compose.project"] || void 0,
|
|
1772
|
+
composeService: labels["com.docker.compose.service"] || void 0
|
|
1773
|
+
};
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
/** Full inspect of one container, normalized. Null if the container is gone. */
|
|
1777
|
+
async inspectContainer(id) {
|
|
1778
|
+
let data;
|
|
1779
|
+
try {
|
|
1780
|
+
data = await this.docker.getContainer(id).inspect();
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
if (isDockerNotFoundError(err)) return null;
|
|
1783
|
+
throw err;
|
|
1784
|
+
}
|
|
1785
|
+
const labels = data.Config?.Labels ?? {};
|
|
1786
|
+
const hc = data.Config?.Healthcheck;
|
|
1787
|
+
const rp = data.HostConfig?.RestartPolicy;
|
|
1788
|
+
const configFiles = labels["com.docker.compose.project.config_files"];
|
|
1789
|
+
return {
|
|
1790
|
+
id: data.Id,
|
|
1791
|
+
name: (data.Name ?? "").replace(/^\//, ""),
|
|
1792
|
+
image: data.Config?.Image ?? data.Image,
|
|
1793
|
+
imageId: data.Image,
|
|
1794
|
+
state: (data.State?.Status ?? "").toLowerCase().trim() || "unknown",
|
|
1795
|
+
command: toStringArray(data.Config?.Cmd),
|
|
1796
|
+
entrypoint: toStringArray(data.Config?.Entrypoint),
|
|
1797
|
+
env: data.Config?.Env ?? [],
|
|
1798
|
+
workingDir: data.Config?.WorkingDir || void 0,
|
|
1799
|
+
labels,
|
|
1800
|
+
restart: rp?.Name ? { name: rp.Name, maximumRetryCount: rp.MaximumRetryCount } : void 0,
|
|
1801
|
+
networks: Object.keys(data.NetworkSettings?.Networks ?? {}),
|
|
1802
|
+
mounts: (data.Mounts ?? []).map(normalizeDockerMount),
|
|
1803
|
+
ports: normalizeInspectPorts(data),
|
|
1804
|
+
healthcheck: hc ? {
|
|
1805
|
+
test: hc.Test,
|
|
1806
|
+
interval: hc.Interval,
|
|
1807
|
+
timeout: hc.Timeout,
|
|
1808
|
+
retries: hc.Retries,
|
|
1809
|
+
startPeriod: hc.StartPeriod
|
|
1810
|
+
} : void 0,
|
|
1811
|
+
composeProject: labels["com.docker.compose.project"] || void 0,
|
|
1812
|
+
composeService: labels["com.docker.compose.service"] || void 0,
|
|
1813
|
+
composeConfigFiles: configFiles ? configFiles.split(",").map((s) => s.trim()).filter(Boolean) : void 0,
|
|
1814
|
+
composeWorkingDir: labels["com.docker.compose.project.working_dir"] || void 0
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
/** The image's baked-in default env (Config.Env). Discovery subtracts these
|
|
1818
|
+
* from a container's env so only user-set vars are imported, not the dozen
|
|
1819
|
+
* defaults a base image (postgres, node, …) ships with. [] if unavailable. */
|
|
1820
|
+
async inspectImageEnv(ref) {
|
|
1821
|
+
try {
|
|
1822
|
+
const data = await this.docker.getImage(ref).inspect();
|
|
1823
|
+
return data.Config?.Env ?? [];
|
|
1824
|
+
} catch {
|
|
1825
|
+
return [];
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
/** The image's baked-in default CMD (exec-form tokens). Used by migration to
|
|
1829
|
+
* drop a container's `command` when it merely restates the image default —
|
|
1830
|
+
* re-specifying it (and wrapping in `sh -c`) defeats entrypoints that drop
|
|
1831
|
+
* privileges by argv (postgres refuses to run as root otherwise). */
|
|
1832
|
+
async inspectImageCmd(ref) {
|
|
1833
|
+
try {
|
|
1834
|
+
const data = await this.docker.getImage(ref).inspect();
|
|
1835
|
+
return data.Config?.Cmd ?? [];
|
|
1836
|
+
} catch {
|
|
1837
|
+
return [];
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Ensure an image is present, pulling it if missing. THE single image-pull
|
|
1842
|
+
* path — reused by the deploy pipeline and the backup/migration executor so
|
|
1843
|
+
* connectivity lives in one place.
|
|
1844
|
+
*
|
|
1845
|
+
* Over an SSH transport the pull runs as a blocking `docker pull` through the
|
|
1846
|
+
* command executor, NOT dockerode's `pull` + `modem.followProgress`: the
|
|
1847
|
+
* progress stream tunneled over the SSH docker socket never emits `end`, so
|
|
1848
|
+
* followProgress hangs forever (this was the cross-server migration stall).
|
|
1849
|
+
* A local socket has no such issue, so it keeps the native dockerode pull.
|
|
1850
|
+
*/
|
|
1851
|
+
async pullImage(ref, opts) {
|
|
1852
|
+
if (!opts?.force) {
|
|
1853
|
+
try {
|
|
1854
|
+
await this.docker.getImage(ref).inspect();
|
|
1855
|
+
return;
|
|
1856
|
+
} catch {
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
const executor = this.connectionOptions?.executor;
|
|
1860
|
+
if (executor) {
|
|
1861
|
+
await executor.exec(`docker pull ${sq(ref)}`, { timeout: 10 * 6e4 });
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
const stream = await this.docker.pull(ref);
|
|
1865
|
+
await new Promise((resolve, reject) => {
|
|
1866
|
+
this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
/** Is this image tag present on THIS daemon? Distinguishes a locally-built
|
|
1870
|
+
* image (must be transferred cross-server) from a registry tag (the target
|
|
1871
|
+
* just pulls it). */
|
|
1872
|
+
async imageExistsLocally(ref) {
|
|
1873
|
+
const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
|
|
1874
|
+
if (executor) {
|
|
1875
|
+
const out = await executor.exec(`docker image inspect ${sq(ref)} >/dev/null 2>&1 && echo yes || true`).catch(() => "");
|
|
1876
|
+
return out.trim() === "yes";
|
|
1877
|
+
}
|
|
1878
|
+
try {
|
|
1879
|
+
await this.docker.getImage(ref).inspect();
|
|
1880
|
+
return true;
|
|
1881
|
+
} catch {
|
|
1882
|
+
return false;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Stream `docker save <ref>` OUT of this daemon as a Readable — the source
|
|
1887
|
+
* half of a cross-server image move. No extra compression: image layers are
|
|
1888
|
+
* already gzip-compressed, so re-compressing burns CPU for ~nothing.
|
|
1889
|
+
*
|
|
1890
|
+
* Over SSH the save runs as a native `docker save` through the raw command
|
|
1891
|
+
* channel (rawExec) — the same reason pullImage/build avoid dockerode over the
|
|
1892
|
+
* tunnel: a streamed dockerode body over the streamlocal bridge is unreliable.
|
|
1893
|
+
* Local socket / TCP use dockerode's image.get() directly.
|
|
1894
|
+
*/
|
|
1895
|
+
async saveImage(ref) {
|
|
1896
|
+
const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
|
|
1897
|
+
if (executor?.rawExec) {
|
|
1898
|
+
const { stdout: stdout2, stderr, onClose } = await executor.rawExec(`docker save ${sq(ref)}`);
|
|
1899
|
+
let stderrBuf = "";
|
|
1900
|
+
stderr.on("data", (c) => {
|
|
1901
|
+
stderrBuf += c.toString();
|
|
1902
|
+
if (stderrBuf.length > 16 * 1024) stderrBuf = stderrBuf.slice(-16 * 1024);
|
|
1903
|
+
});
|
|
1904
|
+
stderr.resume();
|
|
1905
|
+
return { stdout: stdout2, awaitExit: onClose.then((code) => ({ code, stderr: stderrBuf.trim() })) };
|
|
1906
|
+
}
|
|
1907
|
+
const stdout = await this.docker.getImage(ref).get();
|
|
1908
|
+
const awaitExit = new Promise((resolve, reject) => {
|
|
1909
|
+
stdout.on("end", () => resolve({ code: 0, stderr: "" }));
|
|
1910
|
+
stdout.on("error", reject);
|
|
1911
|
+
});
|
|
1912
|
+
return { stdout, awaitExit };
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Load an image tar (a `docker save` stream) INTO this daemon — the target
|
|
1916
|
+
* half of a cross-server image move. Over SSH the tar streams into a native
|
|
1917
|
+
* `docker load` stdin (execWithInput); local socket / TCP use dockerode's
|
|
1918
|
+
* loadImage. Throws on a non-zero load. Returns the reference `docker load`
|
|
1919
|
+
* reported ("Loaded image( ID)?: <ref>") so the caller can retag: a
|
|
1920
|
+
* save-by-id load is untagged AND restores under the CONFIG image id, which
|
|
1921
|
+
* differs from the source ref — tagging by the source id would fail.
|
|
1922
|
+
*/
|
|
1923
|
+
async loadImage(body) {
|
|
1924
|
+
const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
|
|
1925
|
+
if (executor?.execWithInput) {
|
|
1926
|
+
const { code, stderr, stdout } = await executor.execWithInput(`docker load`, body);
|
|
1927
|
+
if (code !== 0) throw new Error(`docker load exited ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
|
|
1928
|
+
return parseLoadedImageRef(stdout);
|
|
1929
|
+
}
|
|
1930
|
+
const stream = await this.docker.loadImage(body);
|
|
1931
|
+
let loadOutput = "";
|
|
1932
|
+
await new Promise((resolve, reject) => {
|
|
1933
|
+
this.docker.modem.followProgress(
|
|
1934
|
+
stream,
|
|
1935
|
+
(err) => err ? reject(err) : resolve(),
|
|
1936
|
+
(ev) => {
|
|
1937
|
+
if (ev?.stream) loadOutput += ev.stream;
|
|
1938
|
+
}
|
|
1939
|
+
);
|
|
1940
|
+
});
|
|
1941
|
+
return parseLoadedImageRef(loadOutput);
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* Apply a tag to an image (`docker tag <source> <target>`). Used after a
|
|
1945
|
+
* save-by-ID / load: `docker save <id>` loads UNTAGGED, so the target daemon
|
|
1946
|
+
* needs the original tag re-applied — the adopted service's deploy `imageRef`
|
|
1947
|
+
* is that tag. `source` is typically the sha id, `target` the tag. Idempotent.
|
|
1948
|
+
*/
|
|
1949
|
+
async tagImage(source, target) {
|
|
1950
|
+
if (source === target) return;
|
|
1951
|
+
const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
|
|
1952
|
+
if (executor) {
|
|
1953
|
+
await executor.exec(`docker tag ${sq(source)} ${sq(target)}`);
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
const [repo, tag] = target.includes(":") ? [target.slice(0, target.lastIndexOf(":")), target.slice(target.lastIndexOf(":") + 1)] : [target, void 0];
|
|
1957
|
+
await this.docker.getImage(source).tag({ repo, ...tag ? { tag } : {} });
|
|
1958
|
+
}
|
|
1959
|
+
/** Every named volume on the host. */
|
|
1960
|
+
async listAllVolumes() {
|
|
1961
|
+
const res = await this.docker.listVolumes();
|
|
1962
|
+
return (res?.Volumes ?? []).map((v) => ({
|
|
1963
|
+
name: v.Name,
|
|
1964
|
+
driver: v.Driver,
|
|
1965
|
+
mountpoint: v.Mountpoint,
|
|
1966
|
+
labels: v.Labels ?? {},
|
|
1967
|
+
composeProject: v.Labels?.["com.docker.compose.project"] || void 0
|
|
1968
|
+
}));
|
|
1969
|
+
}
|
|
1970
|
+
/** Every network on the host. */
|
|
1971
|
+
async listAllNetworks() {
|
|
1972
|
+
const nets = await this.docker.listNetworks();
|
|
1973
|
+
return nets.map((n) => ({
|
|
1974
|
+
id: n.Id,
|
|
1975
|
+
name: n.Name,
|
|
1976
|
+
driver: n.Driver,
|
|
1977
|
+
labels: n.Labels ?? {},
|
|
1978
|
+
composeProject: n.Labels?.["com.docker.compose.project"] || void 0
|
|
1979
|
+
}));
|
|
1980
|
+
}
|
|
1981
|
+
// ── Observability ──────────────────────────────────────────────────────
|
|
1982
|
+
async getContainerInfo(containerId) {
|
|
1983
|
+
const container = this.docker.getContainer(containerId);
|
|
1984
|
+
let data;
|
|
1985
|
+
try {
|
|
1986
|
+
data = await container.inspect();
|
|
1987
|
+
} catch (err) {
|
|
1988
|
+
if (isDockerNotFoundError(err)) {
|
|
1989
|
+
return { containerId, status: "missing" };
|
|
1990
|
+
}
|
|
1991
|
+
throw err;
|
|
1992
|
+
}
|
|
1993
|
+
const statusMap = {
|
|
1994
|
+
running: "running",
|
|
1995
|
+
healthy: "running",
|
|
1996
|
+
starting: "running",
|
|
1997
|
+
restarting: "running",
|
|
1998
|
+
exited: "stopped",
|
|
1999
|
+
paused: "stopped",
|
|
2000
|
+
created: "stopped",
|
|
2001
|
+
dead: "failed",
|
|
2002
|
+
unhealthy: "failed"
|
|
2003
|
+
};
|
|
2004
|
+
const startedAt = data.State.StartedAt;
|
|
2005
|
+
const uptimeSeconds = startedAt && data.State.Running ? Math.floor((Date.now() - new Date(startedAt).getTime()) / 1e3) : void 0;
|
|
2006
|
+
const { ip, hostPort } = extractNetworkInfo(data);
|
|
2007
|
+
let status;
|
|
2008
|
+
if (data.State.Running) {
|
|
2009
|
+
status = "running";
|
|
2010
|
+
} else if (data.State.Paused) {
|
|
2011
|
+
status = "stopped";
|
|
2012
|
+
} else {
|
|
2013
|
+
const rawStatus = (data.State.Status ?? "").toLowerCase().trim();
|
|
2014
|
+
status = statusMap[rawStatus] ?? "stopped";
|
|
2015
|
+
}
|
|
2016
|
+
return {
|
|
2017
|
+
containerId,
|
|
2018
|
+
status,
|
|
2019
|
+
ip,
|
|
2020
|
+
hostPort,
|
|
2021
|
+
uptimeSeconds: uptimeSeconds && uptimeSeconds > 0 ? uptimeSeconds : void 0
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
async getRuntimeLogs(containerId, tail) {
|
|
2025
|
+
const container = this.docker.getContainer(containerId);
|
|
2026
|
+
const buffer = await container.logs({
|
|
2027
|
+
stdout: true,
|
|
2028
|
+
stderr: true,
|
|
2029
|
+
timestamps: true,
|
|
2030
|
+
tail: tail ?? 200
|
|
2031
|
+
});
|
|
2032
|
+
const raw = stripDockerHeaders(buffer);
|
|
2033
|
+
return raw.split("\n").filter(Boolean).map((line) => {
|
|
2034
|
+
const { timestamp, message } = parseTimestampedLine(line);
|
|
2035
|
+
return { timestamp, message, level: parseLogLevel(message) };
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
async streamRuntimeLogs(containerId, onLog, opts) {
|
|
2039
|
+
const container = this.docker.getContainer(containerId);
|
|
2040
|
+
const stream = await container.logs({
|
|
2041
|
+
stdout: true,
|
|
2042
|
+
stderr: true,
|
|
2043
|
+
timestamps: true,
|
|
2044
|
+
follow: true,
|
|
2045
|
+
tail: opts?.tail ?? 100
|
|
2046
|
+
});
|
|
2047
|
+
let destroyed = false;
|
|
2048
|
+
let buffer = "";
|
|
2049
|
+
stream.on("data", (chunk) => {
|
|
2050
|
+
if (destroyed) return;
|
|
2051
|
+
buffer += stripDockerChunkHeader(chunk).toString("utf-8");
|
|
2052
|
+
const lines = buffer.split("\n");
|
|
2053
|
+
buffer = lines.pop() ?? "";
|
|
2054
|
+
for (const line of lines) {
|
|
2055
|
+
if (!line) continue;
|
|
2056
|
+
const { timestamp, message } = parseTimestampedLine(line);
|
|
2057
|
+
onLog({ timestamp, message, level: parseLogLevel(message) });
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
stream.on("end", () => {
|
|
2061
|
+
if (buffer && !destroyed) {
|
|
2062
|
+
onLog({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), message: buffer, level: parseLogLevel(buffer) });
|
|
2063
|
+
buffer = "";
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
return () => {
|
|
2067
|
+
if (!destroyed) {
|
|
2068
|
+
destroyed = true;
|
|
2069
|
+
stream.destroy?.();
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
async getUsage(containerId) {
|
|
2074
|
+
const container = this.docker.getContainer(containerId);
|
|
2075
|
+
const stats = await container.stats({ stream: false });
|
|
2076
|
+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
|
2077
|
+
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
|
2078
|
+
const numCpus = stats.cpu_stats.online_cpus || 1;
|
|
2079
|
+
const cpuPercent = systemDelta > 0 ? cpuDelta / systemDelta * numCpus * 100 : 0;
|
|
2080
|
+
const memoryMb = (stats.memory_stats.usage ?? 0) / (1024 * 1024);
|
|
2081
|
+
let networkRxBytes = 0;
|
|
2082
|
+
let networkTxBytes = 0;
|
|
2083
|
+
if (stats.networks) {
|
|
2084
|
+
for (const iface of Object.values(stats.networks)) {
|
|
2085
|
+
networkRxBytes += iface.rx_bytes ?? 0;
|
|
2086
|
+
networkTxBytes += iface.tx_bytes ?? 0;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
let diskBytes = 0;
|
|
2090
|
+
if (stats.blkio_stats?.io_service_bytes_recursive) {
|
|
2091
|
+
for (const entry of stats.blkio_stats.io_service_bytes_recursive) {
|
|
2092
|
+
diskBytes += entry.value ?? 0;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
const diskMb = diskBytes / (1024 * 1024);
|
|
2096
|
+
return {
|
|
2097
|
+
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
|
2098
|
+
memoryMb: Math.round(memoryMb * 100) / 100,
|
|
2099
|
+
diskMb: Math.round(diskMb * 100) / 100,
|
|
2100
|
+
networkRxBytes,
|
|
2101
|
+
networkTxBytes
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
// ── Network ────────────────────────────────────────────────────────────
|
|
2105
|
+
async getContainerIp(containerId) {
|
|
2106
|
+
const container = this.docker.getContainer(containerId);
|
|
2107
|
+
const data = await container.inspect();
|
|
2108
|
+
for (const net2 of Object.values(data.NetworkSettings.Networks ?? {})) {
|
|
2109
|
+
if (net2.IPAddress) return net2.IPAddress;
|
|
2110
|
+
}
|
|
2111
|
+
return null;
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Open an interactive PTY shell inside a deployed container. Powers
|
|
2115
|
+
* the in-dashboard service terminal — see apps/api/src/modules/service-terminal/.
|
|
2116
|
+
*
|
|
2117
|
+
* Wire-up: dockerode's `container.exec({Tty: true, AttachStdin: true,
|
|
2118
|
+
* AttachStdout: true, AttachStderr: true})` returns an Exec handle.
|
|
2119
|
+
* Starting it with `{hijack: true, stdin: true}` gives a single bi-
|
|
2120
|
+
* directional Duplex carrying TTY bytes in both directions (when Tty
|
|
2121
|
+
* is true, stderr is merged into stdout — exactly what xterm expects).
|
|
2122
|
+
*
|
|
2123
|
+
* The returned ShellSession matches SshExecutor.openShell so the
|
|
2124
|
+
* websocket bridge in service-terminal.controller.ts is identical
|
|
2125
|
+
* across Docker + Cloud + SSH callers.
|
|
2126
|
+
*/
|
|
2127
|
+
async openServiceShell(containerId, opts) {
|
|
2128
|
+
const container = this.docker.getContainer(containerId);
|
|
2129
|
+
const cols = clampShellWindow(opts?.cols, 80, 1, 1e3);
|
|
2130
|
+
const rows = clampShellWindow(opts?.rows, 24, 1, 500);
|
|
2131
|
+
const term = opts?.term || "xterm-256color";
|
|
2132
|
+
const inspect = await container.inspect().catch(() => null);
|
|
2133
|
+
if (!inspect?.State.Running) {
|
|
2134
|
+
throw new Error(
|
|
2135
|
+
`Container ${containerId} is not running (status: ${inspect?.State.Status ?? "unknown"})`
|
|
2136
|
+
);
|
|
2137
|
+
}
|
|
2138
|
+
const exec = await container.exec({
|
|
2139
|
+
AttachStdin: true,
|
|
2140
|
+
AttachStdout: true,
|
|
2141
|
+
AttachStderr: true,
|
|
2142
|
+
Tty: true,
|
|
2143
|
+
Cmd: ["/bin/sh", "-lc", "exec $(command -v bash || echo /bin/sh)"],
|
|
2144
|
+
Env: [`TERM=${term}`]
|
|
2145
|
+
});
|
|
2146
|
+
const duplex = await exec.start({
|
|
2147
|
+
hijack: true,
|
|
2148
|
+
stdin: true,
|
|
2149
|
+
Tty: true
|
|
2150
|
+
});
|
|
2151
|
+
try {
|
|
2152
|
+
await exec.resize({ h: rows, w: cols });
|
|
2153
|
+
} catch {
|
|
2154
|
+
}
|
|
2155
|
+
const stdout = new PassThrough();
|
|
2156
|
+
const stderr = new PassThrough();
|
|
2157
|
+
duplex.on("data", (chunk) => stdout.write(chunk));
|
|
2158
|
+
duplex.on("end", () => {
|
|
2159
|
+
stdout.end();
|
|
2160
|
+
stderr.end();
|
|
2161
|
+
});
|
|
2162
|
+
duplex.on("error", () => {
|
|
2163
|
+
stdout.end();
|
|
2164
|
+
stderr.end();
|
|
2165
|
+
});
|
|
2166
|
+
const stdin = new Writable({
|
|
2167
|
+
write(chunk, _enc, cb) {
|
|
2168
|
+
duplex.write(chunk, (err) => cb(err ?? void 0));
|
|
2169
|
+
},
|
|
2170
|
+
final(cb) {
|
|
2171
|
+
try {
|
|
2172
|
+
duplex.end();
|
|
2173
|
+
} catch {
|
|
2174
|
+
}
|
|
2175
|
+
cb();
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
const closeListeners = [];
|
|
2179
|
+
let closed = false;
|
|
2180
|
+
const fireClose = (code, signal) => {
|
|
2181
|
+
if (closed) return;
|
|
2182
|
+
closed = true;
|
|
2183
|
+
for (const cb of closeListeners) {
|
|
2184
|
+
try {
|
|
2185
|
+
cb(code, signal);
|
|
2186
|
+
} catch {
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
duplex.on("close", () => {
|
|
2191
|
+
exec.inspect().then((info) => fireClose(info.ExitCode ?? null)).catch(() => fireClose(null));
|
|
2192
|
+
});
|
|
2193
|
+
return {
|
|
2194
|
+
stdin,
|
|
2195
|
+
stdout,
|
|
2196
|
+
stderr,
|
|
2197
|
+
setWindow: (c, r) => {
|
|
2198
|
+
const sc = clampShellWindow(c, 80, 1, 1e3);
|
|
2199
|
+
const sr = clampShellWindow(r, 24, 1, 500);
|
|
2200
|
+
void exec.resize({ h: sr, w: sc }).catch(() => void 0);
|
|
2201
|
+
},
|
|
2202
|
+
close: (_signal) => {
|
|
2203
|
+
try {
|
|
2204
|
+
duplex.end();
|
|
2205
|
+
} catch {
|
|
2206
|
+
}
|
|
2207
|
+
try {
|
|
2208
|
+
duplex.destroy();
|
|
2209
|
+
} catch {
|
|
2210
|
+
}
|
|
2211
|
+
},
|
|
2212
|
+
onClose: (cb) => {
|
|
2213
|
+
closeListeners.push(cb);
|
|
2214
|
+
}
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Non-interactive one-shot exec INSIDE a container (Tty:false ⇒ dockerode
|
|
2219
|
+
* multiplexes stdout/stderr, so demux the frames). Sibling of
|
|
2220
|
+
* `openServiceShell` for the advisory port probe — reads stdout + the exit
|
|
2221
|
+
* code. Runs in the CONTAINER, not on the daemon host (never use
|
|
2222
|
+
* `this.executor` here).
|
|
2223
|
+
*/
|
|
2224
|
+
async execInContainer(containerId, command) {
|
|
2225
|
+
const container = this.docker.getContainer(containerId);
|
|
2226
|
+
const inspect = await container.inspect().catch(() => null);
|
|
2227
|
+
if (!inspect?.State.Running) {
|
|
2228
|
+
throw new Error(
|
|
2229
|
+
`Container ${containerId} is not running (status: ${inspect?.State.Status ?? "unknown"})`
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
const exec = await container.exec({
|
|
2233
|
+
AttachStdout: true,
|
|
2234
|
+
AttachStderr: true,
|
|
2235
|
+
Tty: false,
|
|
2236
|
+
Cmd: ["sh", "-c", command]
|
|
2237
|
+
});
|
|
2238
|
+
const stream = await exec.start({ hijack: true, stdin: false });
|
|
2239
|
+
const stdoutChunks = [];
|
|
2240
|
+
const stdoutSink = new Writable({
|
|
2241
|
+
write(chunk, _enc, cb) {
|
|
2242
|
+
stdoutChunks.push(Buffer.from(chunk));
|
|
2243
|
+
cb();
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
const stderrSink = new Writable({
|
|
2247
|
+
write(_chunk, _enc, cb) {
|
|
2248
|
+
cb();
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
this.docker.modem.demuxStream(stream, stdoutSink, stderrSink);
|
|
2252
|
+
await new Promise((resolve, reject) => {
|
|
2253
|
+
stream.on("end", resolve);
|
|
2254
|
+
stream.on("close", resolve);
|
|
2255
|
+
stream.on("error", reject);
|
|
2256
|
+
});
|
|
2257
|
+
const info = await exec.inspect().catch(() => null);
|
|
2258
|
+
return {
|
|
2259
|
+
exitCode: info?.ExitCode ?? null,
|
|
2260
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8")
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
/** Command runner scoped to the inside of the container (advisory port probe). */
|
|
2264
|
+
async inContainerExecutor(containerId) {
|
|
2265
|
+
return {
|
|
2266
|
+
exec: async (command) => {
|
|
2267
|
+
const { exitCode, stdout } = await this.execInContainer(containerId, command);
|
|
2268
|
+
if (exitCode && exitCode !== 0) {
|
|
2269
|
+
throw new Error(stdout || `exec exited with code ${exitCode}`);
|
|
2270
|
+
}
|
|
2271
|
+
return stdout;
|
|
2272
|
+
}
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
// ── Compose / multi-service ────────────────────────────────────────────
|
|
2276
|
+
/**
|
|
2277
|
+
* Ensure a project-level Docker network exists.
|
|
2278
|
+
* All services in a compose project share this network and can
|
|
2279
|
+
* reach each other by service name as hostname.
|
|
2280
|
+
*/
|
|
2281
|
+
async ensureNetwork(slug) {
|
|
2282
|
+
const networkName = `openship-${slug}`;
|
|
2283
|
+
const critical = async () => {
|
|
2284
|
+
const networks = await this.docker.listNetworks({
|
|
2285
|
+
filters: { name: [networkName] }
|
|
2286
|
+
});
|
|
2287
|
+
const existing = networks.find((n) => n.Name === networkName);
|
|
2288
|
+
if (existing) return existing.Id;
|
|
2289
|
+
const network = await this.docker.createNetwork({
|
|
2290
|
+
Name: networkName,
|
|
2291
|
+
Driver: "bridge",
|
|
2292
|
+
Labels: { "openship.network": slug }
|
|
2293
|
+
});
|
|
2294
|
+
return network.id;
|
|
2295
|
+
};
|
|
2296
|
+
return this.provisionLock ? this.provisionLock.run(critical) : critical();
|
|
2297
|
+
}
|
|
2298
|
+
async ensureServiceGroup(config) {
|
|
2299
|
+
void config.deploymentId;
|
|
2300
|
+
const networkId = await this.ensureNetwork(config.slug);
|
|
2301
|
+
await this.reconcileNetworkMembership(networkId, config.projectId);
|
|
2302
|
+
return { id: networkId };
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Join already-running containers (migration attach-live reuse) to a project's
|
|
2306
|
+
* `openship-<slug>` network with a DNS alias each — so a natively-deployed
|
|
2307
|
+
* service in the SAME project resolves them by name (e.g. a freshly-built `web`
|
|
2308
|
+
* reaching the reused `postgres:5432`). These containers keep their ORIGINAL
|
|
2309
|
+
* openship labels, so the label-scoped reconcileNetworkMembership never joins
|
|
2310
|
+
* them; this is the explicit, additive join (a network connect does NOT restart
|
|
2311
|
+
* the container or touch its volumes). Idempotent + best-effort per member.
|
|
2312
|
+
*/
|
|
2313
|
+
async joinServiceGroupContainers(slug, members) {
|
|
2314
|
+
if (members.length === 0) return;
|
|
2315
|
+
const networkId = await this.ensureNetwork(slug);
|
|
2316
|
+
const network = this.docker.getNetwork(networkId);
|
|
2317
|
+
for (const m of members) {
|
|
2318
|
+
if (!m.containerId) continue;
|
|
2319
|
+
try {
|
|
2320
|
+
await network.connect({
|
|
2321
|
+
Container: m.containerId,
|
|
2322
|
+
EndpointConfig: m.alias ? { Aliases: [m.alias] } : {}
|
|
2323
|
+
});
|
|
2324
|
+
} catch (err) {
|
|
2325
|
+
const msg = err?.message ?? "";
|
|
2326
|
+
if (!/already exists|already connected/i.test(msg)) {
|
|
2327
|
+
console.warn(
|
|
2328
|
+
`[docker] group join failed for ${m.containerId.slice(0, 12)} (${m.alias}): ${msg}`
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Guard for GRANDFATHERED (non-namespaced) services: a bare named volume that
|
|
2336
|
+
* another project's container already mounts is a cross-project collision —
|
|
2337
|
+
* the exact bug (two projects sharing one postgres volume) this change
|
|
2338
|
+
* prevents for new services. New namespaced services can't hit this (their
|
|
2339
|
+
* volume name is project-unique by construction).
|
|
2340
|
+
*
|
|
2341
|
+
* Only a FRESH claim is blocked: if THIS project already mounts the name
|
|
2342
|
+
* (it's the incumbent) a redeploy is never blocked — otherwise, during an
|
|
2343
|
+
* active collision, whichever owner redeployed first would be locked out of
|
|
2344
|
+
* its own release. Best-effort on the list call; throws ONLY on a real
|
|
2345
|
+
* newcomer collision so the operator renames it.
|
|
2346
|
+
*/
|
|
2347
|
+
async assertNoForeignNamedVolumeCollision(config) {
|
|
2348
|
+
const named = /* @__PURE__ */ new Set();
|
|
2349
|
+
for (const spec of config.volumes) {
|
|
2350
|
+
const body = spec.replace(/:(ro|rw|z|Z|nocopy)$/, "");
|
|
2351
|
+
const parts = body.split(":");
|
|
2352
|
+
if (parts.length < 2) continue;
|
|
2353
|
+
const source = parts[0];
|
|
2354
|
+
if (isHostPathSource(source)) continue;
|
|
2355
|
+
named.add(source);
|
|
2356
|
+
}
|
|
2357
|
+
if (named.size === 0) return;
|
|
2358
|
+
let containers;
|
|
2359
|
+
try {
|
|
2360
|
+
containers = await this.docker.listContainers({ all: true });
|
|
2361
|
+
} catch {
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
const ownNames = /* @__PURE__ */ new Set();
|
|
2365
|
+
const foreign = /* @__PURE__ */ new Map();
|
|
2366
|
+
for (const c of containers) {
|
|
2367
|
+
const owner = c.Labels?.["openship.project"];
|
|
2368
|
+
if (!owner) continue;
|
|
2369
|
+
for (const m of c.Mounts ?? []) {
|
|
2370
|
+
if (m.Type !== "volume" || !m.Name || !named.has(m.Name)) continue;
|
|
2371
|
+
if (owner === config.projectId) ownNames.add(m.Name);
|
|
2372
|
+
else if (!foreign.has(m.Name)) foreign.set(m.Name, c.Names?.[0]?.replace(/^\//, "") ?? owner);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
for (const [name, other] of foreign) {
|
|
2376
|
+
if (ownNames.has(name)) continue;
|
|
2377
|
+
throw new Error(
|
|
2378
|
+
`Volume "${name}" is already used by another project's container "${other}". Rename this service's volume to a project-unique name before deploying, to avoid overwriting the other project's data.`
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Ensure every container belonging to this project is attached to
|
|
2384
|
+
* `networkId` with its service-name alias. Idempotent (already-connected is a
|
|
2385
|
+
* no-op) and best-effort (never throws). Normal/partial/smart redeploys don't
|
|
2386
|
+
* strand containers (the network is reused by name → stable id); this heals
|
|
2387
|
+
* the narrow case where the network's identity changed out-of-band (docker
|
|
2388
|
+
* network prune/rm, daemon/host rebuild) and surviving containers fell off it.
|
|
2389
|
+
*/
|
|
2390
|
+
async reconcileNetworkMembership(networkId, projectId) {
|
|
2391
|
+
let containers;
|
|
2392
|
+
try {
|
|
2393
|
+
containers = await this.docker.listContainers({
|
|
2394
|
+
all: true,
|
|
2395
|
+
filters: { label: [`openship.project=${projectId}`] }
|
|
2396
|
+
});
|
|
2397
|
+
} catch {
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
const network = this.docker.getNetwork(networkId);
|
|
2401
|
+
for (const c of containers) {
|
|
2402
|
+
const onNetwork = Object.values(c.NetworkSettings?.Networks ?? {}).some(
|
|
2403
|
+
(n) => n?.NetworkID === networkId
|
|
2404
|
+
);
|
|
2405
|
+
if (onNetwork) continue;
|
|
2406
|
+
const service = c.Labels?.["openship.service"];
|
|
2407
|
+
try {
|
|
2408
|
+
await network.connect({
|
|
2409
|
+
Container: c.Id,
|
|
2410
|
+
EndpointConfig: service ? { Aliases: [service] } : {}
|
|
2411
|
+
});
|
|
2412
|
+
} catch (err) {
|
|
2413
|
+
const msg = err?.message ?? "";
|
|
2414
|
+
if (!/already exists|already connected/i.test(msg)) {
|
|
2415
|
+
console.warn(
|
|
2416
|
+
`[docker] reconcile connect failed for ${c.Id.slice(0, 12)} \u2192 ${networkId.slice(0, 12)}: ${msg}`
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Attach every container of `projectId` to the given networks (by name) — for
|
|
2424
|
+
* cross-project service links: a consumer joins a linked database app's
|
|
2425
|
+
* `openship-<slug>` network so it resolves that app's service alias
|
|
2426
|
+
* (`mongo:27017`) with no public port. Best-effort + idempotent; a network that
|
|
2427
|
+
* doesn't exist (source not deployed) is skipped and nothing here ever throws —
|
|
2428
|
+
* a link networking failure must never fail the consumer's deploy.
|
|
2429
|
+
*/
|
|
2430
|
+
async attachToExternalNetworks(projectId, networkNames) {
|
|
2431
|
+
if (networkNames.length === 0) return;
|
|
2432
|
+
let containers;
|
|
2433
|
+
try {
|
|
2434
|
+
containers = await this.docker.listContainers({
|
|
2435
|
+
all: true,
|
|
2436
|
+
filters: { label: [`openship.project=${projectId}`] }
|
|
2437
|
+
});
|
|
2438
|
+
} catch {
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
for (const name of networkNames) {
|
|
2442
|
+
const network = this.docker.getNetwork(name);
|
|
2443
|
+
let netId;
|
|
2444
|
+
try {
|
|
2445
|
+
const info = await network.inspect();
|
|
2446
|
+
netId = info.Id;
|
|
2447
|
+
} catch {
|
|
2448
|
+
continue;
|
|
2449
|
+
}
|
|
2450
|
+
for (const c of containers) {
|
|
2451
|
+
const onNetwork = Object.values(c.NetworkSettings?.Networks ?? {}).some(
|
|
2452
|
+
(n) => n?.NetworkID === netId
|
|
2453
|
+
);
|
|
2454
|
+
if (onNetwork) continue;
|
|
2455
|
+
try {
|
|
2456
|
+
await network.connect({ Container: c.Id });
|
|
2457
|
+
} catch (err) {
|
|
2458
|
+
const msg = err?.message ?? "";
|
|
2459
|
+
if (!/already exists|already connected/i.test(msg)) {
|
|
2460
|
+
console.warn(
|
|
2461
|
+
`[docker] link-connect failed for ${c.Id.slice(0, 12)} \u2192 ${name}: ${msg}`
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
/** Remove a project network (best-effort). */
|
|
2469
|
+
async removeNetwork(slug) {
|
|
2470
|
+
const networkName = `openship-${slug}`;
|
|
2471
|
+
try {
|
|
2472
|
+
const network = this.docker.getNetwork(networkName);
|
|
2473
|
+
await network.remove();
|
|
2474
|
+
} catch {
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Deploy a single service container on a project network.
|
|
2479
|
+
* Unlike `deploy()` which binds to a random host port,
|
|
2480
|
+
* service containers join the project network with their service name as hostname.
|
|
2481
|
+
* External port bindings are only created for services that explicitly expose ports.
|
|
2482
|
+
*/
|
|
2483
|
+
async deployServiceWorkload(group, config, onLog) {
|
|
2484
|
+
const log = onLog ?? (() => {
|
|
2485
|
+
});
|
|
2486
|
+
const containerName = `openship-${config.slug}-${config.serviceName}`;
|
|
2487
|
+
try {
|
|
2488
|
+
const existing = this.docker.getContainer(containerName);
|
|
2489
|
+
await existing.remove({ force: true });
|
|
2490
|
+
} catch {
|
|
2491
|
+
}
|
|
2492
|
+
const env = [
|
|
2493
|
+
...config.publicPort && config.environment.PORT === void 0 ? [`PORT=${config.publicPort}`] : [],
|
|
2494
|
+
...Object.entries(config.environment).map(([k, v]) => `${k}=${v}`)
|
|
2495
|
+
];
|
|
2496
|
+
const cmd = config.command ? ["sh", "-c", config.command] : void 0;
|
|
2497
|
+
const { exposedPorts, portBindings } = parsePortBindings(config.ports);
|
|
2498
|
+
if (!config.namespaceVolumes) {
|
|
2499
|
+
await this.assertNoForeignNamedVolumeCollision(config);
|
|
2500
|
+
}
|
|
2501
|
+
const scopedBinds = scopeVolumeBinds(config.slug, config.volumes, config.namespaceVolumes);
|
|
2502
|
+
const binds = scopedBinds.length > 0 ? scopedBinds : void 0;
|
|
2503
|
+
const restartPolicy = resolveRestartPolicy(config.restart);
|
|
2504
|
+
const healthcheck = toDockerHealthcheck(config.advanced?.healthcheck);
|
|
2505
|
+
log({
|
|
2506
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2507
|
+
message: `Creating service container ${containerName} from ${config.image}...
|
|
2508
|
+
`,
|
|
2509
|
+
level: "info"
|
|
2510
|
+
});
|
|
2511
|
+
if (!config.image.startsWith("openship/")) {
|
|
2512
|
+
try {
|
|
2513
|
+
log({
|
|
2514
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2515
|
+
message: `Pulling image ${config.image}...
|
|
2516
|
+
`,
|
|
2517
|
+
level: "info"
|
|
2518
|
+
});
|
|
2519
|
+
await this.pullImage(config.image, { force: config.forcePull });
|
|
2520
|
+
} catch (err) {
|
|
2521
|
+
log({
|
|
2522
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2523
|
+
message: `Failed to pull ${config.image}: ${err}
|
|
2524
|
+
`,
|
|
2525
|
+
level: "error"
|
|
2526
|
+
});
|
|
2527
|
+
throw err;
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
const container = await this.docker.createContainer({
|
|
2531
|
+
name: containerName,
|
|
2532
|
+
Image: config.image,
|
|
2533
|
+
Cmd: cmd,
|
|
2534
|
+
Env: env,
|
|
2535
|
+
Hostname: config.serviceName,
|
|
2536
|
+
Labels: {
|
|
2537
|
+
...this.labels({
|
|
2538
|
+
deploymentId: config.deploymentId,
|
|
2539
|
+
projectId: config.projectId
|
|
2540
|
+
}),
|
|
2541
|
+
"openship.service": config.serviceName
|
|
2542
|
+
},
|
|
2543
|
+
...healthcheck && { Healthcheck: healthcheck },
|
|
2544
|
+
ExposedPorts: exposedPorts,
|
|
2545
|
+
HostConfig: {
|
|
2546
|
+
RestartPolicy: restartPolicy,
|
|
2547
|
+
...config.resources?.memoryMb && {
|
|
2548
|
+
Memory: config.resources.memoryMb * 1024 * 1024
|
|
2549
|
+
},
|
|
2550
|
+
...config.resources?.cpuCores && {
|
|
2551
|
+
CpuShares: Math.round(config.resources.cpuCores * 1024)
|
|
2552
|
+
},
|
|
2553
|
+
PortBindings: portBindings,
|
|
2554
|
+
Binds: binds,
|
|
2555
|
+
NetworkMode: group.id
|
|
2556
|
+
},
|
|
2557
|
+
NetworkingConfig: {
|
|
2558
|
+
EndpointsConfig: {
|
|
2559
|
+
[group.id]: {
|
|
2560
|
+
Aliases: [config.serviceName]
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2565
|
+
try {
|
|
2566
|
+
await container.start();
|
|
2567
|
+
} catch (startErr) {
|
|
2568
|
+
try {
|
|
2569
|
+
await container.remove({ force: true });
|
|
2570
|
+
} catch {
|
|
2571
|
+
}
|
|
2572
|
+
throw startErr;
|
|
2573
|
+
}
|
|
2574
|
+
const data = await container.inspect();
|
|
2575
|
+
const { ip, hostPort } = extractNetworkInfo(data);
|
|
2576
|
+
const imageDigest = await this.resolveImageDigest(config.image).catch(() => void 0);
|
|
2577
|
+
log({
|
|
2578
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2579
|
+
message: `Service ${config.serviceName} started (${container.id.slice(0, 12)})${ip ? ` at ${ip}` : ""}.
|
|
2580
|
+
`,
|
|
2581
|
+
level: "info"
|
|
2582
|
+
});
|
|
2583
|
+
return {
|
|
2584
|
+
containerId: container.id,
|
|
2585
|
+
status: "running",
|
|
2586
|
+
ip,
|
|
2587
|
+
hostPort,
|
|
2588
|
+
imageDigest
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Resolve the `repo@sha256:…` digest of a pulled image from its RepoDigests,
|
|
2593
|
+
* preferring the entry whose repo matches `ref` (an image may carry digests
|
|
2594
|
+
* for several repos). Returns undefined for a locally-built image (no
|
|
2595
|
+
* RepoDigests) or any inspect error. Works local + SSH (plain inspect, no
|
|
2596
|
+
* followProgress). Never throws.
|
|
2597
|
+
*/
|
|
2598
|
+
async resolveImageDigest(ref) {
|
|
2599
|
+
const info = await this.docker.getImage(ref).inspect();
|
|
2600
|
+
const repoDigests = info?.RepoDigests ?? [];
|
|
2601
|
+
if (repoDigests.length === 0) return void 0;
|
|
2602
|
+
const noDigest = ref.split("@")[0];
|
|
2603
|
+
const lastColon = noDigest.lastIndexOf(":");
|
|
2604
|
+
const lastSlash = noDigest.lastIndexOf("/");
|
|
2605
|
+
const repo = lastColon > lastSlash ? noDigest.slice(0, lastColon) : noDigest;
|
|
2606
|
+
return repoDigests.find((d) => d.startsWith(`${repo}@`)) ?? repoDigests[0];
|
|
2607
|
+
}
|
|
2608
|
+
async deployService(config, onLog) {
|
|
2609
|
+
return this.deployServiceWorkload({ id: config.networkId }, config, onLog);
|
|
2610
|
+
}
|
|
2611
|
+
};
|
|
2612
|
+
|
|
2613
|
+
export {
|
|
2614
|
+
scopedVolumeName,
|
|
2615
|
+
isHostPathSource,
|
|
2616
|
+
DockerRuntime
|
|
2617
|
+
};
|