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