openship 0.5.0 → 0.6.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.
Files changed (103) hide show
  1. package/README.md +26 -1
  2. package/dist/bare-FSUAM5V5.js +23 -0
  3. package/dist/{bowser-O3AEA6AN.js → bowser-HNOTPGJO.js} +2 -2
  4. package/dist/{chunk-5MZ624VX.js → chunk-2GWYYHOR.js} +1 -1
  5. package/dist/chunk-3FYIXVX5.js +8051 -0
  6. package/dist/{chunk-ILFETZOM.js → chunk-3LDWWY7U.js} +21 -1
  7. package/dist/{chunk-W73X43R7.js → chunk-3NQQXMDR.js} +3 -3
  8. package/dist/{chunk-OWED2GRM.js → chunk-42WQABXM.js} +2 -2
  9. package/dist/{chunk-TYVME3XD.js → chunk-6FH4AXVY.js} +20 -3
  10. package/dist/{chunk-ZCOQJCZO.js → chunk-6ODUBKUB.js} +3 -3
  11. package/dist/{chunk-DYPH4HCI.js → chunk-AO252K7Z.js} +1 -1
  12. package/dist/{chunk-K2RPVGPN.js → chunk-B3CE2OMR.js} +14 -12
  13. package/dist/{chunk-IXCHURVK.js → chunk-DOD2BXHQ.js} +4 -4
  14. package/dist/{chunk-VWPEKOH6.js → chunk-DRK3MEDS.js} +3 -3
  15. package/dist/{chunk-V7BZMGOT.js → chunk-EMXI4UCU.js} +5 -5
  16. package/dist/{chunk-NNPEG6HM.js → chunk-EP2TNALB.js} +2 -2
  17. package/dist/{chunk-NXFIRK2Z.js → chunk-GUQ3QCJ7.js} +573 -459
  18. package/dist/chunk-I3DPMTSF.js +29 -0
  19. package/dist/{chunk-KZOBYJUW.js → chunk-IW2UKBVF.js} +3 -3
  20. package/dist/{chunk-YWMODKWL.js → chunk-IXM5245F.js} +165 -13
  21. package/dist/{chunk-KNTJ7PSF.js → chunk-JIMUYEKN.js} +4 -4
  22. package/dist/chunk-OGY63DGD.js +705 -0
  23. package/dist/{chunk-O6ZZF52V.js → chunk-ONKEYSMY.js} +2 -2
  24. package/dist/{chunk-UMQP7SYT.js → chunk-OTLSJYAC.js} +1316 -53
  25. package/dist/{chunk-EI575GHN.js → chunk-PDW4L3NV.js} +3 -3
  26. package/dist/{chunk-JLKKYUZA.js → chunk-PSZSKEPA.js} +7 -7
  27. package/dist/{chunk-KI2EY3WB.js → chunk-PXKCZB2I.js} +1 -1
  28. package/dist/{chunk-RB35YN3J.js → chunk-QEH4QUBX.js} +391 -63
  29. package/dist/{chunk-6APIPIJY.js → chunk-QWHBRY2K.js} +5 -5
  30. package/dist/{chunk-6B5YCFFV.js → chunk-RVER2EOO.js} +5 -5
  31. package/dist/{chunk-Q2CIRAOK.js → chunk-RXQBD4ZY.js} +1 -1
  32. package/dist/{chunk-6LKHXB6T.js → chunk-TWJCLD4W.js} +48 -13
  33. package/dist/{chunk-I6PSTQIR.js → chunk-VDDMADOF.js} +3 -3
  34. package/dist/{chunk-BS2AXYZX.js → chunk-VE2YOK4I.js} +626 -214
  35. package/dist/{chunk-6PIYRG5I.js → chunk-VZ2ZHPS2.js} +52 -42
  36. package/dist/{chunk-AVIB7CT7.js → chunk-WNWQBB7F.js} +2 -2
  37. package/dist/{chunk-FRNAR33O.js → chunk-XZYFZ6A5.js} +3 -3
  38. package/dist/{chunk-RPHRPFEH.js → chunk-ZCFVHA25.js} +1 -1
  39. package/dist/{chunk-FPRHYBY2.js → chunk-ZFJXPCQP.js} +1 -1
  40. package/dist/{chunk-JYGD5WSN.js → chunk-ZWLDLTJP.js} +1 -1
  41. package/dist/cloud-E5M6VMCG.js +31 -0
  42. package/dist/{cloud-7QYOGF7F.js → cloud-KRTZ2IL5.js} +3 -3
  43. package/dist/{detect-BAJMY6KM.js → detect-ALF4GXRX.js} +7 -8
  44. package/dist/{dist-IQJ6H6UZ.js → dist-BM6NPWIH.js} +3 -3
  45. package/dist/{dist-es-X3B5QYKB.js → dist-es-2YRDXMUU.js} +6 -6
  46. package/dist/{dist-es-OSNJBLXB.js → dist-es-CCVZEFRZ.js} +5 -5
  47. package/dist/{dist-es-VNKHQVIB.js → dist-es-IOH6G4GM.js} +4 -4
  48. package/dist/{dist-es-RYHIPOTN.js → dist-es-JGBPABJI.js} +14 -14
  49. package/dist/{dist-es-JSTDUS7Q.js → dist-es-KWSMDPIP.js} +6 -6
  50. package/dist/{dist-es-5DY7K32L.js → dist-es-UYDDOVZ3.js} +6 -6
  51. package/dist/{dist-es-WV4AMOII.js → dist-es-XUOR4NQN.js} +8 -8
  52. package/dist/docker-QECQJ5P4.js +32 -0
  53. package/dist/docker-edge-executor-62N64MGS.js +17 -0
  54. package/dist/edge-import-CQWHN2Q3.js +48 -0
  55. package/dist/ensure-container-edge-MJVXJQG5.js +32 -0
  56. package/dist/{event-streams-EHRAKL75.js → event-streams-TR4GAC53.js} +4 -4
  57. package/dist/{executor-LOYMPU67.js → executor-JELARVKC.js} +8 -8
  58. package/dist/index.js +1170 -459
  59. package/dist/{loadSso-46FQRIZS.js → loadSso-UGF4JMRK.js} +10 -10
  60. package/dist/{local-executor-V7U5VOZW.js → local-executor-WOFZD72X.js} +4 -4
  61. package/dist/nginx-AXGNWRKZ.js +31 -0
  62. package/dist/node-entry.js +4 -0
  63. package/dist/{noop-FMMFG2EZ.js → noop-NJ72F46T.js} +3 -3
  64. package/dist/openresty-lua-IE4P5YQX.js +81 -0
  65. package/dist/server/index.js +23172 -7994
  66. package/dist/server/lua/geo_country.lua +31 -6
  67. package/dist/server/lua/maxminddb.lua +403 -0
  68. package/dist/server/lua/mgmt_api.lua +431 -68
  69. package/dist/server/lua/pipe_log.lua +10 -4
  70. package/dist/server/lua/site_logger.lua +220 -12
  71. package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
  72. package/dist/server/migrations/0083_service_incident.sql +84 -0
  73. package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
  74. package/dist/server/migrations/0085_resource_usage.sql +68 -0
  75. package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
  76. package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
  77. package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
  78. package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
  79. package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
  80. package/dist/server/migrations/0091_project_server_id.sql +26 -0
  81. package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
  82. package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
  83. package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
  84. package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
  85. package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
  86. package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
  87. package/dist/server/migrations/meta/_journal.json +112 -0
  88. package/dist/setup-FA4PW4NU.js +21 -0
  89. package/dist/{signin-ITOPKKUB.js → signin-3M3UPB6K.js} +10 -10
  90. package/dist/{sso-oidc-NON2PD7Y.js → sso-oidc-3VDIYOEJ.js} +10 -10
  91. package/dist/{sts-HHFFSAY2.js → sts-B3WIYLXR.js} +9 -9
  92. package/package.json +6 -3
  93. package/dist/bare-VRGE5MXH.js +0 -23
  94. package/dist/chunk-CUJXGK7R.js +0 -3167
  95. package/dist/chunk-EAAGSQXN.js +0 -377
  96. package/dist/cloud-3M2WZ624.js +0 -30
  97. package/dist/docker-2NGBRJFJ.js +0 -18
  98. package/dist/docker-edge-executor-5I4M5QT7.js +0 -13
  99. package/dist/edge-import-RIWNEIXQ.js +0 -47
  100. package/dist/ensure-container-edge-3NSYKHRX.js +0 -31
  101. package/dist/nginx-QE4DOM5J.js +0 -24
  102. package/dist/openresty-lua-BTAKECCF.js +0 -42
  103. package/dist/setup-KELALK7H.js +0 -20
@@ -1,3167 +0,0 @@
1
- #!/usr/bin/env sh
2
- ":" //# ; exec "$(command -v bun || command -v node)" "$0" "$@"
3
- import { createRequire as __ospCreateRequire } from "node:module";
4
- const require = __ospCreateRequire(import.meta.url);
5
- import {
6
- createDockerBuildContext,
7
- generateDockerfile,
8
- prepareSourceTree,
9
- resolveDockerfileCandidates,
10
- resolveServiceDockerfile,
11
- staticBuilderOutputPath
12
- } from "./chunk-V7BZMGOT.js";
13
- import {
14
- BuildLogger,
15
- assembleGitClone,
16
- materializeGitSsh,
17
- parseLogLevel,
18
- shellGitSshWriter,
19
- sq,
20
- transferLocalDirectory
21
- } from "./chunk-I6PSTQIR.js";
22
- import {
23
- connectSshClient,
24
- execSshCommand,
25
- openSshExecChannel,
26
- openSshUnixSocket
27
- } from "./chunk-ZCOQJCZO.js";
28
- import {
29
- isRuntimeNotFoundError
30
- } from "./chunk-RPHRPFEH.js";
31
- import {
32
- formatCpuCores,
33
- formatMemoryMb,
34
- hasCpuLimit,
35
- hasMemoryLimit,
36
- safeErrorMessage,
37
- withTimeout
38
- } from "./chunk-UMQP7SYT.js";
39
-
40
- // ../../packages/adapters/src/runtime/docker.ts
41
- import Dockerode from "dockerode";
42
- import { PassThrough, Writable } from "stream";
43
-
44
- // ../../packages/adapters/src/runtime/source-tarball.ts
45
- function githubTarballUrl(repoUrl, ref) {
46
- if (!repoUrl || !ref) return null;
47
- const m = repoUrl.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/i);
48
- if (!m) return null;
49
- const [, owner, repo] = m;
50
- if (!owner || !repo) return null;
51
- return `https://api.github.com/repos/${owner}/${repo}/tarball/${ref}`;
52
- }
53
- async function downloadTarballOnRemote(executor, opts) {
54
- const dest = sq(opts.destDir);
55
- const tmp = sq(`${opts.destDir}.opsh-src.tar.gz`);
56
- const authExport = opts.token ? `export OPSH_GH_TOK=${sq(opts.token)}; ` : "";
57
- const authHeader = opts.token ? `-H "Authorization: Bearer $OPSH_GH_TOK" ` : "";
58
- 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}`;
59
- const { code } = await executor.streamExec(cmd, (entry) => opts.onLog?.(entry));
60
- if (code !== 0) {
61
- throw new Error(`tarball download exited with code ${code}`);
62
- }
63
- }
64
-
65
- // ../../packages/adapters/src/runtime/volume-namespace.ts
66
- var MODE_SUFFIX = /:(ro|rw|z|Z|nocopy)$/;
67
- function scopedVolumeName(slug, name) {
68
- return `openship-${slug}-${name}`;
69
- }
70
- function isHostPathSource(source) {
71
- return source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.startsWith("~");
72
- }
73
- function scopeVolumeBinds(slug, rawVolumes, enabled) {
74
- if (!enabled) return rawVolumes;
75
- const prefix = `openship-${slug}-`;
76
- return rawVolumes.map((spec) => {
77
- const modeMatch = spec.match(MODE_SUFFIX);
78
- const mode = modeMatch ? modeMatch[0] : "";
79
- const body = mode ? spec.slice(0, -mode.length) : spec;
80
- const parts = body.split(":");
81
- if (parts.length < 2) return spec;
82
- const source = parts[0];
83
- if (isHostPathSource(source) || source.startsWith(prefix)) return spec;
84
- parts[0] = scopedVolumeName(slug, source);
85
- return parts.join(":") + mode;
86
- });
87
- }
88
-
89
- // ../../packages/adapters/src/runtime/docker-exec-stream.ts
90
- import net from "net";
91
- import tls from "tls";
92
- import { Duplex } from "stream";
93
- var HEADER_END = "\r\n\r\n";
94
- var UPGRADE_TIMEOUT_MS = 2e4;
95
- function connect(conn) {
96
- if (conn.socketPath) return net.connect({ path: conn.socketPath });
97
- const port = Number(conn.port ?? (conn.protocol === "https" ? 443 : 80));
98
- const host = conn.host ?? "127.0.0.1";
99
- if (conn.protocol === "https") {
100
- return tls.connect({ host, port, ca: conn.ca, cert: conn.cert, key: conn.key });
101
- }
102
- return net.connect({ port, host });
103
- }
104
- function bridgeSocket(socket, leftover) {
105
- const out = new Duplex({
106
- read() {
107
- socket.resume();
108
- },
109
- write(chunk, _enc, cb) {
110
- socket.write(chunk, (err) => cb(err ?? null));
111
- },
112
- final(cb) {
113
- if (!socket.destroyed) socket.end();
114
- cb();
115
- },
116
- destroy(err, cb) {
117
- socket.destroy(err ?? void 0);
118
- cb(err);
119
- }
120
- });
121
- let readEnded = false;
122
- const endRead = () => {
123
- if (readEnded) return;
124
- readEnded = true;
125
- out.push(null);
126
- };
127
- if (leftover.length > 0) out.push(leftover);
128
- socket.on("data", (chunk) => {
129
- if (!out.push(chunk)) socket.pause();
130
- });
131
- socket.on("end", endRead);
132
- socket.on("error", (err) => out.destroy(err));
133
- socket.on("close", () => {
134
- endRead();
135
- if (!out.writableEnded) out.end();
136
- });
137
- return out;
138
- }
139
- function startExecStream(conn, execId, opts) {
140
- return new Promise((resolve, reject) => {
141
- const socket = connect(conn);
142
- let settled = false;
143
- let head = Buffer.alloc(0);
144
- const fail = (err) => {
145
- if (settled) return;
146
- settled = true;
147
- clearTimeout(timer);
148
- socket.destroy();
149
- reject(err);
150
- };
151
- const timer = setTimeout(
152
- () => fail(new Error(`docker exec upgrade timed out after ${UPGRADE_TIMEOUT_MS}ms`)),
153
- UPGRADE_TIMEOUT_MS
154
- );
155
- const onData = (chunk) => {
156
- if (settled) return;
157
- head = head.length === 0 ? Buffer.from(chunk) : Buffer.concat([head, chunk]);
158
- const text = head.toString("latin1");
159
- const end = text.indexOf(HEADER_END);
160
- if (end === -1) {
161
- if (head.length > 64 * 1024) fail(new Error("docker exec upgrade: response head too large"));
162
- return;
163
- }
164
- const statusLine = text.slice(0, text.indexOf("\r\n"));
165
- if (!/ 101 /.test(statusLine)) {
166
- const body2 = text.slice(end + HEADER_END.length).trim();
167
- fail(new Error(`docker exec start failed: ${statusLine}${body2 ? ` \u2014 ${body2}` : ""}`));
168
- return;
169
- }
170
- settled = true;
171
- clearTimeout(timer);
172
- socket.removeListener("data", onData);
173
- socket.removeListener("error", fail);
174
- socket.removeListener("close", onEarlyClose);
175
- const headBytes = Buffer.byteLength(text.slice(0, end + HEADER_END.length), "latin1");
176
- resolve(bridgeSocket(socket, head.subarray(headBytes)));
177
- };
178
- const onEarlyClose = () => fail(new Error("docker exec start: connection closed before upgrade"));
179
- socket.on("data", onData);
180
- socket.on("error", fail);
181
- socket.on("close", onEarlyClose);
182
- const body = JSON.stringify({ Detach: false, Tty: opts.tty });
183
- socket.write(
184
- `POST /exec/${execId}/start HTTP/1.1\r
185
- Host: ${conn.socketPath ? "localhost" : conn.host ?? "localhost"}\r
186
- Content-Type: application/json\r
187
- Content-Length: ${Buffer.byteLength(body)}\r
188
- Connection: Upgrade\r
189
- Upgrade: tcp\r
190
- \r
191
- ` + body
192
- );
193
- });
194
- }
195
- function daemonConnectionFrom(docker) {
196
- const m = docker.modem ?? {};
197
- return {
198
- socketPath: typeof m.socketPath === "string" ? m.socketPath : void 0,
199
- host: typeof m.host === "string" ? m.host : void 0,
200
- port: typeof m.port === "number" || typeof m.port === "string" ? m.port : void 0,
201
- protocol: typeof m.protocol === "string" ? m.protocol : void 0,
202
- ca: m.ca,
203
- cert: m.cert,
204
- key: m.key
205
- };
206
- }
207
-
208
- // ../../packages/adapters/src/runtime/compose-cmd.ts
209
- function resolveComposeCmd(config) {
210
- if (config.commandArgv != null) return config.commandArgv;
211
- return config.command ? ["sh", "-c", config.command] : void 0;
212
- }
213
-
214
- // ../../packages/adapters/src/runtime/resource-limits.ts
215
- function dockerResourceLimits(resources) {
216
- const limits = {};
217
- if (hasMemoryLimit(resources)) {
218
- limits.Memory = Math.round(resources.memoryMb * 1024 * 1024);
219
- }
220
- if (hasCpuLimit(resources)) {
221
- limits.NanoCpus = Math.max(1e6, Math.round(resources.cpuCores * 1e9));
222
- }
223
- return limits;
224
- }
225
- var CPU_PERIOD_US = 1e5;
226
- function dockerBuildResourceLimits(resources) {
227
- const limits = {};
228
- if (hasMemoryLimit(resources)) {
229
- limits.memory = Math.round(resources.memoryMb * 1024 * 1024);
230
- }
231
- if (hasCpuLimit(resources)) {
232
- limits.cpuperiod = CPU_PERIOD_US;
233
- limits.cpuquota = Math.max(1e3, Math.round(resources.cpuCores * CPU_PERIOD_US));
234
- }
235
- return limits;
236
- }
237
- function inspectResourceLimits(hostConfig) {
238
- if (!hostConfig) return void 0;
239
- const memoryMb = typeof hostConfig.Memory === "number" && hostConfig.Memory > 0 ? Math.round(hostConfig.Memory / (1024 * 1024)) : void 0;
240
- let cpuCores;
241
- if (typeof hostConfig.NanoCpus === "number" && hostConfig.NanoCpus > 0) {
242
- cpuCores = hostConfig.NanoCpus / 1e9;
243
- } else if (typeof hostConfig.CpuQuota === "number" && hostConfig.CpuQuota > 0 && typeof hostConfig.CpuPeriod === "number" && hostConfig.CpuPeriod > 0) {
244
- cpuCores = hostConfig.CpuQuota / hostConfig.CpuPeriod;
245
- }
246
- if (cpuCores !== void 0) cpuCores = Math.round(cpuCores * 100) / 100;
247
- if (memoryMb === void 0 && cpuCores === void 0) return void 0;
248
- return {
249
- ...cpuCores !== void 0 && { cpuCores },
250
- ...memoryMb !== void 0 && { memoryMb }
251
- };
252
- }
253
- function describeResourceLimits(resources) {
254
- const parts = [
255
- hasCpuLimit(resources) ? formatCpuCores(resources.cpuCores) : null,
256
- hasMemoryLimit(resources) ? formatMemoryMb(resources.memoryMb) : null
257
- ].filter(Boolean);
258
- return parts.length ? parts.join(" \xB7 ") : "no limits (machine capacity)";
259
- }
260
-
261
- // ../../packages/adapters/src/runtime/docker-ssh-agent.ts
262
- import net2 from "net";
263
- var DEFAULT_REMOTE_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
264
- var resolvedDockerSocketPathCache = /* @__PURE__ */ new WeakMap();
265
- var DOCKER_DIAL_STDIO_COMMAND = "docker system dial-stdio";
266
- var STREAMLOCAL_PROBE_TIMEOUT_MS = 8e3;
267
- var STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS = 3e3;
268
- var VERIFY_BUFFER_CAP_BYTES = 8 * 1024 * 1024;
269
- function toSshConfig(opts) {
270
- return {
271
- host: opts.host ?? "",
272
- port: opts.port ?? 22,
273
- username: opts.username,
274
- hostVerifier: opts.hostVerifier,
275
- password: opts.password,
276
- privateKey: opts.privateKey,
277
- privateKeyPassphrase: opts.privateKeyPassphrase,
278
- sshAgent: opts.sshAgent
279
- };
280
- }
281
- function getConfiguredDockerSocketPath(opts) {
282
- const socketPath = opts.dockerSocketPath?.trim();
283
- return socketPath ? socketPath : null;
284
- }
285
- function normalizeSocketPathLines(lines) {
286
- const seen = /* @__PURE__ */ new Set();
287
- const normalized = [];
288
- for (const line of lines) {
289
- const socketPath = line.trim();
290
- if (!socketPath.startsWith("/")) {
291
- continue;
292
- }
293
- if (seen.has(socketPath)) {
294
- continue;
295
- }
296
- seen.add(socketPath);
297
- normalized.push(socketPath);
298
- }
299
- return normalized;
300
- }
301
- var DOCKER_SOCKET_DISCOVERY_SCRIPT = [
302
- "set -eu",
303
- 'uid="$(id -u 2>/dev/null || printf 0)"',
304
- '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',
305
- "find /run/user -maxdepth 2 -type s \\( -name docker.sock -o -name podman.sock \\) -print 2>/dev/null || true",
306
- 'for dir in /run /var/run "$HOME/.docker/run"; do',
307
- ' if [ -d "$dir" ]; then',
308
- ' find "$dir" -maxdepth 3 -type s \\( -name docker.sock -o -name podman.sock \\) -print 2>/dev/null || true',
309
- " fi",
310
- "done"
311
- ].join("\n");
312
- async function discoverRemoteDockerSocketPathsWithClient(client) {
313
- const result = await execSshCommand(client, DOCKER_SOCKET_DISCOVERY_SCRIPT);
314
- const lines = [result.stdout, result.stderr].filter(Boolean).flatMap((text) => text.split(/\r?\n/)).map((line) => line.trim()).filter(Boolean);
315
- return normalizeSocketPathLines(lines);
316
- }
317
- async function discoverRemoteDockerSocketPathsWithExecutor(executor) {
318
- try {
319
- const output = await executor.exec(DOCKER_SOCKET_DISCOVERY_SCRIPT, { timeout: 1e4 });
320
- return normalizeSocketPathLines(output.split(/\r?\n/));
321
- } catch {
322
- return [];
323
- }
324
- }
325
- async function discoverRemoteDockerSocketPaths(opts) {
326
- if (opts.executor) {
327
- return discoverRemoteDockerSocketPathsWithExecutor(opts.executor);
328
- }
329
- let conn = null;
330
- try {
331
- conn = await connectSshClient(toSshConfig(opts));
332
- return await discoverRemoteDockerSocketPathsWithClient(conn);
333
- } finally {
334
- conn?.end();
335
- }
336
- }
337
- async function resolveRemoteDockerSocketPath(opts) {
338
- const configuredSocketPath = getConfiguredDockerSocketPath(opts);
339
- if (configuredSocketPath) {
340
- return configuredSocketPath;
341
- }
342
- const cachedPath = resolvedDockerSocketPathCache.get(opts);
343
- if (cachedPath) {
344
- return cachedPath;
345
- }
346
- const pendingPath = discoverRemoteDockerSocketPaths(opts).then((paths) => paths[0] ?? DEFAULT_REMOTE_DOCKER_SOCKET_PATH).catch(() => DEFAULT_REMOTE_DOCKER_SOCKET_PATH);
347
- resolvedDockerSocketPathCache.set(opts, pendingPath);
348
- return pendingPath;
349
- }
350
- async function openStreamlocalUpstream(opts) {
351
- const socketPath = await resolveRemoteDockerSocketPath(opts);
352
- if (opts.executor?.forwardUnixSocket) {
353
- return opts.executor.forwardUnixSocket(socketPath);
354
- }
355
- const client = await connectSshClient(toSshConfig(opts));
356
- try {
357
- const channel = await openSshUnixSocket(client, socketPath);
358
- channel.once("close", () => client.end());
359
- channel.on("error", () => client.end());
360
- return channel;
361
- } catch (error) {
362
- client.end();
363
- throw error;
364
- }
365
- }
366
- function pipeThrough(client, upstream) {
367
- const teardown = () => {
368
- client.destroy();
369
- upstream.destroy();
370
- };
371
- client.on("error", teardown);
372
- upstream.on("error", teardown);
373
- client.once("close", () => upstream.destroy());
374
- upstream.once("close", () => client.destroy());
375
- client.pipe(upstream);
376
- upstream.pipe(client);
377
- }
378
- function awaitFirstByte(upstream, ms) {
379
- return new Promise((resolve) => {
380
- const finish = (chunk) => {
381
- clearTimeout(timer);
382
- upstream.removeListener("data", onData);
383
- upstream.removeListener("error", onFail);
384
- upstream.removeListener("close", onFail);
385
- resolve(chunk);
386
- };
387
- const onData = (chunk) => finish(chunk);
388
- const onFail = () => finish(null);
389
- const timer = setTimeout(() => finish(null), ms);
390
- upstream.on("data", onData);
391
- upstream.once("error", onFail);
392
- upstream.once("close", onFail);
393
- });
394
- }
395
- function captureClient(client) {
396
- const chunks = [];
397
- let bytes = 0;
398
- let overflowed = false;
399
- let closed = false;
400
- let live = null;
401
- const onData = (chunk) => {
402
- if (live) live.write(chunk);
403
- if (overflowed) return;
404
- chunks.push(chunk);
405
- bytes += chunk.length;
406
- if (bytes >= VERIFY_BUFFER_CAP_BYTES) {
407
- overflowed = true;
408
- client.pause();
409
- }
410
- };
411
- const onGone = () => {
412
- closed = true;
413
- };
414
- client.on("data", onData);
415
- client.once("error", onGone);
416
- client.once("close", onGone);
417
- const detach = () => {
418
- client.removeListener("data", onData);
419
- client.removeListener("error", onGone);
420
- client.removeListener("close", onGone);
421
- };
422
- return {
423
- get closed() {
424
- return closed;
425
- },
426
- get overflowed() {
427
- return overflowed;
428
- },
429
- /** Replay everything buffered so far onto `upstream` and forward the rest
430
- * live. Safe to call again on a different upstream (the fallback): the full
431
- * buffer is retained until `commit`, so the replay is complete each time. */
432
- feed(upstream) {
433
- for (const chunk of chunks) upstream.write(chunk);
434
- live = upstream;
435
- },
436
- /** Stop forwarding to the current upstream (keep buffering) — used before
437
- * destroying a dead channel so late bytes never hit a torn-down stream. */
438
- stop() {
439
- live = null;
440
- },
441
- /** Terminal: detach and hand the socket to the (already-fed) `upstream` via a
442
- * bidi pipe, or destroy it if the client already went away. */
443
- commit(upstream) {
444
- detach();
445
- chunks.length = 0;
446
- if (closed) {
447
- upstream.destroy();
448
- return;
449
- }
450
- pipeThrough(client, upstream);
451
- },
452
- /** Terminal: no usable upstream — surface the error to dockerode. */
453
- fail(err) {
454
- detach();
455
- client.destroy(err instanceof Error ? err : new Error(String(err)));
456
- }
457
- };
458
- }
459
- function createDockerSshBridge(opts) {
460
- const clients = /* @__PURE__ */ new Set();
461
- let upstreamMode = null;
462
- let modeDecision = null;
463
- let dialClient = null;
464
- let pooledUnreliable = false;
465
- const openDialStdioUpstream = async (forceEphemeral = false) => {
466
- if (!forceEphemeral && opts.executor?.openDockerDialStdio) {
467
- const stream = await opts.executor.openDockerDialStdio();
468
- stream.once(
469
- "error",
470
- (e) => console.warn(`[docker-ssh] dial-stdio channel error: ${safeErrorMessage(e)}`)
471
- );
472
- return stream;
473
- }
474
- if (!dialClient) {
475
- const client = await connectSshClient(toSshConfig(opts));
476
- client.once("close", () => {
477
- if (dialClient === client) dialClient = null;
478
- });
479
- dialClient = client;
480
- }
481
- return openSshExecChannel(dialClient, DOCKER_DIAL_STDIO_COMMAND);
482
- };
483
- const decideMode = async () => {
484
- let probe = null;
485
- try {
486
- probe = await withTimeout(
487
- openStreamlocalUpstream(opts),
488
- STREAMLOCAL_PROBE_TIMEOUT_MS,
489
- `streamlocal open timed out after ${STREAMLOCAL_PROBE_TIMEOUT_MS / 1e3}s`
490
- );
491
- const stream = probe;
492
- const flowed = await new Promise((resolve) => {
493
- const timer = setTimeout(() => resolve(false), STREAMLOCAL_PROBE_TIMEOUT_MS);
494
- const settle = (ok) => {
495
- clearTimeout(timer);
496
- resolve(ok);
497
- };
498
- stream.once("data", () => settle(true));
499
- stream.once("error", () => settle(false));
500
- stream.once("close", () => settle(false));
501
- try {
502
- stream.write("GET /_ping HTTP/1.0\r\nHost: localhost\r\n\r\n");
503
- } catch {
504
- settle(false);
505
- }
506
- });
507
- if (flowed) {
508
- console.log(`[docker-ssh] upstream=streamlocal (${opts.host ?? "?"})`);
509
- return "streamlocal";
510
- }
511
- console.warn(
512
- `[docker-ssh] upstream=dial-stdio (${opts.host ?? "?"}) \u2014 streamlocal opened but no data flowed (Bun/ssh2)`
513
- );
514
- return "dialstdio";
515
- } catch (err) {
516
- console.warn(
517
- `[docker-ssh] upstream=dial-stdio (${opts.host ?? "?"}) \u2014 streamlocal unavailable: ${safeErrorMessage(err)}`
518
- );
519
- return "dialstdio";
520
- } finally {
521
- probe?.destroy();
522
- }
523
- };
524
- const ensureMode = () => {
525
- if (upstreamMode) return Promise.resolve(upstreamMode);
526
- modeDecision ??= decideMode().then((mode) => upstreamMode = mode);
527
- return modeDecision;
528
- };
529
- const downgradeToDialStdio = async (capture, reason) => {
530
- console.warn(
531
- `[docker-ssh] streamlocal unusable (${opts.host ?? "?"}): ${reason} \u2014 downgrading this bridge to dial-stdio (fresh connection) and replaying the buffered request.`
532
- );
533
- upstreamMode = "dialstdio";
534
- pooledUnreliable = true;
535
- const upstream = await openDialStdioUpstream(true);
536
- capture.feed(upstream);
537
- capture.commit(upstream);
538
- };
539
- const bridgeClient = async (client) => {
540
- const capture = captureClient(client);
541
- try {
542
- const mode = await ensureMode();
543
- if (mode === "dialstdio") {
544
- const upstream = await openDialStdioUpstream(pooledUnreliable);
545
- capture.feed(upstream);
546
- capture.commit(upstream);
547
- return;
548
- }
549
- let channel;
550
- try {
551
- channel = await withTimeout(
552
- openStreamlocalUpstream(opts),
553
- STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS,
554
- `channel open timed out after ${STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS / 1e3}s`
555
- );
556
- } catch (err) {
557
- await downgradeToDialStdio(capture, safeErrorMessage(err));
558
- return;
559
- }
560
- capture.feed(channel);
561
- const firstByte = capture.overflowed ? null : await awaitFirstByte(channel, STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS);
562
- if (capture.overflowed || firstByte) {
563
- if (firstByte) client.write(firstByte);
564
- capture.commit(channel);
565
- return;
566
- }
567
- capture.stop();
568
- channel.destroy();
569
- await downgradeToDialStdio(capture, "channel opened but no data flowed");
570
- } catch (err) {
571
- console.warn(`[docker-ssh] bridge client failed (${opts.host ?? "?"}): ${safeErrorMessage(err)}`);
572
- capture.fail(err);
573
- }
574
- };
575
- const server = net2.createServer((client) => {
576
- clients.add(client);
577
- client.setNoDelay(true);
578
- client.once("close", () => clients.delete(client));
579
- void bridgeClient(client);
580
- });
581
- return {
582
- start: () => new Promise((resolve, reject) => {
583
- const onError = (error) => reject(error);
584
- server.once("error", onError);
585
- server.listen(0, "127.0.0.1", () => {
586
- server.removeListener("error", onError);
587
- const address = server.address();
588
- if (address === null || typeof address === "string") {
589
- reject(new Error("Docker SSH bridge failed to bind a loopback TCP port."));
590
- return;
591
- }
592
- resolve({ host: "127.0.0.1", port: address.port });
593
- });
594
- }),
595
- probe: async () => {
596
- await ensureMode();
597
- },
598
- close: () => {
599
- for (const client of clients) {
600
- client.destroy();
601
- }
602
- clients.clear();
603
- dialClient?.end();
604
- dialClient = null;
605
- server.close();
606
- }
607
- };
608
- }
609
-
610
- // ../../packages/adapters/src/runtime/docker-transport.ts
611
- var DEFAULT_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
612
- function resolveLocalDockerSocketPath(opts, env = process.env) {
613
- const explicit = opts?.dockerSocketPath?.trim();
614
- if (explicit) return explicit;
615
- const host = env.DOCKER_HOST?.trim();
616
- if (host) {
617
- if (host.startsWith("unix://")) {
618
- const path = host.slice("unix://".length).trim();
619
- if (path.startsWith("/")) return path;
620
- } else if (host.startsWith("/")) {
621
- return host;
622
- }
623
- }
624
- return DEFAULT_DOCKER_SOCKET_PATH;
625
- }
626
- function resolveDockerTransport(opts) {
627
- if (!opts || !opts.transport || opts.transport === "socket") {
628
- const socketPath = resolveLocalDockerSocketPath(opts);
629
- return {
630
- kind: "socket",
631
- description: `local Docker daemon via socket (${socketPath})`,
632
- unreachableHint: socketPath === DEFAULT_DOCKER_SOCKET_PATH ? "Check that the local Docker daemon is running. If it isn't Docker Desktop (Colima, Rancher, Podman, rootless), set DOCKER_HOST to its socket." : `Check that the local Docker daemon is running and listening on ${socketPath} (from DOCKER_HOST / dockerSocketPath).`,
633
- establish: async () => ({ socketPath }),
634
- close: async () => {
635
- },
636
- preflight: async () => {
637
- }
638
- };
639
- }
640
- if (opts.transport === "ssh") {
641
- if (!opts.privateKey && !opts.sshAgent && !opts.password) {
642
- throw new Error("SSH transport requires one of privateKey, sshAgent, or password.");
643
- }
644
- let bridge = null;
645
- return {
646
- kind: "ssh",
647
- description: `remote Docker daemon via SSH (${opts.host ?? "unknown-host"})`,
648
- 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.",
649
- establish: async () => {
650
- bridge = createDockerSshBridge(opts);
651
- const { host, port } = await bridge.start();
652
- return {
653
- protocol: "http",
654
- host,
655
- port,
656
- timeout: opts.timeout ?? 6e5
657
- };
658
- },
659
- close: async () => {
660
- bridge?.close();
661
- bridge = null;
662
- },
663
- // Decide the upstream transport (streamlocal vs dial-stdio) up front so
664
- // the daemon `ping()` in assertReachable runs over the working one. The
665
- // ping itself is the real reachability check.
666
- preflight: async () => {
667
- await bridge?.probe();
668
- }
669
- };
670
- }
671
- if (!opts.ca || !opts.cert || !opts.key) {
672
- throw new Error(
673
- "TCP transport requires ca, cert, and key for mutual TLS. Plaintext TCP connections are not supported for security reasons."
674
- );
675
- }
676
- return {
677
- kind: "tcp",
678
- description: `remote Docker daemon via TLS (${opts.host ?? "unknown-host"}:${opts.port ?? 2376})`,
679
- unreachableHint: "Check that the remote Docker daemon is reachable and the TLS certificates are valid.",
680
- establish: async () => ({
681
- protocol: "https",
682
- host: opts.host,
683
- port: opts.port ?? 2376,
684
- ca: opts.ca,
685
- cert: opts.cert,
686
- key: opts.key,
687
- timeout: opts.timeout ?? 3e4
688
- }),
689
- close: async () => {
690
- },
691
- preflight: async () => {
692
- }
693
- };
694
- }
695
-
696
- // ../../packages/adapters/src/runtime/docker.ts
697
- var isDockerNotFoundError = isRuntimeNotFoundError;
698
- function ownsBuiltImage(imageRef) {
699
- return imageRef.startsWith("openship/");
700
- }
701
- function clampShellWindow(value, fallback, min, max) {
702
- const n = Number.isFinite(value) ? Number(value) : fallback;
703
- if (n < min) return min;
704
- if (n > max) return max;
705
- return Math.floor(n);
706
- }
707
- var RESTART_POLICIES = {
708
- always: { Name: "always", MaximumRetryCount: 0 },
709
- "on-failure": { Name: "on-failure", MaximumRetryCount: 5 },
710
- "unless-stopped": { Name: "unless-stopped", MaximumRetryCount: 0 },
711
- no: { Name: "no", MaximumRetryCount: 0 }
712
- };
713
- var DOCKER_BUILD_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
714
- function resolveRestartPolicy(policy) {
715
- return RESTART_POLICIES[policy ?? "always"] ?? RESTART_POLICIES.always;
716
- }
717
- function normalizeDockerMount(m) {
718
- return {
719
- type: m.Type ?? "volume",
720
- ...m.Name ? { name: m.Name } : {},
721
- ...m.Source ? { source: m.Source } : {},
722
- destination: m.Destination ?? "",
723
- rw: m.RW !== false
724
- };
725
- }
726
- function toStringArray(v) {
727
- if (v == null) return void 0;
728
- const arr = Array.isArray(v) ? v : [v];
729
- return arr.length > 0 ? arr : void 0;
730
- }
731
- function normalizeInspectPorts(data) {
732
- const out = [];
733
- const seen = /* @__PURE__ */ new Set();
734
- const parseKey = (key) => {
735
- const [portStr, proto] = key.split("/");
736
- const port = Number(portStr);
737
- return Number.isFinite(port) ? { port, proto: proto || "tcp" } : null;
738
- };
739
- const nsPorts = data.NetworkSettings?.Ports ?? {};
740
- for (const [key, bindings] of Object.entries(nsPorts)) {
741
- const parsed = parseKey(key);
742
- if (!parsed) continue;
743
- seen.add(key);
744
- if (bindings && bindings.length > 0) {
745
- for (const b of bindings) {
746
- out.push({
747
- privatePort: parsed.port,
748
- ...b.HostPort ? { publicPort: Number(b.HostPort) } : {},
749
- type: parsed.proto,
750
- ...b.HostIp ? { ip: b.HostIp } : {}
751
- });
752
- }
753
- } else {
754
- out.push({ privatePort: parsed.port, type: parsed.proto });
755
- }
756
- }
757
- const hostBindings = data.HostConfig?.PortBindings ?? {};
758
- for (const [key, bindings] of Object.entries(hostBindings)) {
759
- if (seen.has(key)) continue;
760
- const parsed = parseKey(key);
761
- if (!parsed) continue;
762
- seen.add(key);
763
- if (bindings && bindings.length > 0) {
764
- for (const b of bindings) {
765
- out.push({
766
- privatePort: parsed.port,
767
- ...b.HostPort ? { publicPort: Number(b.HostPort) } : {},
768
- type: parsed.proto,
769
- ...b.HostIp ? { ip: b.HostIp } : {}
770
- });
771
- }
772
- } else {
773
- out.push({ privatePort: parsed.port, type: parsed.proto });
774
- }
775
- }
776
- const exposed = data.Config?.ExposedPorts ?? {};
777
- for (const key of Object.keys(exposed)) {
778
- if (seen.has(key)) continue;
779
- const parsed = parseKey(key);
780
- if (parsed) out.push({ privatePort: parsed.port, type: parsed.proto });
781
- }
782
- return out;
783
- }
784
- function parsePortBindings(portSpecs) {
785
- const exposedPorts = {};
786
- const portBindings = {};
787
- for (const spec of portSpecs) {
788
- const slashIdx = spec.lastIndexOf("/");
789
- const rawProto = slashIdx >= 0 ? spec.slice(slashIdx + 1).toLowerCase() : "";
790
- const protocol = rawProto === "udp" || rawProto === "sctp" ? rawProto : "tcp";
791
- const mapping = slashIdx >= 0 ? spec.slice(0, slashIdx) : spec;
792
- const parts = mapping.split(":");
793
- if (parts.length === 1) {
794
- const key = `${parts[0]}/${protocol}`;
795
- exposedPorts[key] = {};
796
- portBindings[key] = [{ HostPort: "" }];
797
- } else {
798
- const containerPort = parts[parts.length - 1];
799
- const hostPort = parts[parts.length - 2];
800
- const hostIp = parts.length > 2 ? parts.slice(0, -2).join(":") : void 0;
801
- const key = `${containerPort}/${protocol}`;
802
- exposedPorts[key] = {};
803
- portBindings[key] = [hostIp ? { HostIp: hostIp, HostPort: hostPort } : { HostPort: hostPort }];
804
- }
805
- }
806
- return { exposedPorts, portBindings };
807
- }
808
- var DURATION_UNITS_NS = {
809
- ns: 1,
810
- us: 1e3,
811
- "\xB5s": 1e3,
812
- ms: 1e6,
813
- s: 1e9,
814
- m: 6e10,
815
- h: 36e11
816
- };
817
- function parseDurationNs(value) {
818
- if (value == null) return void 0;
819
- const str = String(value).trim();
820
- if (!str) return void 0;
821
- if (/^\d+(\.\d+)?$/.test(str)) return Math.round(parseFloat(str) * 1e9);
822
- const re = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g;
823
- let total = 0;
824
- let matched = false;
825
- let m;
826
- while ((m = re.exec(str)) !== null) {
827
- matched = true;
828
- total += parseFloat(m[1]) * DURATION_UNITS_NS[m[2]];
829
- }
830
- return matched ? Math.round(total) : void 0;
831
- }
832
- function toDockerHealthcheck(hc) {
833
- if (!hc) return void 0;
834
- if (hc.disable) return { Test: ["NONE"] };
835
- let Test;
836
- if (typeof hc.test === "string" && hc.test.trim()) {
837
- Test = ["CMD-SHELL", hc.test];
838
- } else if (Array.isArray(hc.test) && hc.test.length > 0) {
839
- Test = ["CMD", ...hc.test];
840
- }
841
- if (!Test) return void 0;
842
- const Interval = parseDurationNs(hc.interval);
843
- const Timeout = parseDurationNs(hc.timeout);
844
- const StartPeriod = parseDurationNs(hc.startPeriod);
845
- return {
846
- Test,
847
- ...Interval !== void 0 && { Interval },
848
- ...Timeout !== void 0 && { Timeout },
849
- ...typeof hc.retries === "number" && { Retries: hc.retries },
850
- ...StartPeriod !== void 0 && { StartPeriod }
851
- };
852
- }
853
- function hasDockerFrameHeader(buf, offset = 0) {
854
- return buf.length >= offset + 8 && (buf[offset] === 1 || buf[offset] === 2) && buf[offset + 1] === 0 && buf[offset + 2] === 0 && buf[offset + 3] === 0;
855
- }
856
- function stripDockerHeaders(buf) {
857
- const lines = [];
858
- let offset = 0;
859
- while (offset < buf.length) {
860
- if (hasDockerFrameHeader(buf, offset)) {
861
- const size = buf.readUInt32BE(offset + 4);
862
- lines.push(buf.subarray(offset + 8, offset + 8 + size).toString("utf-8"));
863
- offset += 8 + size;
864
- } else {
865
- lines.push(buf.subarray(offset).toString("utf-8"));
866
- break;
867
- }
868
- }
869
- return lines.join("");
870
- }
871
- function stripDockerChunkHeader(chunk) {
872
- return hasDockerFrameHeader(chunk) ? chunk.subarray(8) : chunk;
873
- }
874
- function parseTimestampedLine(line) {
875
- const spaceIdx = line.indexOf(" ");
876
- return {
877
- timestamp: spaceIdx > 0 ? line.slice(0, spaceIdx) : (/* @__PURE__ */ new Date()).toISOString(),
878
- message: spaceIdx > 0 ? line.slice(spaceIdx + 1) : line
879
- };
880
- }
881
- function firstNetworkIp(networks) {
882
- for (const net3 of Object.values(networks ?? {})) {
883
- if (net3?.IPAddress) return net3.IPAddress;
884
- }
885
- return void 0;
886
- }
887
- function extractNetworkInfo(data) {
888
- let ip;
889
- for (const net3 of Object.values(data.NetworkSettings.Networks ?? {})) {
890
- if (net3.IPAddress) {
891
- ip = net3.IPAddress;
892
- break;
893
- }
894
- }
895
- let hostPort;
896
- for (const bindings of Object.values(data.NetworkSettings.Ports ?? {})) {
897
- if (bindings?.[0]?.HostPort) {
898
- hostPort = parseInt(bindings[0].HostPort, 10);
899
- break;
900
- }
901
- }
902
- return { ip, hostPort };
903
- }
904
- function parseLoadedImageRef(output) {
905
- const matches = [...(output || "").matchAll(/Loaded image(?: ID)?:\s*(\S+)/gi)];
906
- return matches.length ? matches[matches.length - 1][1].trim() : void 0;
907
- }
908
- var DockerRuntime = class _DockerRuntime {
909
- name = "docker";
910
- capabilities = /* @__PURE__ */ new Set([
911
- "build",
912
- "deploy",
913
- "multiServiceDeploy",
914
- "stop",
915
- "start",
916
- "restart",
917
- "destroy",
918
- "containerInfo",
919
- "runtimeLogs",
920
- "streamLogs",
921
- "usage",
922
- "containerIp",
923
- "rollback",
924
- "serviceShell",
925
- "projectContainerSweep",
926
- "deploymentContainerQuery",
927
- "hostContainerQuery",
928
- "stabilityProbe"
929
- ]);
930
- /** Docker honors every extended compose key we currently support. */
931
- unsupportedComposeKeys = /* @__PURE__ */ new Set();
932
- _docker;
933
- /** Underlying dockerode instance - exposed for advanced usage */
934
- get docker() {
935
- return this._docker;
936
- }
937
- /** Connection config this runtime was created with */
938
- connectionOptions;
939
- /** Resolved transport - single switch point for socket / ssh / tcp */
940
- transport;
941
- systemManager;
942
- provisionLock;
943
- constructor(opts, systemManager, provisionLock) {
944
- this.connectionOptions = opts;
945
- this.transport = resolveDockerTransport(opts);
946
- this.systemManager = systemManager ?? null;
947
- this.provisionLock = provisionLock;
948
- }
949
- /**
950
- * Build a runtime and stand up its transport. Async because the SSH
951
- * transport binds a loopback bridge whose port is only known after listen();
952
- * socket/TCP transports resolve their options synchronously.
953
- */
954
- static async create(opts, systemManager, provisionLock) {
955
- const runtime = new _DockerRuntime(opts, systemManager, provisionLock);
956
- runtime._docker = new Dockerode(await runtime.transport.establish());
957
- return runtime;
958
- }
959
- supports(cap) {
960
- return this.capabilities.has(cap);
961
- }
962
- async dispose() {
963
- await this.transport.close();
964
- }
965
- // ─── Health check ──────────────────────────────────────────────────
966
- /** Ping the Docker daemon - useful for connection testing */
967
- async ping() {
968
- try {
969
- await this.assertReachable();
970
- return true;
971
- } catch (err) {
972
- console.warn(`[docker] daemon unreachable: ${safeErrorMessage(err)}`);
973
- return false;
974
- }
975
- }
976
- /**
977
- * Assert the Docker daemon is reachable, RETHROWING the underlying error
978
- * instead of collapsing it to false. For the SSH transport, `preflight()`
979
- * decides the upstream transport (SSH socket forwarding where it works, else
980
- * `docker system dial-stdio` over an exec channel — the streamlocal-free path
981
- * the Bun-compiled desktop needs), then `ping()` is the real end-to-end check
982
- * over that transport. Use on user-facing paths (e.g. the migration scan) so
983
- * the real cause reaches the user instead of a generic "not reachable".
984
- */
985
- async assertReachable() {
986
- await this.ensureDockerFeature();
987
- await this.transport.preflight();
988
- await this.docker.ping();
989
- }
990
- async ensureDockerFeature(logger) {
991
- if (!this.systemManager) {
992
- return;
993
- }
994
- await this.systemManager.ensureFeature("deploy", (entry) => {
995
- logger?.log(entry.message, entry.level);
996
- });
997
- }
998
- /** Get Docker daemon info (version, platform, etc.) */
999
- async info() {
1000
- return this.docker.info();
1001
- }
1002
- // ── Image naming ────────────────────────────────────────────────────────
1003
- /** Canonical image tag for a build session. */
1004
- imageTag(slug, sessionId) {
1005
- const name = slug ? `openship/${slug}` : `openship/build`;
1006
- return `${name}:${sessionId}`;
1007
- }
1008
- /** Labels applied to both build images and deploy containers. */
1009
- labels(config) {
1010
- const l = {
1011
- "openship.project": config.projectId
1012
- };
1013
- if (config.deploymentId) l["openship.deployment"] = config.deploymentId;
1014
- if (config.sessionId) l["openship.build"] = config.sessionId;
1015
- return l;
1016
- }
1017
- // ── Build lifecycle ────────────────────────────────────────────────────
1018
- /**
1019
- * Sum the byte size of a directory tree. Best-effort - used only for a
1020
- * human-readable "X MB context streamed" log line. Returns 0 if the
1021
- * walk hits an error rather than failing the build.
1022
- */
1023
- async estimateContextSize(dir) {
1024
- const { stat, readdir } = await import("fs/promises");
1025
- const { join } = await import("path");
1026
- let total = 0;
1027
- const stack = [dir];
1028
- while (stack.length) {
1029
- const current = stack.pop();
1030
- let entries;
1031
- try {
1032
- entries = await readdir(current, { withFileTypes: true });
1033
- } catch {
1034
- continue;
1035
- }
1036
- for (const entry of entries) {
1037
- const full = join(current, entry.name);
1038
- if (entry.isDirectory()) {
1039
- stack.push(full);
1040
- } else if (entry.isFile()) {
1041
- try {
1042
- const s = await stat(full);
1043
- total += s.size;
1044
- } catch {
1045
- }
1046
- }
1047
- }
1048
- }
1049
- return total;
1050
- }
1051
- emitDockerStep(logger, step, status, message) {
1052
- const label = status === "running" ? "\u2192" : status === "completed" ? "\u2713" : "\u21B7";
1053
- logger.log(`[${step}] ${label} ${message}`);
1054
- logger.step(step, status, message);
1055
- }
1056
- handleBuildEvent(event, logger) {
1057
- const errorMessage = event.errorDetail?.message ?? event.error;
1058
- if (errorMessage) {
1059
- logger.log(errorMessage, "error");
1060
- return errorMessage;
1061
- }
1062
- if (event.stream) {
1063
- const line = event.stream.trim();
1064
- if (!line) return null;
1065
- const marker = line.match(
1066
- /^\[openship-build\]\s+step=(clone|install|build)\s+status=(running|completed|skipped)$/
1067
- );
1068
- if (marker) {
1069
- const [, step, status] = marker;
1070
- this.emitDockerStep(
1071
- logger,
1072
- step,
1073
- status,
1074
- line
1075
- );
1076
- if (status === "completed" && step === "install") {
1077
- logger.log("Finalizing image (layer commit + tag)...");
1078
- }
1079
- return null;
1080
- }
1081
- if (this.isLowSignalDockerLine(line)) {
1082
- return null;
1083
- }
1084
- logger.log(line, parseLogLevel(line));
1085
- return this.extractBuildFailureHint(line);
1086
- }
1087
- if (event.status) {
1088
- const parts = [event.id, event.status, event.progress].filter((p) => Boolean(p?.trim())).map((p) => p.trim());
1089
- if (parts.length) logger.log(parts.join(" "));
1090
- }
1091
- return null;
1092
- }
1093
- // Only the truly-redundant lines get filtered. We KEEP "Step N/M : ..."
1094
- // because that's the user's best progress signal during a long build -
1095
- // it shows which Dockerfile instruction is currently executing.
1096
- //
1097
- // Removed (= now passes through to terminal):
1098
- // - "Step N/M : ..." → high-signal, shows progress
1099
- // - "Successfully built / tagged" → confirms success
1100
- //
1101
- // Still filtered (= noise):
1102
- // - "---> hash" → opaque layer hash, no signal for users
1103
- // - "Running in ..." → intermediate container id, no signal
1104
- // - "Removing intermediate container ..." → cleanup chatter
1105
- static DOCKER_BUILDER_NOISE = [
1106
- /^--->/i,
1107
- // ---> abc123def
1108
- /^Running in\s+[a-f0-9]{6,}$/i,
1109
- /^Removing intermediate container\s+[a-f0-9]{6,}$/i
1110
- ];
1111
- isLowSignalDockerLine(line) {
1112
- return _DockerRuntime.DOCKER_BUILDER_NOISE.some((p) => p.test(line));
1113
- }
1114
- extractBuildFailureHint(line) {
1115
- if (/returned a non-zero code:\s*\d+/i.test(line)) {
1116
- return line;
1117
- }
1118
- if (/\/workspace\/package\.json/i.test(line) && /ENOENT/i.test(line)) {
1119
- return "Docker build ran from /workspace but package.json was not found there. The configured rootDirectory is likely empty or incorrect.";
1120
- }
1121
- if (/failed to solve|executor failed running|error: build/i.test(line)) {
1122
- return line;
1123
- }
1124
- return null;
1125
- }
1126
- formatDockerConnectivityError(error) {
1127
- const message = safeErrorMessage(error);
1128
- if (/^Cannot reach Docker daemon:/i.test(message)) {
1129
- return message;
1130
- }
1131
- return `Cannot reach Docker daemon: ${message}. ${this.transport.unreachableHint}`;
1132
- }
1133
- /**
1134
- * SSH transport build path. Bypasses dockerode's HTTP-over-SSH upload
1135
- * (which is ~1-2 MB/s and silent) in favor of two well-trodden pieces:
1136
- *
1137
- * 1. `transferLocalDirectory(...)` - defaults to rsync over the
1138
- * SYSTEM `ssh` binary (NOT the Node `ssh2` library), with native
1139
- * `--progress` output streamed straight from rsync. ~10-30 MB/s
1140
- * typical. Lands the context at `/tmp/openship-build-<sessionId>`
1141
- * on remote. Falls back to tar through the ssh2 channel only if
1142
- * rsync isn't installed on either side.
1143
- *
1144
- * 2. `executor.streamExec("docker build ...")` - runs native docker
1145
- * CLI on the remote. Its raw stdout/stderr streams back unfiltered
1146
- * so the user sees real "Step N/M : ...", layer hashes, install
1147
- * output, etc. Same logs you'd see SSHing in and running it by hand.
1148
- *
1149
- * Container lifecycle (deploy, stop, logs, etc.) still uses dockerode -
1150
- * only the slow build upload moves to this path.
1151
- */
1152
- /**
1153
- * Ship a prepared context dir to the remote host (rsync via system ssh with
1154
- * native --progress; tar/ssh2 fallback). Reused by both the single-image SSH
1155
- * build and the batch build (which transfers the shared context ONCE).
1156
- */
1157
- async transferBuildContext(contextDir, remoteContextDir, log) {
1158
- const executor = this.connectionOptions?.executor;
1159
- if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
1160
- log.log(`Streaming build context to ${remoteContextDir}...`);
1161
- await executor.exec(`rm -rf ${sq(remoteContextDir)} && mkdir -p ${sq(remoteContextDir)}`);
1162
- await transferLocalDirectory(
1163
- contextDir,
1164
- { kind: "executor", executor, path: remoteContextDir },
1165
- log,
1166
- { excludes: [] }
1167
- );
1168
- }
1169
- /**
1170
- * Run native `docker build` on the remote host against an already-transferred
1171
- * context dir. One image; `dockerfileName` selects which Dockerfile in the
1172
- * shared tree to use (so N services can build from one transferred context).
1173
- */
1174
- async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log) {
1175
- const executor = this.connectionOptions?.executor;
1176
- if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
1177
- const buildArgs = Object.entries({
1178
- ...config.envVars,
1179
- NODE_ENV: "production"
1180
- }).filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)).map(([k, v]) => `--build-arg ${sq(`${k}=${v}`)}`).join(" ");
1181
- const labelArgs = Object.entries(
1182
- this.labels({ projectId: config.projectId, sessionId: config.sessionId })
1183
- ).map(([k, v]) => `--label ${sq(`${k}=${v}`)}`).join(" ");
1184
- const dockerfileFlag = dockerfileName && dockerfileName !== "Dockerfile" ? ` -f ${sq(dockerfileName)}` : "";
1185
- const buildCmd = `cd ${sq(remoteContextDir)} && docker build --progress=plain -t ${sq(tag)}${dockerfileFlag} ${labelArgs} ${buildArgs} --force-rm .`;
1186
- log.log(`Running on remote: ${buildCmd}`);
1187
- log.log("\u2500\u2500\u2500 docker build output \u2500\u2500\u2500");
1188
- this.emitDockerStep(log, "install", "running", "Running install inside container (docker build)");
1189
- const { code } = await executor.streamExec(buildCmd, (entry) => {
1190
- log.log(entry.message, parseLogLevel(entry.message));
1191
- });
1192
- log.log("\u2500\u2500\u2500 end docker build output \u2500\u2500\u2500");
1193
- if (code !== 0) throw new Error(`docker build exited with code ${code}`);
1194
- this.emitDockerStep(log, "install", "completed", "Image build finished");
1195
- }
1196
- /**
1197
- * Clone the repo directly ON the remote host into `remoteContextDir` — the
1198
- * clone-on-server alternative to transferBuildContext (which clones on the
1199
- * orchestrator and rsyncs the tree). Runs `git clone` in a remote host shell,
1200
- * mirroring the bare runtime (build-pipeline.ts): the credential-helper relay
1201
- * (`config.gitCredentialHelperPath` — plain URL, nothing persisted) when set,
1202
- * else `injectGitToken(...)`. Strips `.git` so it never ships into the image.
1203
- */
1204
- async cloneSourceOnRemote(config, remoteContextDir, log) {
1205
- const executor = this.connectionOptions?.executor;
1206
- if (!executor) throw new Error("Clone-on-server requires an SSH executor on connectionOptions");
1207
- const useHelper = !!config.gitCredentialHelperPath;
1208
- if (!useHelper && !config.gitSsh && !config.gitAmbient) {
1209
- const ref = config.commitSha || config.branch;
1210
- const tarUrl = githubTarballUrl(config.repoUrl, ref);
1211
- if (tarUrl) {
1212
- try {
1213
- log.log(`Fetching ${config.repoUrl} tarball on the server \u2192 ${remoteContextDir}...
1214
- `);
1215
- await downloadTarballOnRemote(executor, {
1216
- url: tarUrl,
1217
- token: config.gitToken,
1218
- destDir: remoteContextDir,
1219
- onLog: (entry) => log.log(entry.message, parseLogLevel(entry.message))
1220
- });
1221
- await executor.exec(`rm -rf ${sq(`${remoteContextDir}/.git`)}`).catch(() => {
1222
- });
1223
- return;
1224
- } catch (err) {
1225
- log.log(
1226
- `Tarball download failed (${safeErrorMessage(err)}); falling back to git clone.
1227
- `,
1228
- "warn"
1229
- );
1230
- }
1231
- }
1232
- }
1233
- let sshMaterial;
1234
- if (config.gitSsh) {
1235
- sshMaterial = await materializeGitSsh(
1236
- shellGitSshWriter({
1237
- exec: (cmd) => executor.exec(cmd),
1238
- writeSecret: (path, content) => executor.writeFile(path, content)
1239
- }),
1240
- `${remoteContextDir}.gitssh`,
1241
- config.gitSsh
1242
- );
1243
- }
1244
- const { cloneUrl, gitEnv: GIT_ENV, credFlag: CRED } = assembleGitClone({
1245
- repoUrl: config.repoUrl,
1246
- gitToken: config.gitToken,
1247
- gitCredentialHelperPath: config.gitCredentialHelperPath,
1248
- ssh: sshMaterial,
1249
- ambient: config.gitAmbient
1250
- });
1251
- const dir = sq(remoteContextDir);
1252
- const authLabel = config.gitSsh ? "ssh key" : useHelper ? "forwarded credentials" : config.gitAmbient ? `the server's own git credentials (${config.gitAmbient.via})` : "token";
1253
- log.log(`Cloning ${config.repoUrl} on the server \u2192 ${remoteContextDir} (${authLabel})...
1254
- `);
1255
- await executor.exec(`rm -rf ${dir} && mkdir -p ${dir}`);
1256
- const run = async (cmd) => {
1257
- const { code } = await executor.streamExec(
1258
- cmd,
1259
- (entry) => log.log(entry.message, parseLogLevel(entry.message))
1260
- );
1261
- if (code !== 0) throw new Error(`git clone on server exited with code ${code}`);
1262
- };
1263
- try {
1264
- if (config.commitSha) {
1265
- try {
1266
- await run(
1267
- `${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)}`
1268
- );
1269
- } catch {
1270
- log.log(`Commit ${config.commitSha} not in the shallow clone; unshallowing and retrying.
1271
- `, "warn");
1272
- await run(
1273
- `cd ${dir} && ${GIT_ENV} git ${CRED} fetch --progress --unshallow && git ${CRED} -c advice.detachedHead=false checkout ${sq(config.commitSha)}`
1274
- );
1275
- }
1276
- } else {
1277
- await run(
1278
- `${GIT_ENV} git ${CRED} clone --progress --depth 1 --branch ${sq(config.branch)} ${sq(cloneUrl)} ${dir}`
1279
- );
1280
- }
1281
- await executor.exec(`rm -rf ${sq(`${remoteContextDir}/.git`)}`).catch(() => {
1282
- });
1283
- } finally {
1284
- await sshMaterial?.cleanup();
1285
- }
1286
- }
1287
- /**
1288
- * Resolve the Dockerfile for a build whose source lives on the REMOTE host
1289
- * (clone-on-server). Mirrors resolveServiceDockerfile but probes the remote
1290
- * tree with `test -f` instead of the local FS: a repository Dockerfile
1291
- * candidate is used when present; otherwise a Dockerfile is generated locally
1292
- * (pure fn) and written to the remote tree. Returns the dockerfile path
1293
- * relative to `remoteContextDir`.
1294
- */
1295
- async resolveRemoteDockerfile(config, remoteContextDir, generatedName, requireRepositoryDockerfile) {
1296
- const executor = this.connectionOptions?.executor;
1297
- if (!executor) throw new Error("Clone-on-server requires an SSH executor on connectionOptions");
1298
- for (const candidate of resolveDockerfileCandidates(config.rootDirectory, config.dockerfilePath)) {
1299
- const out = await executor.exec(`test -f ${sq(`${remoteContextDir}/${candidate}`)} && echo yes || true`).catch(() => "");
1300
- if (out.trim() === "yes") return candidate;
1301
- }
1302
- if (requireRepositoryDockerfile) {
1303
- const expected = config.dockerfilePath?.trim() || "Dockerfile";
1304
- throw new Error(
1305
- `No Dockerfile found in the cloned repo. Expected ${expected}${config.rootDirectory ? ` under ${config.rootDirectory}` : ""}.`
1306
- );
1307
- }
1308
- await executor.writeFile(`${remoteContextDir}/${generatedName}`, generateDockerfile(config));
1309
- return generatedName;
1310
- }
1311
- async buildViaSshTarPipe(config, buildContext, tag, log) {
1312
- const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
1313
- try {
1314
- await this.transferBuildContext(buildContext.contextDir, remoteContextDir, log);
1315
- await this.buildImageOnRemote(config, remoteContextDir, buildContext.dockerfileName, tag, log);
1316
- } finally {
1317
- this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
1318
- });
1319
- await buildContext.cleanup();
1320
- }
1321
- }
1322
- /**
1323
- * Dockerode build path. Used for local socket and TCP transports, plus
1324
- * SSH transports that didn't get an executor wired in (shouldn't happen
1325
- * in normal operation but kept as a safety net).
1326
- *
1327
- * This path is slower for SSH (HTTP-over-SSH upload has no streaming),
1328
- * but it's correct for local/TCP where there's no separate SSH
1329
- * connection to piggyback on.
1330
- */
1331
- async buildViaDockerode(config, buildContext, tag, log) {
1332
- log.log(`Streaming build context to Docker daemon - image tag: ${tag}`);
1333
- let stream;
1334
- try {
1335
- stream = await this.docker.buildImage(
1336
- { context: buildContext.contextDir, src: buildContext.contextEntries },
1337
- {
1338
- t: tag,
1339
- dockerfile: buildContext.dockerfileName,
1340
- labels: this.labels({ projectId: config.projectId, sessionId: config.sessionId }),
1341
- buildargs: {
1342
- ...config.envVars,
1343
- NODE_ENV: "production"
1344
- },
1345
- // Omitted entirely unless the project set a build cap — a self-hosted
1346
- // build should be free to use the machine (a production build often
1347
- // needs several GB). Opt-in only; see dockerBuildResourceLimits.
1348
- ...dockerBuildResourceLimits(config.resources),
1349
- forcerm: true
1350
- }
1351
- );
1352
- } finally {
1353
- await buildContext.cleanup();
1354
- }
1355
- log.log("Connected to Docker daemon. Build output follows:");
1356
- await this.streamDockerodeBuild(stream, log);
1357
- log.log("Docker daemon finished streaming build output. Finalizing image...\n");
1358
- }
1359
- /**
1360
- * Consume a dockerode build stream: stream progress to the log, enforce the
1361
- * idle timeout + keepalive, and throw on a fatal build event. Shared by the
1362
- * single-image dockerode path and the local batch build (buildImages).
1363
- */
1364
- async streamDockerodeBuild(stream, log) {
1365
- let fatalBuildError = null;
1366
- await new Promise((resolve, reject) => {
1367
- let settled = false;
1368
- let idleTimer = null;
1369
- let keepaliveTimer = null;
1370
- let idleMinutes = 0;
1371
- const clearTimers = () => {
1372
- if (idleTimer) {
1373
- clearTimeout(idleTimer);
1374
- idleTimer = null;
1375
- }
1376
- if (keepaliveTimer) {
1377
- clearInterval(keepaliveTimer);
1378
- keepaliveTimer = null;
1379
- }
1380
- idleMinutes = 0;
1381
- };
1382
- const fail = (error) => {
1383
- if (settled) return;
1384
- settled = true;
1385
- clearTimers();
1386
- stream.destroy?.(error);
1387
- reject(error);
1388
- };
1389
- const succeed = () => {
1390
- if (settled) return;
1391
- settled = true;
1392
- clearTimers();
1393
- resolve();
1394
- };
1395
- const resetIdleTimer = () => {
1396
- clearTimers();
1397
- keepaliveTimer = setInterval(() => {
1398
- idleMinutes += 1;
1399
- log.log(`Still building... (no output for ${idleMinutes}m)`);
1400
- }, 6e4);
1401
- if (keepaliveTimer.unref) keepaliveTimer.unref();
1402
- idleTimer = setTimeout(() => {
1403
- fail(new Error(
1404
- "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."
1405
- ));
1406
- }, DOCKER_BUILD_IDLE_TIMEOUT_MS);
1407
- if (idleTimer.unref) idleTimer.unref();
1408
- };
1409
- resetIdleTimer();
1410
- this.docker.modem.followProgress(
1411
- stream,
1412
- (err) => {
1413
- if (err) {
1414
- fail(err);
1415
- return;
1416
- }
1417
- succeed();
1418
- },
1419
- (event) => {
1420
- resetIdleTimer();
1421
- fatalBuildError ??= this.handleBuildEvent(event, log);
1422
- }
1423
- );
1424
- });
1425
- if (fatalBuildError) {
1426
- throw new Error(fatalBuildError);
1427
- }
1428
- }
1429
- /**
1430
- * Confirm a just-built image actually exists before handing its tag back
1431
- * as the deploy artifact.
1432
- *
1433
- * For SSH-transport builds, verify over the SAME pooled SSH exec channel
1434
- * the `docker build` command itself just ran on — not `this.docker`
1435
- * (dockerode), which for SSH transport talks over a SEPARATE, independently
1436
- * -tunneled streamlocal bridge connection (see docker-ssh-agent.ts). That
1437
- * second connection has proven unreliable in practice: it can hang
1438
- * indefinitely or report the image missing even though `docker images` on
1439
- * the box confirms it built successfully seconds earlier. Reusing the
1440
- * already-proven-live exec channel avoids standing up a second, flakier
1441
- * connection just to ask a question the first connection already knows
1442
- * the answer to.
1443
- *
1444
- * For local-socket/TCP transports there is no separate bridge — `this.docker`
1445
- * talks directly to the daemon — so the original dockerode check is fine
1446
- * and stays as the fallback.
1447
- */
1448
- async verifyImageBuilt(tag) {
1449
- const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
1450
- try {
1451
- if (executor) {
1452
- await executor.exec(`docker image inspect ${sq(tag)} >/dev/null`);
1453
- } else {
1454
- await this.docker.getImage(tag).inspect();
1455
- }
1456
- } catch (cause) {
1457
- throw new Error(`Docker build finished but the image ${tag} was not created`, { cause });
1458
- }
1459
- }
1460
- async build(config, logger) {
1461
- const log = logger ?? new BuildLogger();
1462
- const startTime = Date.now();
1463
- const tag = this.imageTag(config.slug, config.sessionId);
1464
- try {
1465
- log.log(`Build strategy: docker (${this.transport.description})
1466
- `);
1467
- try {
1468
- await this.ensureDockerFeature(log);
1469
- } catch (featureErr) {
1470
- throw new Error(this.formatDockerConnectivityError(featureErr));
1471
- }
1472
- const sshExecutor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
1473
- if (sshExecutor && config.cloneOnServer) {
1474
- const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
1475
- try {
1476
- this.emitDockerStep(log, "clone", "running", "Cloning source on the server...");
1477
- await this.cloneSourceOnRemote(config, remoteContextDir, log);
1478
- this.emitDockerStep(log, "clone", "completed", "Source cloned on the server");
1479
- const dockerfileName = await this.resolveRemoteDockerfile(
1480
- config,
1481
- remoteContextDir,
1482
- "Dockerfile.openship",
1483
- config.stack === "docker"
1484
- );
1485
- await this.buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log);
1486
- } finally {
1487
- sshExecutor.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
1488
- });
1489
- }
1490
- await this.verifyImageBuilt(tag);
1491
- log.log(`Image ${tag} is ready.
1492
- `);
1493
- log.step("build", "completed", `Finalizing image ${tag}`);
1494
- return { sessionId: config.sessionId, status: "deploying", imageRef: tag, durationMs: Date.now() - startTime };
1495
- }
1496
- this.emitDockerStep(log, "clone", "running", "Preparing Docker build context...");
1497
- const buildContext = await createDockerBuildContext(config, {
1498
- requireRepositoryDockerfile: config.stack === "docker",
1499
- onLog: log.callback
1500
- });
1501
- try {
1502
- const sizeBytes = await this.estimateContextSize(buildContext.contextDir);
1503
- const sizeMB = (sizeBytes / 1024 / 1024).toFixed(1);
1504
- this.emitDockerStep(
1505
- log,
1506
- "clone",
1507
- "completed",
1508
- `Docker build context ready (${sizeMB} MB)`
1509
- );
1510
- } catch {
1511
- this.emitDockerStep(log, "clone", "completed", "Docker build context ready");
1512
- }
1513
- if (buildContext.rootDirectory) {
1514
- log.log(`Using Docker build root: ${buildContext.rootDirectory}`);
1515
- }
1516
- if (buildContext.usesRepositoryDockerfile) {
1517
- this.emitDockerStep(
1518
- log,
1519
- "install",
1520
- "skipped",
1521
- "Repository Dockerfile owns dependency installation"
1522
- );
1523
- this.emitDockerStep(
1524
- log,
1525
- "build",
1526
- "running",
1527
- "Building image from repository Dockerfile..."
1528
- );
1529
- }
1530
- if (!buildContext.usesRepositoryDockerfile && !config.installCommand) {
1531
- this.emitDockerStep(log, "install", "skipped", "No install command configured");
1532
- }
1533
- if (!buildContext.usesRepositoryDockerfile && !config.buildCommand) {
1534
- this.emitDockerStep(log, "build", "skipped", "No build command configured");
1535
- }
1536
- if (sshExecutor) {
1537
- await this.buildViaSshTarPipe(config, buildContext, tag, log);
1538
- } else {
1539
- await this.buildViaDockerode(config, buildContext, tag, log);
1540
- }
1541
- await this.verifyImageBuilt(tag);
1542
- log.log(`Image ${tag} is ready.
1543
- `);
1544
- log.log(`[build] \u2713 Image ${tag} ready`);
1545
- log.step("build", "completed", `Finalizing image ${tag}`);
1546
- const durationMs = Date.now() - startTime;
1547
- return { sessionId: config.sessionId, status: "deploying", imageRef: tag, durationMs };
1548
- } catch (err) {
1549
- const msg = safeErrorMessage(err);
1550
- log.step("build", "failed", `Docker build failed: ${msg}`);
1551
- return { sessionId: config.sessionId, status: "failed", durationMs: Date.now() - startTime, errorMessage: `Docker build failed: ${msg}` };
1552
- }
1553
- }
1554
- /**
1555
- * Move an extract-only static build's files out of its builder image onto
1556
- * `hostOutDir`, then delete the image.
1557
- *
1558
- * The ONE place this happens, shared by the single-app path
1559
- * ({@link buildStaticToHost}) and the compose batch path ({@link buildImages}) —
1560
- * they used to be destined to diverge, and the source path is subtle enough
1561
- * (see `staticBuilderOutputPath`) that two copies would drift into extracting an
1562
- * empty directory.
1563
- */
1564
- async moveStaticBuildToHost(tag, config, hostOutDir, log) {
1565
- const docRoot = staticBuilderOutputPath(config);
1566
- const sshExecutor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
1567
- log.log(`Moving built files out of the build container (${docRoot})...
1568
- `);
1569
- try {
1570
- if (sshExecutor) {
1571
- await this.extractDocRootOverSsh(sshExecutor, tag, docRoot, hostOutDir);
1572
- } else {
1573
- const copiedInPlace = this.transport.kind === "socket" && await this.extractDocRootViaBindMount(tag, docRoot, hostOutDir);
1574
- if (!copiedInPlace) {
1575
- await this.extractDocRootViaDaemon(tag, docRoot, hostOutDir);
1576
- }
1577
- }
1578
- log.log(`Static files ready at ${hostOutDir}
1579
- `);
1580
- } finally {
1581
- await this.removeImage(tag).catch(() => {
1582
- });
1583
- }
1584
- }
1585
- /**
1586
- * Build a STATIC app in a Docker sandbox, then move the built files onto a host
1587
- * directory the edge serves via nginx `root`. Reuses `build()` so Docker is
1588
- * auto-ensured (ensureDockerFeature).
1589
- *
1590
- * NO RUNTIME IMAGE. `staticExtractOnly` stops the recipe at the builder stage and
1591
- * the files are copied straight out of it. This used to build a full
1592
- * `nginx:alpine` runtime stage — pulling that image, running six more steps, and
1593
- * writing an nginx config nothing reads — only to `docker cp` the doc-root out
1594
- * and delete the whole thing moments later. The edge already serves these files;
1595
- * a second web server was never going to run.
1596
- * Returns the host dir as `imageRef`, matching BareRuntime.build's host-dir
1597
- * contract so the existing file-backed serve path (deployStatic /
1598
- * resolveStaticRoot) consumes it unchanged. Never leaves a long-lived container:
1599
- * the extraction container runs one `cp` (or isn't started at all, when the tar is
1600
- * streamed instead) and is removed either way.
1601
- */
1602
- async buildStaticToHost(config, hostOutDir, logger) {
1603
- const log = logger ?? new BuildLogger();
1604
- const buildResult = await this.build(
1605
- { ...config, isStatic: true, staticExtractOnly: true },
1606
- log
1607
- );
1608
- if (buildResult.status !== "deploying" || !buildResult.imageRef) {
1609
- return buildResult;
1610
- }
1611
- const tag = buildResult.imageRef;
1612
- const extractStart = Date.now();
1613
- try {
1614
- await this.moveStaticBuildToHost(tag, config, hostOutDir, log);
1615
- return {
1616
- sessionId: config.sessionId,
1617
- status: "deploying",
1618
- imageRef: hostOutDir,
1619
- durationMs: (buildResult.durationMs ?? 0) + (Date.now() - extractStart)
1620
- };
1621
- } catch (err) {
1622
- const msg = safeErrorMessage(err);
1623
- log.step("build", "failed", `Static extract failed: ${msg}`);
1624
- return {
1625
- sessionId: config.sessionId,
1626
- status: "failed",
1627
- durationMs: (buildResult.durationMs ?? 0) + (Date.now() - extractStart),
1628
- errorMessage: `Static extract failed: ${msg}`
1629
- };
1630
- }
1631
- }
1632
- /**
1633
- * THE CONTRACT all three extractors satisfy: once one returns, `hostOutDir` holds
1634
- * the doc-root's CONTENTS directly — `index.html` at the top, no wrapping
1635
- * directory. OpenResty's `root` points straight at it, so one stray level is a
1636
- * silent 404 for the whole site.
1637
- *
1638
- * They reach that shape differently, which is why the rule is stated here once
1639
- * instead of in each branch:
1640
- * - `docker cp <cid>:<root>/.` and `cp -a <root>/. <out>/` — the trailing `/.`
1641
- * means "contents of", so there is nothing to strip.
1642
- * - `getArchive({path: <root>})` — the tar is rooted at the doc-root's own
1643
- * basename (`dist/`, `build/`, …), so exactly one leading component is
1644
- * stripped.
1645
- *
1646
- * The other half of the contract is that hostOutDir is non-EMPTY, which is what
1647
- * this enforces. It has to be checked per-path (three different filesystems) but
1648
- * the rule and its wording live here, because the failure it prevents is the
1649
- * expensive one: every extractor can succeed having copied nothing, which deploys
1650
- * green and then 404s the entire site.
1651
- */
1652
- assertDocRootFilled(isEmpty, docRoot) {
1653
- if (!isEmpty) return;
1654
- throw new Error(
1655
- `static extract produced no files from ${docRoot} in the build container \u2014 check the project's output directory setting.`
1656
- );
1657
- }
1658
- /** Remote host: disk-to-disk via the host's own docker CLI — never stream a
1659
- * large tar over the SSH dockerode bridge (same rationale as saveImage/pullImage). */
1660
- async extractDocRootOverSsh(sshExecutor, tag, docRoot, hostOutDir) {
1661
- const cid = (await sshExecutor.exec(`docker create ${sq(tag)}`)).trim();
1662
- try {
1663
- await sshExecutor.exec(`mkdir -p ${sq(hostOutDir)}`);
1664
- await sshExecutor.exec(`docker cp ${sq(`${cid}:${docRoot}/.`)} ${sq(hostOutDir)}`);
1665
- const listing = (await sshExecutor.exec(`ls -A ${sq(hostOutDir)} | head -1`).catch(() => "")).trim();
1666
- this.assertDocRootFilled(!listing, docRoot);
1667
- } finally {
1668
- await sshExecutor.exec(`docker rm ${sq(cid)}`).catch(() => {
1669
- });
1670
- }
1671
- }
1672
- /**
1673
- * LOCAL DAEMON: let the daemon do the copy on its own disk. The builder image is
1674
- * run once with `hostOutDir` bind-mounted, and it `cp -a`s the doc-root across.
1675
- * No archive crosses this process at all.
1676
- *
1677
- * This exists because streaming the doc-root out via `getArchive` is undebuggable
1678
- * when it goes wrong. Once the daemon has sent 200 + headers for an archive it has
1679
- * NO way to report a mid-archive failure — an unreadable file, a socket/fifo, a
1680
- * path it can't stat — so it just closes the connection. The client sees a
1681
- * truncated body and the only symptom anywhere is `tar: Unexpected EOF in
1682
- * archive`, with the actual cause visible solely in the daemon's own log. That is
1683
- * the failure this method removes: `cp` runs in the container, so a real error
1684
- * arrives as real stderr and a real exit code.
1685
- *
1686
- * Requires that the daemon's filesystem IS this process's filesystem, which is
1687
- * true for DooD (`/opt/openship/static` is a host bind mount at the same path
1688
- * inside and out — see docker/docker-compose.yml) and false for a VM-backed
1689
- * daemon (Docker Desktop, Colima), where `hostOutDir` resolves inside the VM and
1690
- * the copy would land somewhere this process can't see.
1691
- *
1692
- * So it PROVES the assumption instead of trusting the transport: a sentinel file
1693
- * is written here and the container refuses to copy unless it can see it. A
1694
- * daemon that can't reach our disk exits 97 and the caller streams instead. That
1695
- * check is folded into the same container run — no extra round trip — because
1696
- * getting it wrong means an empty doc-root, i.e. a site that deploys green and
1697
- * serves 404s.
1698
- *
1699
- * Returns false when the bind isn't shared; throws on a real copy failure.
1700
- */
1701
- async extractDocRootViaBindMount(tag, docRoot, hostOutDir) {
1702
- const { mkdir, readdir, writeFile, rm } = await import("fs/promises");
1703
- const { join } = await import("path");
1704
- const { randomBytes } = await import("crypto");
1705
- await mkdir(hostOutDir, { recursive: true });
1706
- const OUT = "/__openship_out";
1707
- const probe = `.openship-extract-probe-${randomBytes(6).toString("hex")}`;
1708
- await writeFile(join(hostOutDir, probe), "probe");
1709
- const container = await this.docker.createContainer({
1710
- Image: tag,
1711
- // Override the ENTRYPOINT: builder base images (node, bun) ship a
1712
- // docker-entrypoint.sh that would swallow this Cmd.
1713
- Entrypoint: ["/bin/sh", "-c"],
1714
- Cmd: [
1715
- `set -e; test -f ${sq(`${OUT}/${probe}`)} || exit 97; cp -a ${sq(docRoot)}/. ${OUT}/; rm -f ${sq(`${OUT}/${probe}`)}`
1716
- ],
1717
- // Root, explicitly. A builder image that sets `USER node`/`USER bun` would
1718
- // otherwise run this copy unprivileged and fail writing into a root-owned
1719
- // output dir — the two paths this replaces both run with daemon privileges,
1720
- // so anything less would be a NEW failure this change introduced.
1721
- User: "0:0",
1722
- HostConfig: {
1723
- // `:z` matches how compose mounts this dir — required on SELinux hosts for
1724
- // the container to write, and ignored where SELinux is off.
1725
- Binds: [`${hostOutDir}:${OUT}:z`]
1726
- }
1727
- });
1728
- try {
1729
- try {
1730
- await container.start();
1731
- } catch {
1732
- return false;
1733
- }
1734
- const status = await container.wait();
1735
- if (status.StatusCode === 97) return false;
1736
- if (status.StatusCode !== 0) {
1737
- const logs = await container.logs({ stdout: true, stderr: true, tail: 40 }).then((b) => Buffer.from(b).toString("utf8")).then((s) => s.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, " ").trim()).catch(() => "");
1738
- throw new Error(
1739
- `static extract failed copying ${docRoot} out of the build container (exit ${status.StatusCode})${logs ? `: ${logs.slice(-500)}` : ""}`
1740
- );
1741
- }
1742
- const entries = (await readdir(hostOutDir).catch(() => [])).filter((e) => e !== probe);
1743
- this.assertDocRootFilled(entries.length === 0, docRoot);
1744
- return true;
1745
- } finally {
1746
- await container.remove({ force: true }).catch(() => {
1747
- });
1748
- await rm(join(hostOutDir, probe), { force: true }).catch(() => {
1749
- });
1750
- }
1751
- }
1752
- /** Remote TCP daemon: no shared filesystem and no local `docker` CLI, so the tar
1753
- * has to come through dockerode and be extracted onto THIS process's disk. */
1754
- async extractDocRootViaDaemon(tag, docRoot, hostOutDir) {
1755
- const { mkdir, readdir } = await import("fs/promises");
1756
- const { spawn } = await import("child_process");
1757
- const { pipeline } = await import("stream/promises");
1758
- await mkdir(hostOutDir, { recursive: true });
1759
- const container = await this.docker.createContainer({ Image: tag });
1760
- try {
1761
- const tarStream = await container.getArchive({ path: docRoot });
1762
- const extract = spawn("tar", ["-x", "--strip-components=1", "-C", hostOutDir]);
1763
- let errBuf = "";
1764
- extract.stderr.on("data", (d) => errBuf += d.toString());
1765
- const exited = new Promise((resolve) => {
1766
- extract.on("close", (code2) => resolve(code2 ?? 0));
1767
- extract.on("error", () => resolve(-1));
1768
- });
1769
- let streamError;
1770
- await pipeline(tarStream, extract.stdin).catch((err) => {
1771
- streamError = err;
1772
- });
1773
- const code = await exited;
1774
- const tarErr = errBuf.trim();
1775
- if (streamError && !(code !== 0 && tarErr)) {
1776
- throw new Error(
1777
- `static extract failed reading ${docRoot} from the build container: ${safeErrorMessage(streamError)}${tarErr ? ` (tar: ${tarErr.slice(-200)})` : ""}`
1778
- );
1779
- }
1780
- if (code !== 0) {
1781
- throw new Error(`static extract failed (tar ${code}): ${tarErr.slice(-500)}`);
1782
- }
1783
- const entries = await readdir(hostOutDir).catch(() => []);
1784
- this.assertDocRootFilled(entries.length === 0, docRoot);
1785
- } finally {
1786
- await container.remove({ force: true }).catch(() => {
1787
- });
1788
- }
1789
- }
1790
- /**
1791
- * Batch build: clone + prune the shared source ONCE, then build every image
1792
- * from that single tree. For SSH the context is transferred ONCE and each
1793
- * image builds on the remote against it. Eliminates the per-service re-clone
1794
- * / re-transfer that N separate build() calls incur — every service builds on
1795
- * the SAME daemon, so the source only needs to arrive once.
1796
- *
1797
- * Prepare-phase logs (clone/transfer) go to `prepareLogger`; each image's
1798
- * build output goes to its own `spec.logger`.
1799
- */
1800
- async buildImages(specs, prepareLogger) {
1801
- if (specs.length === 0) return [];
1802
- try {
1803
- await this.ensureDockerFeature(prepareLogger);
1804
- } catch (featureErr) {
1805
- throw new Error(this.formatDockerConnectivityError(featureErr));
1806
- }
1807
- const source = specs[0].config;
1808
- const isSsh = this.transport.kind === "ssh" && !!this.connectionOptions?.executor;
1809
- const cloneOnServer = isSsh && !!source.cloneOnServer;
1810
- const remoteContextDir = `/tmp/openship-build-${source.sessionId}`;
1811
- let tree = null;
1812
- if (cloneOnServer) {
1813
- prepareLogger.step("clone", "running", "Cloning source on the server...");
1814
- await this.cloneSourceOnRemote(source, remoteContextDir, prepareLogger);
1815
- prepareLogger.step("clone", "completed", "Source cloned on the server");
1816
- } else {
1817
- prepareLogger.step("clone", "running", "Preparing shared build context...");
1818
- tree = await prepareSourceTree(source, { onLog: prepareLogger.callback });
1819
- }
1820
- try {
1821
- const resolvedList = await Promise.all(
1822
- specs.map(async (spec) => {
1823
- const generatedName = `Dockerfile.openship.${spec.config.sessionId}`;
1824
- const requireRepo = spec.requireRepositoryDockerfile ?? spec.config.stack === "docker";
1825
- try {
1826
- if (cloneOnServer) {
1827
- const dockerfileName = await this.resolveRemoteDockerfile(
1828
- spec.config,
1829
- remoteContextDir,
1830
- generatedName,
1831
- requireRepo
1832
- );
1833
- return {
1834
- spec,
1835
- dockerfileName,
1836
- contextEntries: null,
1837
- error: null
1838
- };
1839
- }
1840
- const resolved = await resolveServiceDockerfile(tree.contextDir, spec.config, {
1841
- requireRepositoryDockerfile: requireRepo,
1842
- generatedName
1843
- });
1844
- return {
1845
- spec,
1846
- dockerfileName: resolved.dockerfileName,
1847
- contextEntries: resolved.contextEntries,
1848
- error: null
1849
- };
1850
- } catch (err) {
1851
- return {
1852
- spec,
1853
- dockerfileName: null,
1854
- contextEntries: null,
1855
- error: safeErrorMessage(err)
1856
- };
1857
- }
1858
- })
1859
- );
1860
- if (!cloneOnServer && tree) {
1861
- try {
1862
- const sizeBytes = await this.estimateContextSize(tree.contextDir);
1863
- prepareLogger.step(
1864
- "clone",
1865
- "completed",
1866
- `Shared build context ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB)`
1867
- );
1868
- } catch {
1869
- prepareLogger.step("clone", "completed", "Shared build context ready");
1870
- }
1871
- if (isSsh) {
1872
- await this.transferBuildContext(tree.contextDir, remoteContextDir, prepareLogger);
1873
- }
1874
- }
1875
- const results = [];
1876
- for (const { spec, dockerfileName, contextEntries, error } of resolvedList) {
1877
- const startedAt = Date.now();
1878
- const tag = this.imageTag(spec.config.slug, spec.config.sessionId);
1879
- if (error || !dockerfileName) {
1880
- const result = {
1881
- sessionId: spec.config.sessionId,
1882
- status: "failed",
1883
- durationMs: Date.now() - startedAt,
1884
- errorMessage: error ?? "Failed to resolve Dockerfile"
1885
- };
1886
- spec.onResult?.(result);
1887
- results.push({ serviceName: spec.serviceName, result });
1888
- continue;
1889
- }
1890
- spec.onStart?.();
1891
- try {
1892
- if (isSsh) {
1893
- await this.buildImageOnRemote(
1894
- spec.config,
1895
- remoteContextDir,
1896
- dockerfileName,
1897
- tag,
1898
- spec.logger
1899
- );
1900
- } else {
1901
- const stream = await this.docker.buildImage(
1902
- { context: tree.contextDir, src: contextEntries ?? [] },
1903
- {
1904
- t: tag,
1905
- dockerfile: dockerfileName,
1906
- labels: this.labels({
1907
- projectId: spec.config.projectId,
1908
- sessionId: spec.config.sessionId
1909
- }),
1910
- buildargs: { ...spec.config.envVars, NODE_ENV: "production" },
1911
- ...dockerBuildResourceLimits(spec.config.resources),
1912
- forcerm: true
1913
- }
1914
- );
1915
- await this.streamDockerodeBuild(stream, spec.logger);
1916
- }
1917
- await this.verifyImageBuilt(tag);
1918
- if (spec.config.staticExtractOnly && spec.config.staticOutDir) {
1919
- await this.moveStaticBuildToHost(
1920
- tag,
1921
- spec.config,
1922
- spec.config.staticOutDir,
1923
- spec.logger
1924
- );
1925
- const result2 = {
1926
- sessionId: spec.config.sessionId,
1927
- status: "deploying",
1928
- imageRef: spec.config.staticOutDir,
1929
- durationMs: Date.now() - startedAt
1930
- };
1931
- spec.onResult?.(result2);
1932
- results.push({ serviceName: spec.serviceName, result: result2 });
1933
- continue;
1934
- }
1935
- spec.logger.log(`Image ${tag} is ready.
1936
- `);
1937
- const result = {
1938
- sessionId: spec.config.sessionId,
1939
- status: "deploying",
1940
- imageRef: tag,
1941
- durationMs: Date.now() - startedAt
1942
- };
1943
- spec.onResult?.(result);
1944
- results.push({ serviceName: spec.serviceName, result });
1945
- } catch (err) {
1946
- const msg = safeErrorMessage(err);
1947
- spec.logger.log(`Docker build failed: ${msg}
1948
- `, "error");
1949
- const result = {
1950
- sessionId: spec.config.sessionId,
1951
- status: "failed",
1952
- durationMs: Date.now() - startedAt,
1953
- errorMessage: `Docker build failed: ${msg}`
1954
- };
1955
- spec.onResult?.(result);
1956
- results.push({ serviceName: spec.serviceName, result });
1957
- }
1958
- }
1959
- return results;
1960
- } finally {
1961
- if (isSsh) {
1962
- this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
1963
- });
1964
- }
1965
- if (tree) await tree.cleanup();
1966
- }
1967
- }
1968
- async cancelBuild(sessionId) {
1969
- const containers = await this.docker.listContainers({
1970
- all: true,
1971
- filters: { label: [`openship.build=${sessionId}`] }
1972
- });
1973
- for (const c of containers) {
1974
- try {
1975
- await this.docker.getContainer(c.Id).remove({ force: true });
1976
- } catch {
1977
- }
1978
- }
1979
- }
1980
- async getBuildLogs(sessionId) {
1981
- void sessionId;
1982
- return [];
1983
- }
1984
- // ── Deploy lifecycle ───────────────────────────────────────────────────
1985
- async deploy(config, onLog) {
1986
- const log = onLog ?? (() => {
1987
- });
1988
- const imageRef = config.imageRef;
1989
- if (!imageRef) {
1990
- throw new Error("Docker deploy requires an imageRef (built image tag)");
1991
- }
1992
- const containerName = `openship-${config.runtimeName || config.projectId}-${config.deploymentId}`;
1993
- const env = [
1994
- `PORT=${config.port}`,
1995
- `NODE_ENV=${config.environment === "production" ? "production" : "development"}`,
1996
- ...Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`)
1997
- ];
1998
- const cmd = config.startCommand ? ["sh", "-c", config.startCommand] : void 0;
1999
- const restartPolicy = resolveRestartPolicy(config.restartPolicy);
2000
- const scopedBinds = scopeVolumeBinds(
2001
- config.slug || config.runtimeName || config.projectId,
2002
- config.volumes ?? [],
2003
- true
2004
- );
2005
- const binds = scopedBinds.length > 0 ? scopedBinds : void 0;
2006
- log({
2007
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2008
- message: `Creating container ${containerName} from ${imageRef}...
2009
- `,
2010
- level: "info"
2011
- });
2012
- if (binds) {
2013
- log({
2014
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2015
- message: `Persistent storage: ${binds.join(", ")}
2016
- `,
2017
- level: "info"
2018
- });
2019
- }
2020
- log({
2021
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2022
- message: `Resource limits: ${describeResourceLimits(config.resources)}
2023
- `,
2024
- level: "info"
2025
- });
2026
- const container = await this.docker.createContainer({
2027
- name: containerName,
2028
- Image: imageRef,
2029
- Cmd: cmd,
2030
- Env: env,
2031
- Labels: this.labels({
2032
- deploymentId: config.deploymentId,
2033
- projectId: config.projectId
2034
- }),
2035
- ExposedPorts: { [`${config.port}/tcp`]: {} },
2036
- HostConfig: {
2037
- RestartPolicy: restartPolicy,
2038
- Binds: binds,
2039
- // Omitted entirely when the project has no cap (self-hosted default) —
2040
- // see dockerResourceLimits. Never substitute a tier here.
2041
- ...dockerResourceLimits(config.resources),
2042
- // Publish on the LOOPBACK interface only — the edge (host process, or a
2043
- // host-net OpenResty container) reaches it at 127.0.0.1:<hostPort>, and
2044
- // it never faces the network. Binding 0.0.0.0 here would expose every
2045
- // app directly, bypassing the edge's SSL/rate-limit/rules (and Docker's
2046
- // iptables bypass ufw). A pinned `config.hostPort` (loopback-port route
2047
- // strategy) is stable across redeploys; otherwise a random loopback port.
2048
- PortBindings: {
2049
- [`${config.port}/tcp`]: [
2050
- { HostIp: "127.0.0.1", HostPort: config.hostPort ? String(config.hostPort) : "" }
2051
- ]
2052
- }
2053
- }
2054
- });
2055
- await container.start();
2056
- log({
2057
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2058
- message: `Container ${container.id.slice(0, 12)} started.
2059
- `,
2060
- level: "info"
2061
- });
2062
- return {
2063
- deploymentId: config.deploymentId,
2064
- containerId: container.id,
2065
- status: "running"
2066
- };
2067
- }
2068
- async stop(containerId) {
2069
- const container = this.docker.getContainer(containerId);
2070
- await container.stop();
2071
- }
2072
- async start(containerId) {
2073
- const container = this.docker.getContainer(containerId);
2074
- await container.start();
2075
- }
2076
- async restart(containerId) {
2077
- const container = this.docker.getContainer(containerId);
2078
- await container.restart();
2079
- }
2080
- async removeImage(imageRef) {
2081
- const image = this.docker.getImage(imageRef);
2082
- try {
2083
- await image.remove({ force: true });
2084
- } catch (err) {
2085
- if (!isDockerNotFoundError(err)) throw err;
2086
- }
2087
- }
2088
- async destroy(containerId) {
2089
- if (containerId.startsWith("/")) {
2090
- const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
2091
- if (executor) {
2092
- await executor.exec(`rm -rf ${sq(containerId)}`).catch(() => {
2093
- });
2094
- } else {
2095
- const { rm } = await import("fs/promises");
2096
- await rm(containerId, { recursive: true, force: true }).catch(() => {
2097
- });
2098
- }
2099
- return;
2100
- }
2101
- const container = this.docker.getContainer(containerId);
2102
- try {
2103
- await container.remove({ force: true });
2104
- } catch (err) {
2105
- if (!isDockerNotFoundError(err)) throw err;
2106
- }
2107
- }
2108
- /**
2109
- * Every container (running OR stopped) labeled for this project. Lets
2110
- * project teardown reclaim orphans that have no DB row — e.g. a deploy
2111
- * that started a container then failed during routing, or rows lost to
2112
- * a crash. The `openship.project` label is stamped at create time
2113
- * (see `labels()`), so this is authoritative for THIS docker host.
2114
- */
2115
- async listProjectContainerIds(projectId) {
2116
- const containers = await this.docker.listContainers({
2117
- all: true,
2118
- filters: { label: [`openship.project=${projectId}`] }
2119
- });
2120
- return containers.map((c) => c.Id);
2121
- }
2122
- /**
2123
- * Built images that belong to this project — the label `openship.project=<id>`
2124
- * that `labels()` stamps on every FINAL build image. Base/third-party images
2125
- * (postgres, redis, …) are PULLED, never labeled, so they can never appear
2126
- * here — that label filter is the primary guardrail for the image GC, which
2127
- * reconciles this on-host set against the DB keep-set. Includes untagged
2128
- * (dangling) superseded finals: labels persist after the tag is removed.
2129
- */
2130
- async listProjectImages(projectId) {
2131
- const images = await this.docker.listImages({
2132
- filters: { label: [`openship.project=${projectId}`] }
2133
- });
2134
- return images.map((img) => ({
2135
- id: img.Id,
2136
- repoTags: (img.RepoTags ?? []).filter((t) => t && t !== "<none>:<none>"),
2137
- buildId: img.Labels?.["openship.build"],
2138
- deploymentId: img.Labels?.["openship.deployment"],
2139
- size: img.Size ?? 0
2140
- }));
2141
- }
2142
- /**
2143
- * Reclaim dangling (untagged) images carrying THIS project's label — the
2144
- * superseded final-stage layers a rebuild leaves behind. NEVER a bare
2145
- * `docker image prune`: the label filter guarantees base/other-project/other-
2146
- * tool dangling layers are untouched. (Unlabeled multi-stage intermediate
2147
- * layers under the classic builder aren't caught — BuildKit avoids them.)
2148
- */
2149
- async pruneProjectDanglingImages(projectId) {
2150
- try {
2151
- await this.docker.pruneImages({
2152
- filters: { dangling: ["true"], label: [`openship.project=${projectId}`] }
2153
- });
2154
- } catch {
2155
- }
2156
- }
2157
- /**
2158
- * Containers labeled for this deployment, with live state — the reconcile
2159
- * read-back. `State` is dockerode's `running | exited | paused | ...`; map it
2160
- * to ContainerStatus the same way getContainerInfo does. Absence is conveyed
2161
- * by an EMPTY list (no container carries the label), which the reconciler
2162
- * reads as drift for the expected services.
2163
- */
2164
- async listDeploymentContainers(deploymentId) {
2165
- const containers = await this.docker.listContainers({
2166
- all: true,
2167
- filters: { label: [`openship.deployment=${deploymentId}`] }
2168
- });
2169
- const stateMap = {
2170
- running: "running",
2171
- healthy: "running",
2172
- starting: "running",
2173
- restarting: "running",
2174
- exited: "stopped",
2175
- paused: "stopped",
2176
- created: "stopped",
2177
- dead: "failed",
2178
- unhealthy: "failed"
2179
- };
2180
- return containers.map((c) => ({
2181
- containerId: c.Id,
2182
- status: stateMap[(c.State ?? "").toLowerCase().trim()] ?? "stopped",
2183
- serviceName: c.Labels?.["openship.service"]
2184
- }));
2185
- }
2186
- // ── Rollback primitives (retention half only) ────────────────────────
2187
- //
2188
- // Docker deliberately does NOT implement `makeActive` — see the
2189
- // "unitRestore" capability. A redeploy REMOVES the previous container
2190
- // (loopback-port routing can't overlap two containers on one host
2191
- // port), so there is no unit left to restart; the durable artifact is
2192
- // the IMAGE. Restore therefore re-materializes the container through
2193
- // the normal deploy step from the target's frozen snapshot + retained
2194
- // image (modules/deployments/rollback/restore-plan.ts), which is the
2195
- // only way env, published port, volumes, labels, network and routing
2196
- // all come back correctly.
2197
- //
2198
- // archive — `docker stop` (usually a no-op: the container is gone).
2199
- // The image stays tagged, retained by the rollback-window
2200
- // keep-set in modules/deployments/image-gc.
2201
- // purge — `docker rm` (force) + `docker rmi`. Past this point an
2202
- // instant restore of this deployment is impossible and
2203
- // rollback degrades to a rebuild from its commit.
2204
- async archive(deployment) {
2205
- if (!deployment.containerId) return;
2206
- try {
2207
- await this.stop(deployment.containerId);
2208
- } catch {
2209
- }
2210
- }
2211
- async purge(deployment) {
2212
- if (deployment.containerId) {
2213
- try {
2214
- await this.destroy(deployment.containerId);
2215
- } catch {
2216
- }
2217
- }
2218
- if (deployment.imageRef && ownsBuiltImage(deployment.imageRef)) {
2219
- try {
2220
- await this.removeImage(deployment.imageRef);
2221
- } catch {
2222
- }
2223
- }
2224
- }
2225
- /**
2226
- * Inspect a container and return the names of its **named** volumes -
2227
- * the ones that survive `container.remove()` and would otherwise leak.
2228
- * Anonymous volumes are auto-removed with `{ v: true }` and don't need to
2229
- * be enumerated. Bind mounts and tmpfs are skipped (the user manages them
2230
- * outside our control). Returns [] if the container is already gone.
2231
- */
2232
- async inspectNamedVolumes(containerId) {
2233
- try {
2234
- const container = this.docker.getContainer(containerId);
2235
- const data = await container.inspect();
2236
- const mounts = data.Mounts ?? [];
2237
- return mounts.filter((m) => m.Type === "volume" && typeof m.Name === "string" && m.Name.length > 0).map((m) => m.Name);
2238
- } catch {
2239
- return [];
2240
- }
2241
- }
2242
- /** Remove a named volume by name. Best-effort - already-gone is fine. */
2243
- async removeVolume(name) {
2244
- try {
2245
- const volume = this.docker.getVolume(name);
2246
- await volume.remove({ force: true });
2247
- } catch {
2248
- }
2249
- }
2250
- // ── Docker discovery (label-agnostic) ────────────────────────────────────
2251
- //
2252
- // Enumerate the ENTIRE daemon, not just openship-labeled resources. Powers
2253
- // "migrate an existing Docker deployment": read whatever already runs on a
2254
- // server (a compose stack or hand-run containers) so it can be adopted as an
2255
- // Openship project. Strictly read-only.
2256
- /** Every container on the host (running or stopped), summarized. */
2257
- async listAllContainers() {
2258
- const containers = await this.docker.listContainers({ all: true });
2259
- return containers.map((c) => {
2260
- const labels = c.Labels ?? {};
2261
- const ip = firstNetworkIp(c.NetworkSettings?.Networks);
2262
- return {
2263
- id: c.Id,
2264
- names: (c.Names ?? []).map((n) => n.replace(/^\//, "")),
2265
- image: c.Image,
2266
- imageId: c.ImageID,
2267
- state: (c.State ?? "").toLowerCase().trim(),
2268
- status: c.Status,
2269
- labels,
2270
- ports: (c.Ports ?? []).map((p) => ({
2271
- privatePort: p.PrivatePort,
2272
- ...p.PublicPort ? { publicPort: p.PublicPort } : {},
2273
- type: p.Type ?? "tcp",
2274
- ...p.IP ? { ip: p.IP } : {}
2275
- })),
2276
- mounts: (c.Mounts ?? []).map(normalizeDockerMount),
2277
- ...ip ? { ip } : {},
2278
- composeProject: labels["com.docker.compose.project"] || void 0,
2279
- composeService: labels["com.docker.compose.service"] || void 0
2280
- };
2281
- });
2282
- }
2283
- /** Full inspect of one container, normalized. Null if the container is gone. */
2284
- async inspectContainer(id) {
2285
- let data;
2286
- try {
2287
- data = await this.docker.getContainer(id).inspect();
2288
- } catch (err) {
2289
- if (isDockerNotFoundError(err)) return null;
2290
- throw err;
2291
- }
2292
- const labels = data.Config?.Labels ?? {};
2293
- const hc = data.Config?.Healthcheck;
2294
- const rp = data.HostConfig?.RestartPolicy;
2295
- const configFiles = labels["com.docker.compose.project.config_files"];
2296
- return {
2297
- id: data.Id,
2298
- name: (data.Name ?? "").replace(/^\//, ""),
2299
- image: data.Config?.Image ?? data.Image,
2300
- imageId: data.Image,
2301
- state: (data.State?.Status ?? "").toLowerCase().trim() || "unknown",
2302
- command: toStringArray(data.Config?.Cmd),
2303
- entrypoint: toStringArray(data.Config?.Entrypoint),
2304
- env: data.Config?.Env ?? [],
2305
- workingDir: data.Config?.WorkingDir || void 0,
2306
- labels,
2307
- restart: rp?.Name ? { name: rp.Name, maximumRetryCount: rp.MaximumRetryCount } : void 0,
2308
- networks: Object.keys(data.NetworkSettings?.Networks ?? {}),
2309
- mounts: (data.Mounts ?? []).map(normalizeDockerMount),
2310
- ports: normalizeInspectPorts(data),
2311
- healthcheck: hc ? {
2312
- test: hc.Test,
2313
- interval: hc.Interval,
2314
- timeout: hc.Timeout,
2315
- retries: hc.Retries,
2316
- startPeriod: hc.StartPeriod
2317
- } : void 0,
2318
- resources: inspectResourceLimits(data.HostConfig),
2319
- composeProject: labels["com.docker.compose.project"] || void 0,
2320
- composeService: labels["com.docker.compose.service"] || void 0,
2321
- composeConfigFiles: configFiles ? configFiles.split(",").map((s) => s.trim()).filter(Boolean) : void 0,
2322
- composeWorkingDir: labels["com.docker.compose.project.working_dir"] || void 0
2323
- };
2324
- }
2325
- /** The image's baked-in default env (Config.Env). Discovery subtracts these
2326
- * from a container's env so only user-set vars are imported, not the dozen
2327
- * defaults a base image (postgres, node, …) ships with. [] if unavailable. */
2328
- async inspectImageEnv(ref) {
2329
- try {
2330
- const data = await this.docker.getImage(ref).inspect();
2331
- return data.Config?.Env ?? [];
2332
- } catch {
2333
- return [];
2334
- }
2335
- }
2336
- /** The image's baked-in default CMD (exec-form tokens). Used by migration to
2337
- * drop a container's `command` when it merely restates the image default —
2338
- * re-specifying it (and wrapping in `sh -c`) defeats entrypoints that drop
2339
- * privileges by argv (postgres refuses to run as root otherwise). */
2340
- async inspectImageCmd(ref) {
2341
- try {
2342
- const data = await this.docker.getImage(ref).inspect();
2343
- return data.Config?.Cmd ?? [];
2344
- } catch {
2345
- return [];
2346
- }
2347
- }
2348
- /**
2349
- * Ensure an image is present, pulling it if missing. THE single image-pull
2350
- * path — reused by the deploy pipeline and the backup/migration executor so
2351
- * connectivity lives in one place.
2352
- *
2353
- * Over an SSH transport the pull runs as a blocking `docker pull` through the
2354
- * command executor, NOT dockerode's `pull` + `modem.followProgress`: the
2355
- * progress stream tunneled over the SSH docker socket never emits `end`, so
2356
- * followProgress hangs forever (this was the cross-server migration stall).
2357
- * A local socket has no such issue, so it keeps the native dockerode pull.
2358
- */
2359
- async pullImage(ref, opts) {
2360
- if (!opts?.force) {
2361
- try {
2362
- await this.docker.getImage(ref).inspect();
2363
- return;
2364
- } catch {
2365
- }
2366
- }
2367
- const executor = this.connectionOptions?.executor;
2368
- if (executor) {
2369
- await executor.exec(`docker pull ${sq(ref)}`, { timeout: 10 * 6e4 });
2370
- return;
2371
- }
2372
- const stream = await this.docker.pull(ref);
2373
- await new Promise((resolve, reject) => {
2374
- this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
2375
- });
2376
- }
2377
- /** Is this image tag present on THIS daemon? Distinguishes a locally-built
2378
- * image (must be transferred cross-server) from a registry tag (the target
2379
- * just pulls it). */
2380
- async imageExistsLocally(ref) {
2381
- const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
2382
- if (executor) {
2383
- const out = await executor.exec(`docker image inspect ${sq(ref)} >/dev/null 2>&1 && echo yes || true`).catch(() => "");
2384
- return out.trim() === "yes";
2385
- }
2386
- try {
2387
- await this.docker.getImage(ref).inspect();
2388
- return true;
2389
- } catch {
2390
- return false;
2391
- }
2392
- }
2393
- /**
2394
- * Stream `docker save <ref>` OUT of this daemon as a Readable — the source
2395
- * half of a cross-server image move. No extra compression: image layers are
2396
- * already gzip-compressed, so re-compressing burns CPU for ~nothing.
2397
- *
2398
- * Over SSH the save runs as a native `docker save` through the raw command
2399
- * channel (rawExec) — the same reason pullImage/build avoid dockerode over the
2400
- * tunnel: a streamed dockerode body over the streamlocal bridge is unreliable.
2401
- * Local socket / TCP use dockerode's image.get() directly.
2402
- */
2403
- async saveImage(ref) {
2404
- const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
2405
- if (executor?.rawExec) {
2406
- const { stdout: stdout2, stderr, onClose } = await executor.rawExec(`docker save ${sq(ref)}`);
2407
- let stderrBuf = "";
2408
- stderr.on("data", (c) => {
2409
- stderrBuf += c.toString();
2410
- if (stderrBuf.length > 16 * 1024) stderrBuf = stderrBuf.slice(-16 * 1024);
2411
- });
2412
- stderr.resume();
2413
- return { stdout: stdout2, awaitExit: onClose.then((code) => ({ code, stderr: stderrBuf.trim() })) };
2414
- }
2415
- const stdout = await this.docker.getImage(ref).get();
2416
- const awaitExit = new Promise((resolve, reject) => {
2417
- stdout.on("end", () => resolve({ code: 0, stderr: "" }));
2418
- stdout.on("error", reject);
2419
- });
2420
- return { stdout, awaitExit };
2421
- }
2422
- /**
2423
- * Load an image tar (a `docker save` stream) INTO this daemon — the target
2424
- * half of a cross-server image move. Over SSH the tar streams into a native
2425
- * `docker load` stdin (execWithInput); local socket / TCP use dockerode's
2426
- * loadImage. Throws on a non-zero load. Returns the reference `docker load`
2427
- * reported ("Loaded image( ID)?: <ref>") so the caller can retag: a
2428
- * save-by-id load is untagged AND restores under the CONFIG image id, which
2429
- * differs from the source ref — tagging by the source id would fail.
2430
- */
2431
- async loadImage(body) {
2432
- const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
2433
- if (executor?.execWithInput) {
2434
- const { code, stderr, stdout } = await executor.execWithInput(`docker load`, body);
2435
- if (code !== 0) throw new Error(`docker load exited ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
2436
- return parseLoadedImageRef(stdout);
2437
- }
2438
- const stream = await this.docker.loadImage(body);
2439
- let loadOutput = "";
2440
- await new Promise((resolve, reject) => {
2441
- this.docker.modem.followProgress(
2442
- stream,
2443
- (err) => err ? reject(err) : resolve(),
2444
- (ev) => {
2445
- if (ev?.stream) loadOutput += ev.stream;
2446
- }
2447
- );
2448
- });
2449
- return parseLoadedImageRef(loadOutput);
2450
- }
2451
- /**
2452
- * Apply a tag to an image (`docker tag <source> <target>`). Used after a
2453
- * save-by-ID / load: `docker save <id>` loads UNTAGGED, so the target daemon
2454
- * needs the original tag re-applied — the adopted service's deploy `imageRef`
2455
- * is that tag. `source` is typically the sha id, `target` the tag. Idempotent.
2456
- */
2457
- async tagImage(source, target) {
2458
- if (source === target) return;
2459
- const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
2460
- if (executor) {
2461
- await executor.exec(`docker tag ${sq(source)} ${sq(target)}`);
2462
- return;
2463
- }
2464
- const [repo, tag] = target.includes(":") ? [target.slice(0, target.lastIndexOf(":")), target.slice(target.lastIndexOf(":") + 1)] : [target, void 0];
2465
- await this.docker.getImage(source).tag({ repo, ...tag ? { tag } : {} });
2466
- }
2467
- /** Every named volume on the host. */
2468
- async listAllVolumes() {
2469
- const res = await this.docker.listVolumes();
2470
- return (res?.Volumes ?? []).map((v) => ({
2471
- name: v.Name,
2472
- driver: v.Driver,
2473
- mountpoint: v.Mountpoint,
2474
- labels: v.Labels ?? {},
2475
- composeProject: v.Labels?.["com.docker.compose.project"] || void 0
2476
- }));
2477
- }
2478
- /** Every network on the host. */
2479
- async listAllNetworks() {
2480
- const nets = await this.docker.listNetworks();
2481
- return nets.map((n) => ({
2482
- id: n.Id,
2483
- name: n.Name,
2484
- driver: n.Driver,
2485
- labels: n.Labels ?? {},
2486
- composeProject: n.Labels?.["com.docker.compose.project"] || void 0
2487
- }));
2488
- }
2489
- // ── Observability ──────────────────────────────────────────────────────
2490
- async getContainerInfo(containerId) {
2491
- const container = this.docker.getContainer(containerId);
2492
- let data;
2493
- try {
2494
- data = await container.inspect();
2495
- } catch (err) {
2496
- if (isDockerNotFoundError(err)) {
2497
- return { containerId, status: "missing" };
2498
- }
2499
- throw err;
2500
- }
2501
- const statusMap = {
2502
- running: "running",
2503
- healthy: "running",
2504
- starting: "running",
2505
- restarting: "running",
2506
- exited: "stopped",
2507
- paused: "stopped",
2508
- created: "stopped",
2509
- dead: "failed",
2510
- unhealthy: "failed"
2511
- };
2512
- const startedAt = data.State.StartedAt;
2513
- const uptimeSeconds = startedAt && data.State.Running ? Math.floor((Date.now() - new Date(startedAt).getTime()) / 1e3) : void 0;
2514
- const { ip, hostPort } = extractNetworkInfo(data);
2515
- let status;
2516
- if (data.State.Running) {
2517
- status = "running";
2518
- } else if (data.State.Paused) {
2519
- status = "stopped";
2520
- } else {
2521
- const rawStatus = (data.State.Status ?? "").toLowerCase().trim();
2522
- status = statusMap[rawStatus] ?? "stopped";
2523
- }
2524
- return {
2525
- containerId,
2526
- status,
2527
- ip,
2528
- hostPort,
2529
- uptimeSeconds: uptimeSeconds && uptimeSeconds > 0 ? uptimeSeconds : void 0
2530
- };
2531
- }
2532
- /**
2533
- * One stabilization reading for the post-deploy watch. Unlike
2534
- * `getContainerInfo` this keeps `restarting` distinct and carries
2535
- * `RestartCount` / `ExitCode` / health — the only fields that separate a
2536
- * container that is UP from one that is bouncing. A vanished container is a
2537
- * `missing` sample (not a throw): that is a verdict, not a transport error.
2538
- */
2539
- async sampleStability(containerId) {
2540
- let data;
2541
- try {
2542
- data = await this.docker.getContainer(containerId).inspect();
2543
- } catch (err) {
2544
- if (isDockerNotFoundError(err)) {
2545
- return {
2546
- state: "missing",
2547
- exitCode: null,
2548
- restartCount: 0,
2549
- health: null,
2550
- errorLine: null,
2551
- oomKilled: false,
2552
- restartPolicy: null
2553
- };
2554
- }
2555
- throw err;
2556
- }
2557
- const rawState = (data.State?.Status ?? "").toLowerCase().trim();
2558
- const state = ["created", "running", "restarting", "paused", "exited", "dead", "removing"].includes(rawState) ? rawState : "unknown";
2559
- const rawHealth = (data.State?.Health?.Status ?? "").toLowerCase().trim();
2560
- const health = rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "starting" ? rawHealth : null;
2561
- return {
2562
- state,
2563
- exitCode: typeof data.State?.ExitCode === "number" ? data.State.ExitCode : null,
2564
- restartCount: typeof data.RestartCount === "number" ? data.RestartCount : 0,
2565
- health,
2566
- errorLine: data.State?.Error?.trim() ? data.State.Error.trim() : null,
2567
- oomKilled: data.State?.OOMKilled === true,
2568
- restartPolicy: data.HostConfig?.RestartPolicy?.Name ?? null
2569
- };
2570
- }
2571
- async getRuntimeLogs(containerId, tail) {
2572
- const container = this.docker.getContainer(containerId);
2573
- const buffer = await container.logs({
2574
- stdout: true,
2575
- stderr: true,
2576
- timestamps: true,
2577
- tail: tail ?? 200
2578
- });
2579
- const raw = stripDockerHeaders(buffer);
2580
- return raw.split("\n").filter(Boolean).map((line) => {
2581
- const { timestamp, message } = parseTimestampedLine(line);
2582
- return { timestamp, message, level: parseLogLevel(message) };
2583
- });
2584
- }
2585
- async streamRuntimeLogs(containerId, onLog, opts) {
2586
- const container = this.docker.getContainer(containerId);
2587
- const stream = await container.logs({
2588
- stdout: true,
2589
- stderr: true,
2590
- timestamps: true,
2591
- follow: true,
2592
- tail: opts?.tail ?? 100
2593
- });
2594
- let destroyed = false;
2595
- let buffer = "";
2596
- stream.on("data", (chunk) => {
2597
- if (destroyed) return;
2598
- buffer += stripDockerChunkHeader(chunk).toString("utf-8");
2599
- const lines = buffer.split("\n");
2600
- buffer = lines.pop() ?? "";
2601
- for (const line of lines) {
2602
- if (!line) continue;
2603
- const { timestamp, message } = parseTimestampedLine(line);
2604
- onLog({ timestamp, message, level: parseLogLevel(message) });
2605
- }
2606
- });
2607
- stream.on("end", () => {
2608
- if (buffer && !destroyed) {
2609
- onLog({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), message: buffer, level: parseLogLevel(buffer) });
2610
- buffer = "";
2611
- }
2612
- });
2613
- return () => {
2614
- if (!destroyed) {
2615
- destroyed = true;
2616
- stream.destroy?.();
2617
- }
2618
- };
2619
- }
2620
- async getUsage(containerId) {
2621
- const container = this.docker.getContainer(containerId);
2622
- const stats = await container.stats({ stream: false });
2623
- const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
2624
- const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
2625
- const numCpus = stats.cpu_stats.online_cpus || 1;
2626
- const cpuPercent = systemDelta > 0 ? cpuDelta / systemDelta * numCpus * 100 : 0;
2627
- const memoryMb = (stats.memory_stats.usage ?? 0) / (1024 * 1024);
2628
- let networkRxBytes = 0;
2629
- let networkTxBytes = 0;
2630
- if (stats.networks) {
2631
- for (const iface of Object.values(stats.networks)) {
2632
- networkRxBytes += iface.rx_bytes ?? 0;
2633
- networkTxBytes += iface.tx_bytes ?? 0;
2634
- }
2635
- }
2636
- let diskBytes = 0;
2637
- if (stats.blkio_stats?.io_service_bytes_recursive) {
2638
- for (const entry of stats.blkio_stats.io_service_bytes_recursive) {
2639
- diskBytes += entry.value ?? 0;
2640
- }
2641
- }
2642
- const diskMb = diskBytes / (1024 * 1024);
2643
- return {
2644
- cpuPercent: Math.round(cpuPercent * 100) / 100,
2645
- memoryMb: Math.round(memoryMb * 100) / 100,
2646
- diskMb: Math.round(diskMb * 100) / 100,
2647
- networkRxBytes,
2648
- networkTxBytes
2649
- };
2650
- }
2651
- // ── Network ────────────────────────────────────────────────────────────
2652
- async getContainerIp(containerId) {
2653
- const container = this.docker.getContainer(containerId);
2654
- const data = await container.inspect();
2655
- for (const net3 of Object.values(data.NetworkSettings.Networks ?? {})) {
2656
- if (net3.IPAddress) return net3.IPAddress;
2657
- }
2658
- return null;
2659
- }
2660
- /**
2661
- * Open an interactive PTY shell inside a deployed container. Powers
2662
- * the in-dashboard service terminal — see apps/api/src/modules/service-terminal/.
2663
- *
2664
- * Wire-up: dockerode's `container.exec({Tty: true, AttachStdin: true,
2665
- * AttachStdout: true, AttachStderr: true})` returns an Exec handle.
2666
- * Starting it with `{hijack: true, stdin: true}` gives a single bi-
2667
- * directional Duplex carrying TTY bytes in both directions (when Tty
2668
- * is true, stderr is merged into stdout — exactly what xterm expects).
2669
- *
2670
- * The returned ShellSession matches SshExecutor.openShell so the
2671
- * websocket bridge in service-terminal.controller.ts is identical
2672
- * across Docker + Cloud + SSH callers.
2673
- */
2674
- async openServiceShell(containerId, opts) {
2675
- const container = this.docker.getContainer(containerId);
2676
- const cols = clampShellWindow(opts?.cols, 80, 1, 1e3);
2677
- const rows = clampShellWindow(opts?.rows, 24, 1, 500);
2678
- const term = opts?.term || "xterm-256color";
2679
- const inspect = await container.inspect().catch(() => null);
2680
- if (!inspect?.State.Running) {
2681
- throw new Error(
2682
- `Container ${containerId} is not running (status: ${inspect?.State.Status ?? "unknown"})`
2683
- );
2684
- }
2685
- const exec = await container.exec({
2686
- AttachStdin: true,
2687
- AttachStdout: true,
2688
- AttachStderr: true,
2689
- Tty: true,
2690
- Cmd: ["/bin/sh", "-lc", "exec $(command -v bash || echo /bin/sh)"],
2691
- Env: [`TERM=${term}`]
2692
- });
2693
- const duplex = await startExecStream(
2694
- daemonConnectionFrom(this.docker),
2695
- exec.id,
2696
- { tty: true, stdin: true }
2697
- );
2698
- try {
2699
- await exec.resize({ h: rows, w: cols });
2700
- } catch {
2701
- }
2702
- const stdout = new PassThrough();
2703
- const stderr = new PassThrough();
2704
- duplex.on("data", (chunk) => stdout.write(chunk));
2705
- duplex.resume();
2706
- duplex.on("end", () => {
2707
- stdout.end();
2708
- stderr.end();
2709
- });
2710
- duplex.on("error", () => {
2711
- stdout.end();
2712
- stderr.end();
2713
- });
2714
- const stdin = new Writable({
2715
- write(chunk, _enc, cb) {
2716
- duplex.write(chunk, (err) => cb(err ?? void 0));
2717
- },
2718
- final(cb) {
2719
- try {
2720
- duplex.end();
2721
- } catch {
2722
- }
2723
- cb();
2724
- }
2725
- });
2726
- const closeListeners = [];
2727
- let closed = false;
2728
- const fireClose = (code, signal) => {
2729
- if (closed) return;
2730
- closed = true;
2731
- for (const cb of closeListeners) {
2732
- try {
2733
- cb(code, signal);
2734
- } catch {
2735
- }
2736
- }
2737
- };
2738
- duplex.on("close", () => {
2739
- exec.inspect().then((info) => fireClose(info.ExitCode ?? null)).catch(() => fireClose(null));
2740
- });
2741
- return {
2742
- stdin,
2743
- stdout,
2744
- stderr,
2745
- setWindow: (c, r) => {
2746
- const sc = clampShellWindow(c, 80, 1, 1e3);
2747
- const sr = clampShellWindow(r, 24, 1, 500);
2748
- void exec.resize({ h: sr, w: sc }).catch(() => void 0);
2749
- },
2750
- close: (_signal) => {
2751
- try {
2752
- duplex.end();
2753
- } catch {
2754
- }
2755
- try {
2756
- duplex.destroy();
2757
- } catch {
2758
- }
2759
- },
2760
- onClose: (cb) => {
2761
- closeListeners.push(cb);
2762
- }
2763
- };
2764
- }
2765
- /**
2766
- * Non-interactive one-shot exec INSIDE a container (Tty:false ⇒ dockerode
2767
- * multiplexes stdout/stderr, so demux the frames). Sibling of
2768
- * `openServiceShell` for the advisory port probe — reads stdout + the exit
2769
- * code. Runs in the CONTAINER, not on the daemon host (never use
2770
- * `this.executor` here).
2771
- */
2772
- async execInContainer(containerId, command) {
2773
- const container = this.docker.getContainer(containerId);
2774
- const inspect = await container.inspect().catch(() => null);
2775
- if (!inspect?.State.Running) {
2776
- throw new Error(
2777
- `Container ${containerId} is not running (status: ${inspect?.State.Status ?? "unknown"})`
2778
- );
2779
- }
2780
- const exec = await container.exec({
2781
- AttachStdout: true,
2782
- AttachStderr: true,
2783
- Tty: false,
2784
- Cmd: ["sh", "-c", command]
2785
- });
2786
- const stream = await exec.start({ hijack: true, stdin: false });
2787
- const stdoutChunks = [];
2788
- const stdoutSink = new Writable({
2789
- write(chunk, _enc, cb) {
2790
- stdoutChunks.push(Buffer.from(chunk));
2791
- cb();
2792
- }
2793
- });
2794
- const stderrSink = new Writable({
2795
- write(_chunk, _enc, cb) {
2796
- cb();
2797
- }
2798
- });
2799
- this.docker.modem.demuxStream(stream, stdoutSink, stderrSink);
2800
- await new Promise((resolve, reject) => {
2801
- stream.on("end", resolve);
2802
- stream.on("close", resolve);
2803
- stream.on("error", reject);
2804
- });
2805
- const info = await exec.inspect().catch(() => null);
2806
- return {
2807
- exitCode: info?.ExitCode ?? null,
2808
- stdout: Buffer.concat(stdoutChunks).toString("utf8")
2809
- };
2810
- }
2811
- /** Command runner scoped to the inside of the container (advisory port probe). */
2812
- async inContainerExecutor(containerId) {
2813
- return {
2814
- exec: async (command) => {
2815
- const { exitCode, stdout } = await this.execInContainer(containerId, command);
2816
- if (exitCode && exitCode !== 0) {
2817
- throw new Error(stdout || `exec exited with code ${exitCode}`);
2818
- }
2819
- return stdout;
2820
- }
2821
- };
2822
- }
2823
- // ── Compose / multi-service ────────────────────────────────────────────
2824
- /**
2825
- * Ensure a project-level Docker network exists.
2826
- * All services in a compose project share this network and can
2827
- * reach each other by service name as hostname.
2828
- */
2829
- async ensureNetwork(slug) {
2830
- const networkName = `openship-${slug}`;
2831
- const critical = async () => {
2832
- const networks = await this.docker.listNetworks({
2833
- filters: { name: [networkName] }
2834
- });
2835
- const existing = networks.find((n) => n.Name === networkName);
2836
- if (existing) return existing.Id;
2837
- const network = await this.docker.createNetwork({
2838
- Name: networkName,
2839
- Driver: "bridge",
2840
- Labels: { "openship.network": slug }
2841
- });
2842
- return network.id;
2843
- };
2844
- return this.provisionLock ? this.provisionLock.run(critical) : critical();
2845
- }
2846
- async ensureServiceGroup(config) {
2847
- void config.deploymentId;
2848
- const networkId = await this.ensureNetwork(config.slug);
2849
- await this.reconcileNetworkMembership(networkId, config.projectId);
2850
- return { id: networkId };
2851
- }
2852
- /**
2853
- * Join already-running containers (migration attach-live reuse) to a project's
2854
- * `openship-<slug>` network with a DNS alias each — so a natively-deployed
2855
- * service in the SAME project resolves them by name (e.g. a freshly-built `web`
2856
- * reaching the reused `postgres:5432`). These containers keep their ORIGINAL
2857
- * openship labels, so the label-scoped reconcileNetworkMembership never joins
2858
- * them; this is the explicit, additive join (a network connect does NOT restart
2859
- * the container or touch its volumes). Idempotent + best-effort per member.
2860
- */
2861
- async joinServiceGroupContainers(slug, members) {
2862
- if (members.length === 0) return;
2863
- const networkId = await this.ensureNetwork(slug);
2864
- const network = this.docker.getNetwork(networkId);
2865
- for (const m of members) {
2866
- if (!m.containerId) continue;
2867
- try {
2868
- await network.connect({
2869
- Container: m.containerId,
2870
- EndpointConfig: m.alias ? { Aliases: [m.alias] } : {}
2871
- });
2872
- } catch (err) {
2873
- const msg = err?.message ?? "";
2874
- if (!/already exists|already connected/i.test(msg)) {
2875
- console.warn(
2876
- `[docker] group join failed for ${m.containerId.slice(0, 12)} (${m.alias}): ${msg}`
2877
- );
2878
- }
2879
- }
2880
- }
2881
- }
2882
- /**
2883
- * Guard for GRANDFATHERED (non-namespaced) services: a bare named volume that
2884
- * another project's container already mounts is a cross-project collision —
2885
- * the exact bug (two projects sharing one postgres volume) this change
2886
- * prevents for new services. New namespaced services can't hit this (their
2887
- * volume name is project-unique by construction).
2888
- *
2889
- * Only a FRESH claim is blocked: if THIS project already mounts the name
2890
- * (it's the incumbent) a redeploy is never blocked — otherwise, during an
2891
- * active collision, whichever owner redeployed first would be locked out of
2892
- * its own release. Best-effort on the list call; throws ONLY on a real
2893
- * newcomer collision so the operator renames it.
2894
- */
2895
- async assertNoForeignNamedVolumeCollision(config) {
2896
- const named = /* @__PURE__ */ new Set();
2897
- for (const spec of config.volumes) {
2898
- const body = spec.replace(/:(ro|rw|z|Z|nocopy)$/, "");
2899
- const parts = body.split(":");
2900
- if (parts.length < 2) continue;
2901
- const source = parts[0];
2902
- if (isHostPathSource(source)) continue;
2903
- named.add(source);
2904
- }
2905
- if (named.size === 0) return;
2906
- let containers;
2907
- try {
2908
- containers = await this.docker.listContainers({ all: true });
2909
- } catch {
2910
- return;
2911
- }
2912
- const ownNames = /* @__PURE__ */ new Set();
2913
- const foreign = /* @__PURE__ */ new Map();
2914
- for (const c of containers) {
2915
- const owner = c.Labels?.["openship.project"];
2916
- if (!owner) continue;
2917
- for (const m of c.Mounts ?? []) {
2918
- if (m.Type !== "volume" || !m.Name || !named.has(m.Name)) continue;
2919
- if (owner === config.projectId) ownNames.add(m.Name);
2920
- else if (!foreign.has(m.Name)) foreign.set(m.Name, c.Names?.[0]?.replace(/^\//, "") ?? owner);
2921
- }
2922
- }
2923
- for (const [name, other] of foreign) {
2924
- if (ownNames.has(name)) continue;
2925
- throw new Error(
2926
- `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.`
2927
- );
2928
- }
2929
- }
2930
- /**
2931
- * Ensure every container belonging to this project is attached to
2932
- * `networkId` with its service-name alias. Idempotent (already-connected is a
2933
- * no-op) and best-effort (never throws). Normal/partial/smart redeploys don't
2934
- * strand containers (the network is reused by name → stable id); this heals
2935
- * the narrow case where the network's identity changed out-of-band (docker
2936
- * network prune/rm, daemon/host rebuild) and surviving containers fell off it.
2937
- */
2938
- async reconcileNetworkMembership(networkId, projectId) {
2939
- let containers;
2940
- try {
2941
- containers = await this.docker.listContainers({
2942
- all: true,
2943
- filters: { label: [`openship.project=${projectId}`] }
2944
- });
2945
- } catch {
2946
- return;
2947
- }
2948
- const network = this.docker.getNetwork(networkId);
2949
- for (const c of containers) {
2950
- const onNetwork = Object.values(c.NetworkSettings?.Networks ?? {}).some(
2951
- (n) => n?.NetworkID === networkId
2952
- );
2953
- if (onNetwork) continue;
2954
- const service = c.Labels?.["openship.service"];
2955
- try {
2956
- await network.connect({
2957
- Container: c.Id,
2958
- EndpointConfig: service ? { Aliases: [service] } : {}
2959
- });
2960
- } catch (err) {
2961
- const msg = err?.message ?? "";
2962
- if (!/already exists|already connected/i.test(msg)) {
2963
- console.warn(
2964
- `[docker] reconcile connect failed for ${c.Id.slice(0, 12)} \u2192 ${networkId.slice(0, 12)}: ${msg}`
2965
- );
2966
- }
2967
- }
2968
- }
2969
- }
2970
- /**
2971
- * Attach every container of `projectId` to the given networks (by name) — for
2972
- * cross-project service links: a consumer joins a linked database app's
2973
- * `openship-<slug>` network so it resolves that app's service alias
2974
- * (`mongo:27017`) with no public port. Best-effort + idempotent; a network that
2975
- * doesn't exist (source not deployed) is skipped and nothing here ever throws —
2976
- * a link networking failure must never fail the consumer's deploy.
2977
- */
2978
- async attachToExternalNetworks(projectId, networkNames) {
2979
- if (networkNames.length === 0) return;
2980
- let containers;
2981
- try {
2982
- containers = await this.docker.listContainers({
2983
- all: true,
2984
- filters: { label: [`openship.project=${projectId}`] }
2985
- });
2986
- } catch {
2987
- return;
2988
- }
2989
- for (const name of networkNames) {
2990
- const network = this.docker.getNetwork(name);
2991
- let netId;
2992
- try {
2993
- const info = await network.inspect();
2994
- netId = info.Id;
2995
- } catch {
2996
- continue;
2997
- }
2998
- for (const c of containers) {
2999
- const onNetwork = Object.values(c.NetworkSettings?.Networks ?? {}).some(
3000
- (n) => n?.NetworkID === netId
3001
- );
3002
- if (onNetwork) continue;
3003
- try {
3004
- await network.connect({ Container: c.Id });
3005
- } catch (err) {
3006
- const msg = err?.message ?? "";
3007
- if (!/already exists|already connected/i.test(msg)) {
3008
- console.warn(
3009
- `[docker] link-connect failed for ${c.Id.slice(0, 12)} \u2192 ${name}: ${msg}`
3010
- );
3011
- }
3012
- }
3013
- }
3014
- }
3015
- }
3016
- /** Remove a project network (best-effort). */
3017
- async removeNetwork(slug) {
3018
- const networkName = `openship-${slug}`;
3019
- try {
3020
- const network = this.docker.getNetwork(networkName);
3021
- await network.remove();
3022
- } catch {
3023
- }
3024
- }
3025
- /**
3026
- * Deploy a single service container on a project network.
3027
- * Unlike `deploy()` which binds to a random host port,
3028
- * service containers join the project network with their service name as hostname.
3029
- * External port bindings are only created for services that explicitly expose ports.
3030
- */
3031
- async deployServiceWorkload(group, config, onLog) {
3032
- const log = onLog ?? (() => {
3033
- });
3034
- const containerName = `openship-${config.slug}-${config.serviceName}`;
3035
- try {
3036
- const existing = this.docker.getContainer(containerName);
3037
- await existing.remove({ force: true });
3038
- } catch {
3039
- }
3040
- const env = [
3041
- ...config.publicPort && config.environment.PORT === void 0 ? [`PORT=${config.publicPort}`] : [],
3042
- ...Object.entries(config.environment).map(([k, v]) => `${k}=${v}`)
3043
- ];
3044
- const cmd = resolveComposeCmd(config);
3045
- const { exposedPorts, portBindings } = parsePortBindings(config.ports);
3046
- if (!config.namespaceVolumes) {
3047
- await this.assertNoForeignNamedVolumeCollision(config);
3048
- }
3049
- const scopedBinds = scopeVolumeBinds(config.slug, config.volumes, config.namespaceVolumes);
3050
- const binds = scopedBinds.length > 0 ? scopedBinds : void 0;
3051
- const restartPolicy = resolveRestartPolicy(config.restart);
3052
- const healthcheck = toDockerHealthcheck(config.advanced?.healthcheck);
3053
- log({
3054
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3055
- message: `Creating service container ${containerName} from ${config.image}...
3056
- `,
3057
- level: "info"
3058
- });
3059
- log({
3060
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3061
- message: `Resource limits: ${describeResourceLimits(config.resources)}
3062
- `,
3063
- level: "info"
3064
- });
3065
- if (!config.image.startsWith("openship/")) {
3066
- try {
3067
- log({
3068
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3069
- message: `Pulling image ${config.image}...
3070
- `,
3071
- level: "info"
3072
- });
3073
- await this.pullImage(config.image, { force: config.forcePull });
3074
- } catch (err) {
3075
- log({
3076
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3077
- message: `Failed to pull ${config.image}: ${err}
3078
- `,
3079
- level: "error"
3080
- });
3081
- throw err;
3082
- }
3083
- }
3084
- const container = await this.docker.createContainer({
3085
- name: containerName,
3086
- Image: config.image,
3087
- Cmd: cmd,
3088
- Env: env,
3089
- Hostname: config.serviceName,
3090
- Labels: {
3091
- ...this.labels({
3092
- deploymentId: config.deploymentId,
3093
- projectId: config.projectId
3094
- }),
3095
- "openship.service": config.serviceName
3096
- },
3097
- ...healthcheck && { Healthcheck: healthcheck },
3098
- ExposedPorts: exposedPorts,
3099
- HostConfig: {
3100
- RestartPolicy: restartPolicy,
3101
- ...dockerResourceLimits(config.resources),
3102
- PortBindings: portBindings,
3103
- Binds: binds,
3104
- NetworkMode: group.id
3105
- },
3106
- NetworkingConfig: {
3107
- EndpointsConfig: {
3108
- [group.id]: {
3109
- Aliases: [config.serviceName]
3110
- }
3111
- }
3112
- }
3113
- });
3114
- try {
3115
- await container.start();
3116
- } catch (startErr) {
3117
- try {
3118
- await container.remove({ force: true });
3119
- } catch {
3120
- }
3121
- throw startErr;
3122
- }
3123
- const data = await container.inspect();
3124
- const { ip, hostPort } = extractNetworkInfo(data);
3125
- const imageDigest = await this.resolveImageDigest(config.image).catch(() => void 0);
3126
- log({
3127
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3128
- message: `Service ${config.serviceName} started (${container.id.slice(0, 12)})${ip ? ` at ${ip}` : ""}.
3129
- `,
3130
- level: "info"
3131
- });
3132
- return {
3133
- containerId: container.id,
3134
- status: "running",
3135
- ip,
3136
- hostPort,
3137
- imageDigest
3138
- };
3139
- }
3140
- /**
3141
- * Resolve the `repo@sha256:…` digest of a pulled image from its RepoDigests,
3142
- * preferring the entry whose repo matches `ref` (an image may carry digests
3143
- * for several repos). Returns undefined for a locally-built image (no
3144
- * RepoDigests) or any inspect error. Works local + SSH (plain inspect, no
3145
- * followProgress). Never throws.
3146
- */
3147
- async resolveImageDigest(ref) {
3148
- const info = await this.docker.getImage(ref).inspect();
3149
- const repoDigests = info?.RepoDigests ?? [];
3150
- if (repoDigests.length === 0) return void 0;
3151
- const noDigest = ref.split("@")[0];
3152
- const lastColon = noDigest.lastIndexOf(":");
3153
- const lastSlash = noDigest.lastIndexOf("/");
3154
- const repo = lastColon > lastSlash ? noDigest.slice(0, lastColon) : noDigest;
3155
- return repoDigests.find((d) => d.startsWith(`${repo}@`)) ?? repoDigests[0];
3156
- }
3157
- async deployService(config, onLog) {
3158
- return this.deployServiceWorkload({ id: config.networkId }, config, onLog);
3159
- }
3160
- };
3161
-
3162
- export {
3163
- scopedVolumeName,
3164
- isHostPathSource,
3165
- ownsBuiltImage,
3166
- DockerRuntime
3167
- };