openship 0.6.1 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-IWS2QHJ3.js → bare-OXJVW3AK.js} +11 -9
  3. package/dist/{chunk-VZ2ZHPS2.js → chunk-2YM3NI2E.js} +3 -9
  4. package/dist/chunk-4I2BM2DT.js +68 -0
  5. package/dist/{chunk-2NZSUXOB.js → chunk-5DMWSTOM.js} +310 -1459
  6. package/dist/{chunk-3FYIXVX5.js → chunk-77QDF6UD.js} +581 -122
  7. package/dist/{chunk-IOT5ZX23.js → chunk-A4GXYE4L.js} +449 -383
  8. package/dist/chunk-CBLD2HS5.js +428 -0
  9. package/dist/chunk-ELGRMZOW.js +25 -0
  10. package/dist/{chunk-TWJCLD4W.js → chunk-FE7ZZQSE.js} +5 -5
  11. package/dist/{chunk-3NZICQHF.js → chunk-HKRQXPTI.js} +31 -8
  12. package/dist/{chunk-IXM5245F.js → chunk-LFE5KQ7Y.js} +553 -538
  13. package/dist/{chunk-VDDMADOF.js → chunk-LH2PLRQO.js} +16 -2
  14. package/dist/{chunk-EMXI4UCU.js → chunk-LUKYPVJ4.js} +29 -10
  15. package/dist/{chunk-UC7YIEHT.js → chunk-MLYSEZQB.js} +352 -47
  16. package/dist/chunk-NK5N5LXN.js +1681 -0
  17. package/dist/{chunk-RRJJGVKE.js → chunk-OBPKYXQL.js} +75 -17
  18. package/dist/{chunk-6ODUBKUB.js → chunk-PCPVTH6M.js} +78 -88
  19. package/dist/chunk-Q7WGTQQ5.js +88 -0
  20. package/dist/{chunk-EW6KB2J7.js → chunk-QVW76WDL.js} +440 -74
  21. package/dist/{chunk-YIPMDGG6.js → chunk-RSR7PFIQ.js} +79 -23
  22. package/dist/{chunk-3LDWWY7U.js → chunk-SM26HDS2.js} +41 -7
  23. package/dist/{chunk-OTLSJYAC.js → chunk-UJ6JZM65.js} +5185 -341
  24. package/dist/chunk-UVNMQM7W.js +444 -0
  25. package/dist/chunk-VTGPEC6B.js +308 -0
  26. package/dist/chunk-WVAML4H6.js +2145 -0
  27. package/dist/{chunk-JIMUYEKN.js → chunk-YIMOCZBZ.js} +1 -1
  28. package/dist/chunk-YMMQRE5J.js +292 -0
  29. package/dist/cloud-75VIT4KV.js +37 -0
  30. package/dist/{detect-ALF4GXRX.js → detect-OIE5V3VZ.js} +3 -3
  31. package/dist/{docker-QECQJ5P4.js → docker-VTR2GK5Y.js} +16 -8
  32. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-22IBXNYJ.js} +5 -5
  33. package/dist/{edge-import-SAYTRJLO.js → edge-import-WJUB4M7F.js} +27 -23
  34. package/dist/edge-preflight-GBNCPNVU.js +42 -0
  35. package/dist/{ensure-container-edge-CFSCPGZK.js → ensure-container-edge-KPI2CVXJ.js} +9 -8
  36. package/dist/{executor-PT6UEJ5M.js → executor-7LXXZPBR.js} +15 -5
  37. package/dist/host-channel-preflight-KE6OONYK.js +50 -0
  38. package/dist/index.js +3783 -4221
  39. package/dist/{nginx-YOEEFBTE.js → nginx-KUFMLMAC.js} +13 -5
  40. package/dist/node-bootstrap.js +241 -0
  41. package/dist/node-entry.js +52 -1
  42. package/dist/{openresty-lua-WGBO2M3Z.js → openresty-lua-ATTH35BE.js} +8 -2
  43. package/dist/privilege-I4W3ZNZK.js +19 -0
  44. package/dist/server/engine/samples/amavisd/amavisd.conf +25 -0
  45. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  46. package/dist/server/engine/samples/postfix/master.cf +14 -0
  47. package/dist/server/index.js +77659 -61909
  48. package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
  49. package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
  50. package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
  51. package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
  52. package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
  53. package/dist/server/migrations/0103_dns_credential.sql +41 -0
  54. package/dist/server/migrations/0104_instance_host_control.sql +15 -0
  55. package/dist/server/migrations/0105_mail_inbound_rules.sql +80 -0
  56. package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
  57. package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
  58. package/dist/server/migrations/0108_credential.sql +68 -0
  59. package/dist/server/migrations/meta/_journal.json +77 -0
  60. package/dist/{setup-M2ZDGH3O.js → setup-XGLGUUFW.js} +10 -9
  61. package/dist/ssh-client-JBMA7SN4.js +28 -0
  62. package/dist/up-K4BV7466.js +57 -0
  63. package/package.json +4 -2
  64. package/dist/chunk-IW2UKBVF.js +0 -144
  65. package/dist/chunk-ZCFVHA25.js +0 -89
  66. package/dist/cloud-M2YLWEXO.js +0 -31
  67. /package/dist/{dist-es-JGBPABJI.js → dist-es-AXFRS7PS.js} +0 -0
@@ -9,33 +9,43 @@ import {
9
9
  resolveDockerfileCandidates,
10
10
  resolveServiceDockerfile,
11
11
  staticBuilderOutputPath
12
- } from "./chunk-EMXI4UCU.js";
12
+ } from "./chunk-LUKYPVJ4.js";
13
13
  import {
14
+ BuildCancelledError,
14
15
  BuildLogger,
15
16
  assembleGitClone,
17
+ killProcessesUnderDir,
16
18
  materializeGitSsh,
17
19
  parseLogLevel,
18
20
  shellGitSshWriter,
19
21
  sq,
20
22
  transferLocalDirectory
21
- } from "./chunk-VDDMADOF.js";
23
+ } from "./chunk-LH2PLRQO.js";
22
24
  import {
25
+ DOCKER_DIAL_STDIO_COMMAND,
26
+ attachDialStdioDiagnostics,
23
27
  connectSshClient,
28
+ dialStdioDiagnostics,
24
29
  execSshCommand,
25
- openSshExecChannel,
30
+ openDockerDialStdioChannel,
26
31
  openSshUnixSocket
27
- } from "./chunk-6ODUBKUB.js";
32
+ } from "./chunk-PCPVTH6M.js";
28
33
  import {
29
- isRuntimeNotFoundError
30
- } from "./chunk-ZCFVHA25.js";
34
+ dirOf,
35
+ ensureOwnedDir
36
+ } from "./chunk-UVNMQM7W.js";
31
37
  import {
32
38
  formatCpuCores,
33
39
  formatMemoryMb,
34
40
  hasCpuLimit,
35
41
  hasMemoryLimit,
42
+ isRuntimeNotFoundError,
43
+ ownsNetworkEndpoint,
44
+ registryConfigKeys,
45
+ registryForImage,
36
46
  safeErrorMessage,
37
47
  withTimeout
38
- } from "./chunk-OTLSJYAC.js";
48
+ } from "./chunk-UJ6JZM65.js";
39
49
  import {
40
50
  __commonJS,
41
51
  __require,
@@ -305,7 +315,7 @@ var require_buffer_list = __commonJS({
305
315
  }
306
316
  }, {
307
317
  key: "join",
308
- value: function join2(s) {
318
+ value: function join3(s) {
309
319
  if (this.length === 0) return "";
310
320
  var p = this.head;
311
321
  var ret = "" + p.data;
@@ -4600,6 +4610,82 @@ import Dockerode from "dockerode";
4600
4610
  import { createGzip } from "zlib";
4601
4611
  import { PassThrough, Writable } from "stream";
4602
4612
 
4613
+ // ../../packages/adapters/src/runtime/docker-auth.ts
4614
+ import { readFile } from "fs/promises";
4615
+ import { homedir } from "os";
4616
+ import { join } from "path";
4617
+ function firstPresent(values, keys) {
4618
+ if (!values) return void 0;
4619
+ for (const key of keys) {
4620
+ if (Object.hasOwn(values, key)) return { key, value: values[key] };
4621
+ }
4622
+ return void 0;
4623
+ }
4624
+ function dockerConfigJsonFor(auth) {
4625
+ const a = auth;
4626
+ const registry = a.serveraddress?.trim();
4627
+ if (!registry) return null;
4628
+ if (a.identitytoken) {
4629
+ return JSON.stringify({ auths: { [registry]: { identitytoken: a.identitytoken } } });
4630
+ }
4631
+ if (!a.username || !a.password) return null;
4632
+ const encoded = Buffer.from(`${a.username}:${a.password}`).toString("base64");
4633
+ return JSON.stringify({ auths: { [registry]: { auth: encoded } } });
4634
+ }
4635
+ function toAuthConfig(entry, serveraddress) {
4636
+ if (entry.identitytoken) {
4637
+ return { identitytoken: entry.identitytoken, serveraddress };
4638
+ }
4639
+ if (entry.username && entry.password) {
4640
+ return { username: entry.username, password: entry.password, serveraddress };
4641
+ }
4642
+ if (!entry.auth) return void 0;
4643
+ let decoded;
4644
+ try {
4645
+ decoded = Buffer.from(entry.auth, "base64").toString("utf8");
4646
+ } catch {
4647
+ return void 0;
4648
+ }
4649
+ const separator = decoded.indexOf(":");
4650
+ if (separator < 1) return void 0;
4651
+ return {
4652
+ username: decoded.slice(0, separator),
4653
+ password: decoded.slice(separator + 1),
4654
+ serveraddress
4655
+ };
4656
+ }
4657
+ var DockerCredentialHelperError = class extends Error {
4658
+ constructor(registry) {
4659
+ super(
4660
+ `Openship can't read the credentials for ${registry}: they are stored in a Docker credential helper, which is not available inside the Openship container. Add the credentials inline to the Docker config (docker logout ${registry}, remove the credential helper for it, then docker login ${registry}), or use a public image.`
4661
+ );
4662
+ this.name = "DockerCredentialHelperError";
4663
+ }
4664
+ };
4665
+ async function resolveDockerAuth(ref) {
4666
+ const registry = registryForImage(ref);
4667
+ const configPath = join(
4668
+ process.env.DOCKER_CONFIG || join(homedir(), ".docker"),
4669
+ "config.json"
4670
+ );
4671
+ let config;
4672
+ try {
4673
+ config = JSON.parse(await readFile(configPath, "utf8"));
4674
+ } catch {
4675
+ return void 0;
4676
+ }
4677
+ if (!config || typeof config !== "object") return void 0;
4678
+ const keys = registryConfigKeys(registry);
4679
+ const inline = firstPresent(config.auths, keys);
4680
+ const usable = inline ? toAuthConfig(inline.value, inline.key) : void 0;
4681
+ if (usable) return usable;
4682
+ const helperForRegistry = firstPresent(config.credHelpers, keys);
4683
+ if (helperForRegistry || config.credsStore && inline) {
4684
+ throw new DockerCredentialHelperError(registry);
4685
+ }
4686
+ return void 0;
4687
+ }
4688
+
4603
4689
  // ../../packages/adapters/src/runtime/source-tarball.ts
4604
4690
  function githubTarballUrl(repoUrl, ref) {
4605
4691
  if (!repoUrl || !ref) return null;
@@ -4784,6 +4870,9 @@ function resolveComposeCmd(config) {
4784
4870
  if (config.commandArgv != null) return config.commandArgv;
4785
4871
  return config.command ? ["sh", "-c", config.command] : void 0;
4786
4872
  }
4873
+ function resolveComposeEntrypoint(advanced) {
4874
+ return advanced?.entrypoint ?? void 0;
4875
+ }
4787
4876
 
4788
4877
  // ../../packages/adapters/src/runtime/resource-limits.ts
4789
4878
  function dockerResourceLimits(resources) {
@@ -4835,16 +4924,16 @@ function describeResourceLimits(resources) {
4835
4924
  // ../../packages/adapters/src/runtime/docker-transport.ts
4836
4925
  import { createHash } from "crypto";
4837
4926
  import { readFileSync } from "fs";
4838
- import { homedir } from "os";
4839
- import { join } from "path";
4927
+ import { homedir as homedir2 } from "os";
4928
+ import { join as join2 } from "path";
4840
4929
 
4841
4930
  // ../../packages/adapters/src/runtime/docker-ssh-agent.ts
4842
4931
  import net2 from "net";
4843
4932
  var DEFAULT_REMOTE_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
4844
4933
  var resolvedDockerSocketPathCache = /* @__PURE__ */ new WeakMap();
4845
- var DOCKER_DIAL_STDIO_COMMAND = "docker system dial-stdio";
4846
4934
  var STREAMLOCAL_PROBE_TIMEOUT_MS = 8e3;
4847
4935
  var STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS = 3e3;
4936
+ var DIAL_STDIO_ANSWER_TIMEOUT_MS = 3e3;
4848
4937
  var VERIFY_BUFFER_CAP_BYTES = 8 * 1024 * 1024;
4849
4938
  function toSshConfig(opts) {
4850
4939
  return {
@@ -4895,12 +4984,8 @@ async function discoverRemoteDockerSocketPathsWithClient(client) {
4895
4984
  return normalizeSocketPathLines(lines);
4896
4985
  }
4897
4986
  async function discoverRemoteDockerSocketPathsWithExecutor(executor) {
4898
- try {
4899
- const output = await executor.exec(DOCKER_SOCKET_DISCOVERY_SCRIPT, { timeout: 1e4 });
4900
- return normalizeSocketPathLines(output.split(/\r?\n/));
4901
- } catch {
4902
- return [];
4903
- }
4987
+ const output = await executor.exec(DOCKER_SOCKET_DISCOVERY_SCRIPT, { timeout: 1e4 });
4988
+ return normalizeSocketPathLines(output.split(/\r?\n/));
4904
4989
  }
4905
4990
  async function discoverRemoteDockerSocketPaths(opts) {
4906
4991
  if (opts.executor) {
@@ -4923,7 +5008,13 @@ async function resolveRemoteDockerSocketPath(opts) {
4923
5008
  if (cachedPath) {
4924
5009
  return cachedPath;
4925
5010
  }
4926
- const pendingPath = discoverRemoteDockerSocketPaths(opts).then((paths) => paths[0] ?? DEFAULT_REMOTE_DOCKER_SOCKET_PATH).catch(() => DEFAULT_REMOTE_DOCKER_SOCKET_PATH);
5011
+ const pendingPath = discoverRemoteDockerSocketPaths(opts).then(
5012
+ (paths) => paths[0] ?? DEFAULT_REMOTE_DOCKER_SOCKET_PATH,
5013
+ () => {
5014
+ resolvedDockerSocketPathCache.delete(opts);
5015
+ return DEFAULT_REMOTE_DOCKER_SOCKET_PATH;
5016
+ }
5017
+ );
4927
5018
  resolvedDockerSocketPathCache.set(opts, pendingPath);
4928
5019
  return pendingPath;
4929
5020
  }
@@ -4943,35 +5034,98 @@ async function openStreamlocalUpstream(opts) {
4943
5034
  throw error;
4944
5035
  }
4945
5036
  }
4946
- function pipeThrough(client, upstream) {
5037
+ function respondBadGateway(client, reason) {
5038
+ if (client.destroyed || client.writableEnded) {
5039
+ client.destroy();
5040
+ return;
5041
+ }
5042
+ const body = JSON.stringify({ message: reason });
5043
+ try {
5044
+ client.end(
5045
+ `HTTP/1.1 502 Bad Gateway\r
5046
+ Content-Type: application/json\r
5047
+ Content-Length: ${Buffer.byteLength(body)}\r
5048
+ Connection: close\r
5049
+ \r
5050
+ ` + body
5051
+ );
5052
+ } catch {
5053
+ client.destroy();
5054
+ }
5055
+ }
5056
+ function pipeThrough(client, upstream, handoff) {
5057
+ let answered = handoff.answered;
4947
5058
  const teardown = () => {
4948
5059
  client.destroy();
4949
5060
  upstream.destroy();
4950
5061
  };
4951
5062
  client.on("error", teardown);
4952
- upstream.on("error", teardown);
5063
+ upstream.on("error", () => {
5064
+ if (!answered) respondBadGateway(client, handoff.unansweredReason());
5065
+ upstream.destroy();
5066
+ });
4953
5067
  client.once("close", () => upstream.destroy());
4954
- upstream.once("close", () => client.destroy());
5068
+ upstream.once("close", () => {
5069
+ if (!answered) {
5070
+ respondBadGateway(client, handoff.unansweredReason());
5071
+ return;
5072
+ }
5073
+ if (!client.destroyed) client.end();
5074
+ });
5075
+ upstream.on("data", () => {
5076
+ answered = true;
5077
+ });
4955
5078
  client.pipe(upstream);
4956
- upstream.pipe(client);
5079
+ upstream.pipe(client, { end: false });
5080
+ upstream.once("end", () => {
5081
+ if (answered && !client.writableEnded) client.end();
5082
+ });
4957
5083
  }
4958
5084
  function awaitFirstByte(upstream, ms) {
4959
5085
  return new Promise((resolve) => {
4960
- const finish = (chunk) => {
5086
+ const finish = (outcome) => {
4961
5087
  clearTimeout(timer);
4962
5088
  upstream.removeListener("data", onData);
4963
5089
  upstream.removeListener("error", onFail);
4964
5090
  upstream.removeListener("close", onFail);
4965
- resolve(chunk);
5091
+ resolve(outcome);
4966
5092
  };
4967
- const onData = (chunk) => finish(chunk);
4968
- const onFail = () => finish(null);
4969
- const timer = setTimeout(() => finish(null), ms);
5093
+ const onData = (chunk) => finish({ kind: "data", chunk });
5094
+ const onFail = () => finish({ kind: "closed" });
5095
+ const timer = setTimeout(() => finish({ kind: "silent" }), ms);
4970
5096
  upstream.on("data", onData);
4971
5097
  upstream.once("error", onFail);
4972
5098
  upstream.once("close", onFail);
4973
5099
  });
4974
5100
  }
5101
+ function looksLikeHttpResponse(chunk) {
5102
+ const marker = "HTTP/";
5103
+ const head = chunk.subarray(0, marker.length).toString("latin1");
5104
+ return marker.startsWith(head) || head.startsWith(marker);
5105
+ }
5106
+ var BRIDGE_FAILURE_HINT = "Check that Docker is installed and running on the server (`docker info`) and that the SSH user can reach its socket.";
5107
+ function sentence(text) {
5108
+ const trimmed = text.trim();
5109
+ return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
5110
+ }
5111
+ function bridgeFailureReason(opts, cause) {
5112
+ const host = opts.host?.trim() || "the remote host";
5113
+ return `Could not reach the Docker daemon on ${host} over SSH: ${sentence(cause)} ${BRIDGE_FAILURE_HINT}`;
5114
+ }
5115
+ function upstreamClosedReason(opts, upstream, transport) {
5116
+ return bridgeFailureReason(
5117
+ opts,
5118
+ dialStdioDiagnostics(upstream) || `the ${transport} channel closed before answering the request`
5119
+ );
5120
+ }
5121
+ var REPLAY_SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
5122
+ function requestIsReplaySafe(chunks) {
5123
+ const head = chunks.find((c) => c.length > 0);
5124
+ if (!head) return true;
5125
+ const space = head.indexOf(32);
5126
+ if (space <= 0) return false;
5127
+ return REPLAY_SAFE_METHODS.has(head.subarray(0, space).toString("latin1"));
5128
+ }
4975
5129
  function captureClient(client) {
4976
5130
  const chunks = [];
4977
5131
  let bytes = 0;
@@ -5006,9 +5160,15 @@ function captureClient(client) {
5006
5160
  get overflowed() {
5007
5161
  return overflowed;
5008
5162
  },
5163
+ /** Whether this request may be handed to a second upstream — see
5164
+ * `requestIsReplaySafe`. Read at the moment of the decision, not at capture time. */
5165
+ get replaySafe() {
5166
+ return requestIsReplaySafe(chunks);
5167
+ },
5009
5168
  /** Replay everything buffered so far onto `upstream` and forward the rest
5010
- * live. Safe to call again on a different upstream (the fallback): the full
5011
- * buffer is retained until `commit`, so the replay is complete each time. */
5169
+ * live. Calling it again on a different upstream (the fallback) replays the
5170
+ * whole buffer — complete, because nothing is dropped until `commit`, but a
5171
+ * second dispatch to the daemon: gate that on `replaySafe`. */
5012
5172
  feed(upstream) {
5013
5173
  for (const chunk of chunks) upstream.write(chunk);
5014
5174
  live = upstream;
@@ -5019,25 +5179,30 @@ function captureClient(client) {
5019
5179
  live = null;
5020
5180
  },
5021
5181
  /** Terminal: detach and hand the socket to the (already-fed) `upstream` via a
5022
- * bidi pipe, or destroy it if the client already went away. */
5023
- commit(upstream) {
5182
+ * bidi pipe, or destroy it if the client already went away. `handoff` tells the
5183
+ * pipe whether a response byte has already been forwarded, and what to say if
5184
+ * the upstream closes without ever producing one. */
5185
+ commit(upstream, handoff) {
5024
5186
  detach();
5025
5187
  chunks.length = 0;
5026
5188
  if (closed) {
5027
5189
  upstream.destroy();
5028
5190
  return;
5029
5191
  }
5030
- pipeThrough(client, upstream);
5192
+ pipeThrough(client, upstream, handoff);
5031
5193
  },
5032
- /** Terminal: no usable upstream. Destroy WITHOUT an error argument a
5033
- * server-side accepted socket can't transmit a JS Error to the peer anyway
5034
- * (dockerode only ever sees the reset), and destroy(err) emits a local
5035
- * 'error' event that, now that detach() removed this socket's error
5036
- * listener, would be unhandled and crash the process. The cause is already
5037
- * logged by bridgeClient's catch before this runs. */
5038
- fail() {
5194
+ /** Terminal: no usable upstream. Answer the request with `reason` (a real HTTP
5195
+ * 502 see `respondBadGateway`) instead of destroying the socket, which is
5196
+ * what turned every one of these into `socket hang up`.
5197
+ *
5198
+ * Written BEFORE detach so the socket still has an error listener during the
5199
+ * write, and never with `destroy(err)`: a server-side socket cannot transmit a
5200
+ * JS Error to its peer, and destroy(err) emits a local 'error' event that —
5201
+ * once detach() has removed this socket's listener — would be unhandled and
5202
+ * take down the process over one dead Docker request. */
5203
+ fail(reason) {
5204
+ respondBadGateway(client, reason);
5039
5205
  detach();
5040
- client.destroy();
5041
5206
  }
5042
5207
  };
5043
5208
  }
@@ -5049,12 +5214,7 @@ function createDockerSshBridge(opts) {
5049
5214
  let pooledUnreliable = false;
5050
5215
  const openDialStdioUpstream = async (forceEphemeral = false) => {
5051
5216
  if (!forceEphemeral && opts.executor?.openDockerDialStdio) {
5052
- const stream = await opts.executor.openDockerDialStdio();
5053
- stream.once(
5054
- "error",
5055
- (e) => console.warn(`[docker-ssh] dial-stdio channel error: ${safeErrorMessage(e)}`)
5056
- );
5057
- return stream;
5217
+ return attachDialStdioDiagnostics(await opts.executor.openDockerDialStdio());
5058
5218
  }
5059
5219
  if (!dialClient) {
5060
5220
  const client = await connectSshClient(toSshConfig(opts));
@@ -5063,7 +5223,34 @@ function createDockerSshBridge(opts) {
5063
5223
  });
5064
5224
  dialClient = client;
5065
5225
  }
5066
- return openSshExecChannel(dialClient, DOCKER_DIAL_STDIO_COMMAND);
5226
+ return openDockerDialStdioChannel(dialClient);
5227
+ };
5228
+ const serveDialStdio = async (capture, client, upstream) => {
5229
+ capture.feed(upstream);
5230
+ const first = capture.overflowed ? { kind: "silent" } : await awaitFirstByte(upstream, DIAL_STDIO_ANSWER_TIMEOUT_MS);
5231
+ if (first.kind === "closed") {
5232
+ capture.stop();
5233
+ upstream.destroy();
5234
+ capture.fail(upstreamClosedReason(opts, upstream, DOCKER_DIAL_STDIO_COMMAND));
5235
+ return;
5236
+ }
5237
+ if (first.kind === "data" && !looksLikeHttpResponse(first.chunk)) {
5238
+ capture.stop();
5239
+ upstream.destroy();
5240
+ const banner = first.chunk.toString("utf8").trim().replace(/\s+/g, " ").slice(0, 300);
5241
+ capture.fail(
5242
+ bridgeFailureReason(
5243
+ opts,
5244
+ `the SSH session answered ${JSON.stringify(banner)} instead of a Docker API response \u2014 something on the remote account (an sshd forced command, or a login banner) is intercepting \`${DOCKER_DIAL_STDIO_COMMAND}\``
5245
+ )
5246
+ );
5247
+ return;
5248
+ }
5249
+ if (first.kind === "data") client.write(first.chunk);
5250
+ capture.commit(upstream, {
5251
+ answered: first.kind === "data",
5252
+ unansweredReason: () => upstreamClosedReason(opts, upstream, DOCKER_DIAL_STDIO_COMMAND)
5253
+ });
5067
5254
  };
5068
5255
  const decideMode = async () => {
5069
5256
  let probe = null;
@@ -5111,24 +5298,20 @@ function createDockerSshBridge(opts) {
5111
5298
  modeDecision ??= decideMode().then((mode) => upstreamMode = mode);
5112
5299
  return modeDecision;
5113
5300
  };
5114
- const downgradeToDialStdio = async (capture, reason) => {
5301
+ const downgradeToDialStdio = async (capture, client, reason) => {
5115
5302
  console.warn(
5116
5303
  `[docker-ssh] streamlocal unusable (${opts.host ?? "?"}): ${reason} \u2014 downgrading this bridge to dial-stdio (fresh connection) and replaying the buffered request.`
5117
5304
  );
5118
5305
  upstreamMode = "dialstdio";
5119
5306
  pooledUnreliable = true;
5120
- const upstream = await openDialStdioUpstream(true);
5121
- capture.feed(upstream);
5122
- capture.commit(upstream);
5307
+ await serveDialStdio(capture, client, await openDialStdioUpstream(true));
5123
5308
  };
5124
5309
  const bridgeClient = async (client) => {
5125
5310
  const capture = captureClient(client);
5126
5311
  try {
5127
5312
  const mode = await ensureMode();
5128
5313
  if (mode === "dialstdio") {
5129
- const upstream = await openDialStdioUpstream(pooledUnreliable);
5130
- capture.feed(upstream);
5131
- capture.commit(upstream);
5314
+ await serveDialStdio(capture, client, await openDialStdioUpstream(pooledUnreliable));
5132
5315
  return;
5133
5316
  }
5134
5317
  let channel;
@@ -5139,22 +5322,36 @@ function createDockerSshBridge(opts) {
5139
5322
  `channel open timed out after ${STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS / 1e3}s`
5140
5323
  );
5141
5324
  } catch (err) {
5142
- await downgradeToDialStdio(capture, safeErrorMessage(err));
5325
+ await downgradeToDialStdio(capture, client, safeErrorMessage(err));
5143
5326
  return;
5144
5327
  }
5145
5328
  capture.feed(channel);
5146
- const firstByte = capture.overflowed ? null : await awaitFirstByte(channel, STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS);
5147
- if (capture.overflowed || firstByte) {
5148
- if (firstByte) client.write(firstByte);
5149
- capture.commit(channel);
5329
+ const firstByte = capture.overflowed ? { kind: "silent" } : await awaitFirstByte(channel, STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS);
5330
+ if (capture.overflowed || firstByte.kind === "data") {
5331
+ if (firstByte.kind === "data") client.write(firstByte.chunk);
5332
+ capture.commit(channel, {
5333
+ answered: firstByte.kind === "data",
5334
+ unansweredReason: () => upstreamClosedReason(opts, channel, "streamlocal")
5335
+ });
5336
+ return;
5337
+ }
5338
+ if (firstByte.kind === "silent" && !capture.replaySafe) {
5339
+ capture.commit(channel, {
5340
+ answered: false,
5341
+ unansweredReason: () => upstreamClosedReason(opts, channel, "streamlocal")
5342
+ });
5150
5343
  return;
5151
5344
  }
5152
5345
  capture.stop();
5153
5346
  channel.destroy();
5154
- await downgradeToDialStdio(capture, "channel opened but no data flowed");
5347
+ await downgradeToDialStdio(
5348
+ capture,
5349
+ client,
5350
+ firstByte.kind === "closed" ? "channel closed before answering" : "channel opened but no data flowed"
5351
+ );
5155
5352
  } catch (err) {
5156
5353
  console.warn(`[docker-ssh] bridge client failed (${opts.host ?? "?"}): ${safeErrorMessage(err)}`);
5157
- capture.fail();
5354
+ capture.fail(bridgeFailureReason(opts, safeErrorMessage(err)));
5158
5355
  }
5159
5356
  };
5160
5357
  const server = net2.createServer((client) => {
@@ -5201,7 +5398,7 @@ function createDockerSshBridge(opts) {
5201
5398
  var DEFAULT_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
5202
5399
  var contextSocketCache = /* @__PURE__ */ new Map();
5203
5400
  function resolveDockerContextSocket(env) {
5204
- const configDir = env.DOCKER_CONFIG?.trim() || join(homedir(), ".docker");
5401
+ const configDir = env.DOCKER_CONFIG?.trim() || join2(homedir2(), ".docker");
5205
5402
  let name = env.DOCKER_CONTEXT?.trim();
5206
5403
  const cacheKey = `${configDir}\0${name ?? ""}`;
5207
5404
  const cached = contextSocketCache.get(cacheKey);
@@ -5209,13 +5406,13 @@ function resolveDockerContextSocket(env) {
5209
5406
  let socket = null;
5210
5407
  try {
5211
5408
  if (!name) {
5212
- const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"));
5409
+ const config = JSON.parse(readFileSync(join2(configDir, "config.json"), "utf8"));
5213
5410
  name = typeof config.currentContext === "string" ? config.currentContext.trim() : "";
5214
5411
  }
5215
5412
  if (name && name !== "default") {
5216
5413
  const digest = createHash("sha256").update(name).digest("hex");
5217
5414
  const meta = JSON.parse(
5218
- readFileSync(join(configDir, "contexts", "meta", digest, "meta.json"), "utf8")
5415
+ readFileSync(join2(configDir, "contexts", "meta", digest, "meta.json"), "utf8")
5219
5416
  );
5220
5417
  const host = meta.Endpoints?.docker?.Host;
5221
5418
  if (typeof host === "string" && host.startsWith("unix://")) {
@@ -5330,6 +5527,35 @@ var RESTART_POLICIES = {
5330
5527
  no: { Name: "no", MaximumRetryCount: 0 }
5331
5528
  };
5332
5529
  var DOCKER_BUILD_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
5530
+ var activeDockerBuilds = /* @__PURE__ */ new Map();
5531
+ var pendingDockerBuildCancellations = /* @__PURE__ */ new Map();
5532
+ var PENDING_CANCEL_TTL_MS = 10 * 60 * 1e3;
5533
+ function cancelCovers(cancelledId, sessionId) {
5534
+ return sessionId === cancelledId || sessionId.startsWith(`${cancelledId}-`);
5535
+ }
5536
+ function recordPendingCancellation(sessionId) {
5537
+ clearTimeout(pendingDockerBuildCancellations.get(sessionId));
5538
+ const timer = setTimeout(
5539
+ () => pendingDockerBuildCancellations.delete(sessionId),
5540
+ PENDING_CANCEL_TTL_MS
5541
+ );
5542
+ timer.unref?.();
5543
+ pendingDockerBuildCancellations.set(sessionId, timer);
5544
+ }
5545
+ function registerDockerBuild(sessionId) {
5546
+ const abort = new AbortController();
5547
+ for (const cancelledId of pendingDockerBuildCancellations.keys()) {
5548
+ if (cancelCovers(cancelledId, sessionId)) {
5549
+ abort.abort();
5550
+ break;
5551
+ }
5552
+ }
5553
+ activeDockerBuilds.set(sessionId, abort);
5554
+ return abort;
5555
+ }
5556
+ function releaseDockerBuild(sessionId, abort) {
5557
+ if (activeDockerBuilds.get(sessionId) === abort) activeDockerBuilds.delete(sessionId);
5558
+ }
5333
5559
  var DEFAULT_IN_CONTAINER_EXEC_TIMEOUT_MS = 12e4;
5334
5560
  var SIGTERM_EXIT_CODE = 143;
5335
5561
  var IN_CONTAINER_EXEC_WATCHDOG = [
@@ -5475,7 +5701,7 @@ function parsePortBindings(portSpecs) {
5475
5701
  }
5476
5702
  return { exposedPorts, portBindings };
5477
5703
  }
5478
- function packBuildContext(contextDir, entries) {
5704
+ function packBuildContext(contextDir, entries, cancelSignal) {
5479
5705
  const controller = new AbortController();
5480
5706
  const pack2 = tarFs.pack(contextDir, { entries });
5481
5707
  const body = pack2.pipe(createGzip());
@@ -5484,6 +5710,10 @@ function packBuildContext(contextDir, entries) {
5484
5710
  contextError ??= err instanceof Error ? err : new Error(String(err));
5485
5711
  if (!controller.signal.aborted) controller.abort();
5486
5712
  };
5713
+ if (cancelSignal) {
5714
+ if (cancelSignal.aborted) controller.abort();
5715
+ else cancelSignal.addEventListener("abort", () => controller.abort(), { once: true });
5716
+ }
5487
5717
  pack2.once("error", capture);
5488
5718
  body.once("error", capture);
5489
5719
  return { body, abortSignal: controller.signal, takeError: () => contextError };
@@ -5519,7 +5749,9 @@ function toDockerHealthcheck(hc) {
5519
5749
  if (typeof hc.test === "string" && hc.test.trim()) {
5520
5750
  Test = ["CMD-SHELL", hc.test];
5521
5751
  } else if (Array.isArray(hc.test) && hc.test.length > 0) {
5522
- Test = ["CMD", ...hc.test];
5752
+ const head = hc.test[0];
5753
+ if (head === "NONE") return { Test: ["NONE"] };
5754
+ Test = head === "CMD" || head === "CMD-SHELL" ? [...hc.test] : ["CMD", ...hc.test];
5523
5755
  }
5524
5756
  if (!Test) return void 0;
5525
5757
  const Interval = parseDurationNs(hc.interval);
@@ -5533,6 +5765,28 @@ function toDockerHealthcheck(hc) {
5533
5765
  ...StartPeriod !== void 0 && { StartPeriod }
5534
5766
  };
5535
5767
  }
5768
+ function toStopConfig(advanced) {
5769
+ const out = {};
5770
+ const signal = advanced?.stopSignal?.trim();
5771
+ if (signal) out.StopSignal = signal;
5772
+ const graceNs = parseDurationNs(advanced?.stopGracePeriod);
5773
+ if (graceNs !== void 0) {
5774
+ out.StopTimeout = graceNs <= 0 ? 0 : Math.max(1, Math.round(graceNs / 1e9));
5775
+ }
5776
+ return out;
5777
+ }
5778
+ async function gracefulStopForGrace(container) {
5779
+ let stopTimeout;
5780
+ try {
5781
+ stopTimeout = (await container.inspect()).Config.StopTimeout;
5782
+ } catch {
5783
+ return;
5784
+ }
5785
+ if (typeof stopTimeout === "number" && stopTimeout > 0) {
5786
+ await container.stop().catch(() => {
5787
+ });
5788
+ }
5789
+ }
5536
5790
  function hasDockerFrameHeader(buf, offset = 0) {
5537
5791
  return buf.length >= offset + 8 && (buf[offset] === 1 || buf[offset] === 2) && buf[offset + 1] === 0 && buf[offset + 2] === 0 && buf[offset + 3] === 0;
5538
5792
  }
@@ -5654,7 +5908,15 @@ var DockerRuntime = class _DockerRuntime {
5654
5908
  "deploymentContainerQuery",
5655
5909
  "hostContainerQuery",
5656
5910
  "stabilityProbe",
5657
- "containerEvents"
5911
+ "containerEvents",
5912
+ // Docker implements `inContainerExecutor` but had never DECLARED the capability.
5913
+ // Nothing gated on it (every caller tested for the method), so the omission was
5914
+ // invisible — but it meant the honest answer to supports("inContainerExec") was
5915
+ // wrong for the one runtime that best supports it.
5916
+ "inContainerExec",
5917
+ // A docker exec lands in the container's own namespaces, so a command run
5918
+ // through it cannot reach the host. Bare deliberately does NOT declare this.
5919
+ "isolatedExec"
5658
5920
  ]);
5659
5921
  /** Docker honors every extended compose key we currently support. */
5660
5922
  unsupportedComposeKeys = /* @__PURE__ */ new Set();
@@ -5752,7 +6014,7 @@ var DockerRuntime = class _DockerRuntime {
5752
6014
  */
5753
6015
  async estimateContextSize(dir) {
5754
6016
  const { stat, readdir } = await import("fs/promises");
5755
- const { join: join2 } = await import("path");
6017
+ const { join: join3 } = await import("path");
5756
6018
  let total = 0;
5757
6019
  const stack = [dir];
5758
6020
  while (stack.length) {
@@ -5764,7 +6026,7 @@ var DockerRuntime = class _DockerRuntime {
5764
6026
  continue;
5765
6027
  }
5766
6028
  for (const entry of entries) {
5767
- const full = join2(current, entry.name);
6029
+ const full = join3(current, entry.name);
5768
6030
  if (entry.isDirectory()) {
5769
6031
  stack.push(full);
5770
6032
  } else if (entry.isFile()) {
@@ -5901,7 +6163,7 @@ var DockerRuntime = class _DockerRuntime {
5901
6163
  * context dir. One image; `dockerfileName` selects which Dockerfile in the
5902
6164
  * shared tree to use (so N services can build from one transferred context).
5903
6165
  */
5904
- async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log) {
6166
+ async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log, signal) {
5905
6167
  const executor = this.connectionOptions?.executor;
5906
6168
  if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
5907
6169
  const buildArgs = Object.entries({
@@ -5916,10 +6178,15 @@ var DockerRuntime = class _DockerRuntime {
5916
6178
  log.log(`Running on remote: ${buildCmd}`);
5917
6179
  log.log("\u2500\u2500\u2500 docker build output \u2500\u2500\u2500");
5918
6180
  this.emitDockerStep(log, "install", "running", "Running install inside container (docker build)");
5919
- const { code } = await executor.streamExec(buildCmd, (entry) => {
5920
- log.log(entry.message, parseLogLevel(entry.message));
5921
- });
6181
+ const { code } = await executor.streamExec(
6182
+ buildCmd,
6183
+ (entry) => {
6184
+ log.log(entry.message, parseLogLevel(entry.message));
6185
+ },
6186
+ { signal }
6187
+ );
5922
6188
  log.log("\u2500\u2500\u2500 end docker build output \u2500\u2500\u2500");
6189
+ if (signal?.aborted) throw new BuildCancelledError();
5923
6190
  if (code !== 0) throw new Error(`docker build exited with code ${code}`);
5924
6191
  this.emitDockerStep(log, "install", "completed", "Image build finished");
5925
6192
  }
@@ -6038,11 +6305,18 @@ var DockerRuntime = class _DockerRuntime {
6038
6305
  await executor.writeFile(`${remoteContextDir}/${generatedName}`, generateDockerfile(config));
6039
6306
  return generatedName;
6040
6307
  }
6041
- async buildViaSshTarPipe(config, buildContext, tag, log) {
6308
+ async buildViaSshTarPipe(config, buildContext, tag, log, signal) {
6042
6309
  const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
6043
6310
  try {
6044
6311
  await this.transferBuildContext(buildContext.contextDir, remoteContextDir, log);
6045
- await this.buildImageOnRemote(config, remoteContextDir, buildContext.dockerfileName, tag, log);
6312
+ await this.buildImageOnRemote(
6313
+ config,
6314
+ remoteContextDir,
6315
+ buildContext.dockerfileName,
6316
+ tag,
6317
+ log,
6318
+ signal
6319
+ );
6046
6320
  } finally {
6047
6321
  this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6048
6322
  });
@@ -6058,11 +6332,12 @@ var DockerRuntime = class _DockerRuntime {
6058
6332
  * but it's correct for local/TCP where there's no separate SSH
6059
6333
  * connection to piggyback on.
6060
6334
  */
6061
- async buildViaDockerode(config, buildContext, tag, log) {
6335
+ async buildViaDockerode(config, buildContext, tag, log, cancelSignal) {
6062
6336
  log.log(`Streaming build context to Docker daemon - image tag: ${tag}`);
6063
6337
  const { body, abortSignal, takeError } = packBuildContext(
6064
6338
  buildContext.contextDir,
6065
- buildContext.contextEntries
6339
+ buildContext.contextEntries,
6340
+ cancelSignal
6066
6341
  );
6067
6342
  try {
6068
6343
  const stream = await this.docker.buildImage(body, {
@@ -6195,7 +6470,17 @@ var DockerRuntime = class _DockerRuntime {
6195
6470
  const log = logger ?? new BuildLogger();
6196
6471
  const startTime = Date.now();
6197
6472
  const tag = this.imageTag(config.slug, config.sessionId);
6473
+ const abort = registerDockerBuild(config.sessionId);
6474
+ const cancelled = () => {
6475
+ log.step("build", "failed", "Docker build cancelled");
6476
+ return {
6477
+ sessionId: config.sessionId,
6478
+ status: "cancelled",
6479
+ durationMs: Date.now() - startTime
6480
+ };
6481
+ };
6198
6482
  try {
6483
+ if (abort.signal.aborted) return cancelled();
6199
6484
  log.log(`Build strategy: docker (${this.transport.description})
6200
6485
  `);
6201
6486
  try {
@@ -6209,6 +6494,7 @@ var DockerRuntime = class _DockerRuntime {
6209
6494
  try {
6210
6495
  this.emitDockerStep(log, "clone", "running", "Cloning source on the server...");
6211
6496
  await this.cloneSourceOnRemote(config, remoteContextDir, log);
6497
+ if (abort.signal.aborted) throw new BuildCancelledError();
6212
6498
  this.emitDockerStep(log, "clone", "completed", "Source cloned on the server");
6213
6499
  const dockerfileName = await this.resolveRemoteDockerfile(
6214
6500
  config,
@@ -6216,11 +6502,19 @@ var DockerRuntime = class _DockerRuntime {
6216
6502
  "Dockerfile.openship",
6217
6503
  config.stack === "docker"
6218
6504
  );
6219
- await this.buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log);
6505
+ await this.buildImageOnRemote(
6506
+ config,
6507
+ remoteContextDir,
6508
+ dockerfileName,
6509
+ tag,
6510
+ log,
6511
+ abort.signal
6512
+ );
6220
6513
  } finally {
6221
6514
  sshExecutor.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6222
6515
  });
6223
6516
  }
6517
+ if (abort.signal.aborted) return cancelled();
6224
6518
  await this.verifyImageBuilt(tag);
6225
6519
  log.log(`Image ${tag} is ready.
6226
6520
  `);
@@ -6232,6 +6526,7 @@ var DockerRuntime = class _DockerRuntime {
6232
6526
  requireRepositoryDockerfile: config.stack === "docker",
6233
6527
  onLog: log.callback
6234
6528
  });
6529
+ if (abort.signal.aborted) throw new BuildCancelledError();
6235
6530
  try {
6236
6531
  const sizeBytes = await this.estimateContextSize(buildContext.contextDir);
6237
6532
  const sizeMB = (sizeBytes / 1024 / 1024).toFixed(1);
@@ -6268,10 +6563,11 @@ var DockerRuntime = class _DockerRuntime {
6268
6563
  this.emitDockerStep(log, "build", "skipped", "No build command configured");
6269
6564
  }
6270
6565
  if (sshExecutor) {
6271
- await this.buildViaSshTarPipe(config, buildContext, tag, log);
6566
+ await this.buildViaSshTarPipe(config, buildContext, tag, log, abort.signal);
6272
6567
  } else {
6273
- await this.buildViaDockerode(config, buildContext, tag, log);
6568
+ await this.buildViaDockerode(config, buildContext, tag, log, abort.signal);
6274
6569
  }
6570
+ if (abort.signal.aborted) return cancelled();
6275
6571
  await this.verifyImageBuilt(tag);
6276
6572
  log.log(`Image ${tag} is ready.
6277
6573
  `);
@@ -6280,9 +6576,12 @@ var DockerRuntime = class _DockerRuntime {
6280
6576
  const durationMs = Date.now() - startTime;
6281
6577
  return { sessionId: config.sessionId, status: "deploying", imageRef: tag, durationMs };
6282
6578
  } catch (err) {
6579
+ if (abort.signal.aborted || err instanceof BuildCancelledError) return cancelled();
6283
6580
  const msg = safeErrorMessage(err);
6284
6581
  log.step("build", "failed", `Docker build failed: ${msg}`);
6285
6582
  return { sessionId: config.sessionId, status: "failed", durationMs: Date.now() - startTime, errorMessage: `Docker build failed: ${msg}` };
6583
+ } finally {
6584
+ releaseDockerBuild(config.sessionId, abort);
6286
6585
  }
6287
6586
  }
6288
6587
  /**
@@ -6344,23 +6643,33 @@ var DockerRuntime = class _DockerRuntime {
6344
6643
  }
6345
6644
  const tag = buildResult.imageRef;
6346
6645
  const extractStart = Date.now();
6646
+ const abort = registerDockerBuild(config.sessionId);
6647
+ const elapsed = () => (buildResult.durationMs ?? 0) + (Date.now() - extractStart);
6648
+ const cancelled = () => {
6649
+ log.step("build", "failed", "Static extract cancelled");
6650
+ return { sessionId: config.sessionId, status: "cancelled", durationMs: elapsed() };
6651
+ };
6347
6652
  try {
6348
6653
  await this.moveStaticBuildToHost(tag, config, hostOutDir, log);
6654
+ if (abort.signal.aborted) return cancelled();
6349
6655
  return {
6350
6656
  sessionId: config.sessionId,
6351
6657
  status: "deploying",
6352
6658
  imageRef: hostOutDir,
6353
- durationMs: (buildResult.durationMs ?? 0) + (Date.now() - extractStart)
6659
+ durationMs: elapsed()
6354
6660
  };
6355
6661
  } catch (err) {
6662
+ if (abort.signal.aborted || err instanceof BuildCancelledError) return cancelled();
6356
6663
  const msg = safeErrorMessage(err);
6357
6664
  log.step("build", "failed", `Static extract failed: ${msg}`);
6358
6665
  return {
6359
6666
  sessionId: config.sessionId,
6360
6667
  status: "failed",
6361
- durationMs: (buildResult.durationMs ?? 0) + (Date.now() - extractStart),
6668
+ durationMs: elapsed(),
6362
6669
  errorMessage: `Static extract failed: ${msg}`
6363
6670
  };
6671
+ } finally {
6672
+ releaseDockerBuild(config.sessionId, abort);
6364
6673
  }
6365
6674
  }
6366
6675
  /**
@@ -6394,6 +6703,7 @@ var DockerRuntime = class _DockerRuntime {
6394
6703
  async extractDocRootOverSsh(sshExecutor, tag, docRoot, hostOutDir) {
6395
6704
  const cid = (await sshExecutor.exec(`docker create ${sq(tag)}`)).trim();
6396
6705
  try {
6706
+ await ensureOwnedDir(sshExecutor, dirOf(hostOutDir));
6397
6707
  await sshExecutor.exec(`mkdir -p ${sq(hostOutDir)}`);
6398
6708
  await sshExecutor.exec(`docker cp ${sq(`${cid}:${docRoot}/.`)} ${sq(hostOutDir)}`);
6399
6709
  const listing = (await sshExecutor.exec(`ls -A ${sq(hostOutDir)} | head -1`).catch(() => "")).trim();
@@ -6434,12 +6744,12 @@ var DockerRuntime = class _DockerRuntime {
6434
6744
  */
6435
6745
  async extractDocRootViaBindMount(tag, docRoot, hostOutDir) {
6436
6746
  const { mkdir, readdir, writeFile, rm } = await import("fs/promises");
6437
- const { join: join2 } = await import("path");
6747
+ const { join: join3 } = await import("path");
6438
6748
  const { randomBytes } = await import("crypto");
6439
6749
  await mkdir(hostOutDir, { recursive: true });
6440
6750
  const OUT = "/__openship_out";
6441
6751
  const probe = `.openship-extract-probe-${randomBytes(6).toString("hex")}`;
6442
- await writeFile(join2(hostOutDir, probe), "probe");
6752
+ await writeFile(join3(hostOutDir, probe), "probe");
6443
6753
  const container = await this.docker.createContainer({
6444
6754
  Image: tag,
6445
6755
  // Override the ENTRYPOINT: builder base images (node, bun) ship a
@@ -6479,7 +6789,7 @@ var DockerRuntime = class _DockerRuntime {
6479
6789
  } finally {
6480
6790
  await container.remove({ force: true }).catch(() => {
6481
6791
  });
6482
- await rm(join2(hostOutDir, probe), { force: true }).catch(() => {
6792
+ await rm(join3(hostOutDir, probe), { force: true }).catch(() => {
6483
6793
  });
6484
6794
  }
6485
6795
  }
@@ -6542,16 +6852,34 @@ var DockerRuntime = class _DockerRuntime {
6542
6852
  const isSsh = this.transport.kind === "ssh" && !!this.connectionOptions?.executor;
6543
6853
  const cloneOnServer = isSsh && !!source.cloneOnServer;
6544
6854
  const remoteContextDir = `/tmp/openship-build-${source.sessionId}`;
6855
+ const abortControllers = new Map(
6856
+ specs.map((spec) => [spec.config.sessionId, registerDockerBuild(spec.config.sessionId)])
6857
+ );
6858
+ const isCancelled = (sessionId) => abortControllers.get(sessionId)?.signal.aborted === true;
6859
+ const allCancelled = () => [...abortControllers.values()].every((c) => c.signal.aborted);
6860
+ const cancelledResult = (sessionId, startedAt) => ({
6861
+ sessionId,
6862
+ status: "cancelled",
6863
+ durationMs: Date.now() - startedAt
6864
+ });
6545
6865
  let tree = null;
6546
- if (cloneOnServer) {
6547
- prepareLogger.step("clone", "running", "Cloning source on the server...");
6548
- await this.cloneSourceOnRemote(source, remoteContextDir, prepareLogger);
6549
- prepareLogger.step("clone", "completed", "Source cloned on the server");
6550
- } else {
6551
- prepareLogger.step("clone", "running", "Preparing shared build context...");
6552
- tree = await prepareSourceTree(source, { onLog: prepareLogger.callback });
6553
- }
6554
6866
  try {
6867
+ if (allCancelled()) {
6868
+ const startedAt = Date.now();
6869
+ return specs.map((spec) => {
6870
+ const result = cancelledResult(spec.config.sessionId, startedAt);
6871
+ spec.onResult?.(result);
6872
+ return { serviceName: spec.serviceName, result };
6873
+ });
6874
+ }
6875
+ if (cloneOnServer) {
6876
+ prepareLogger.step("clone", "running", "Cloning source on the server...");
6877
+ await this.cloneSourceOnRemote(source, remoteContextDir, prepareLogger);
6878
+ prepareLogger.step("clone", "completed", "Source cloned on the server");
6879
+ } else {
6880
+ prepareLogger.step("clone", "running", "Preparing shared build context...");
6881
+ tree = await prepareSourceTree(source, { onLog: prepareLogger.callback });
6882
+ }
6555
6883
  const resolvedList = await Promise.all(
6556
6884
  specs.map(async (spec) => {
6557
6885
  const generatedName = `Dockerfile.openship.${spec.config.sessionId}`;
@@ -6602,7 +6930,7 @@ var DockerRuntime = class _DockerRuntime {
6602
6930
  } catch {
6603
6931
  prepareLogger.step("clone", "completed", "Shared build context ready");
6604
6932
  }
6605
- if (isSsh) {
6933
+ if (isSsh && !allCancelled()) {
6606
6934
  await this.transferBuildContext(tree.contextDir, remoteContextDir, prepareLogger);
6607
6935
  }
6608
6936
  }
@@ -6610,6 +6938,13 @@ var DockerRuntime = class _DockerRuntime {
6610
6938
  for (const { spec, dockerfileName, contextEntries, error } of resolvedList) {
6611
6939
  const startedAt = Date.now();
6612
6940
  const tag = this.imageTag(spec.config.slug, spec.config.sessionId);
6941
+ const signal = abortControllers.get(spec.config.sessionId)?.signal;
6942
+ if (isCancelled(spec.config.sessionId)) {
6943
+ const result = cancelledResult(spec.config.sessionId, startedAt);
6944
+ spec.onResult?.(result);
6945
+ results.push({ serviceName: spec.serviceName, result });
6946
+ continue;
6947
+ }
6613
6948
  if (error || !dockerfileName) {
6614
6949
  const result = {
6615
6950
  sessionId: spec.config.sessionId,
@@ -6629,12 +6964,14 @@ var DockerRuntime = class _DockerRuntime {
6629
6964
  remoteContextDir,
6630
6965
  dockerfileName,
6631
6966
  tag,
6632
- spec.logger
6967
+ spec.logger,
6968
+ signal
6633
6969
  );
6634
6970
  } else {
6635
6971
  const { body, abortSignal, takeError } = packBuildContext(
6636
6972
  tree.contextDir,
6637
- contextEntries ?? []
6973
+ contextEntries ?? [],
6974
+ signal
6638
6975
  );
6639
6976
  try {
6640
6977
  const stream = await this.docker.buildImage(body, {
@@ -6655,6 +6992,12 @@ var DockerRuntime = class _DockerRuntime {
6655
6992
  throw contextErr ? new Error(`Failed to read Docker build context: ${safeErrorMessage(contextErr)}`, { cause: contextErr }) : err;
6656
6993
  }
6657
6994
  }
6995
+ if (isCancelled(spec.config.sessionId)) {
6996
+ const result2 = cancelledResult(spec.config.sessionId, startedAt);
6997
+ spec.onResult?.(result2);
6998
+ results.push({ serviceName: spec.serviceName, result: result2 });
6999
+ continue;
7000
+ }
6658
7001
  await this.verifyImageBuilt(tag);
6659
7002
  if (spec.config.staticExtractOnly && spec.config.staticOutDir) {
6660
7003
  await this.moveStaticBuildToHost(
@@ -6663,7 +7006,7 @@ var DockerRuntime = class _DockerRuntime {
6663
7006
  spec.config.staticOutDir,
6664
7007
  spec.logger
6665
7008
  );
6666
- const result2 = {
7009
+ const result2 = isCancelled(spec.config.sessionId) ? cancelledResult(spec.config.sessionId, startedAt) : {
6667
7010
  sessionId: spec.config.sessionId,
6668
7011
  status: "deploying",
6669
7012
  imageRef: spec.config.staticOutDir,
@@ -6684,6 +7027,13 @@ var DockerRuntime = class _DockerRuntime {
6684
7027
  spec.onResult?.(result);
6685
7028
  results.push({ serviceName: spec.serviceName, result });
6686
7029
  } catch (err) {
7030
+ if (isCancelled(spec.config.sessionId) || err instanceof BuildCancelledError) {
7031
+ spec.logger.log("Docker build cancelled\n", "error");
7032
+ const result2 = cancelledResult(spec.config.sessionId, startedAt);
7033
+ spec.onResult?.(result2);
7034
+ results.push({ serviceName: spec.serviceName, result: result2 });
7035
+ continue;
7036
+ }
6687
7037
  const msg = safeErrorMessage(err);
6688
7038
  spec.logger.log(`Docker build failed: ${msg}
6689
7039
  `, "error");
@@ -6699,6 +7049,9 @@ var DockerRuntime = class _DockerRuntime {
6699
7049
  }
6700
7050
  return results;
6701
7051
  } finally {
7052
+ for (const [sessionId, abort] of abortControllers) {
7053
+ releaseDockerBuild(sessionId, abort);
7054
+ }
6702
7055
  if (isSsh) {
6703
7056
  this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6704
7057
  });
@@ -6707,11 +7060,23 @@ var DockerRuntime = class _DockerRuntime {
6707
7060
  }
6708
7061
  }
6709
7062
  async cancelBuild(sessionId) {
7063
+ for (const [activeId, abort] of activeDockerBuilds) {
7064
+ if (cancelCovers(sessionId, activeId)) abort.abort();
7065
+ }
7066
+ recordPendingCancellation(sessionId);
7067
+ const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : void 0;
7068
+ if (executor) {
7069
+ await killProcessesUnderDir(executor, `/tmp/openship-build-${sessionId}`, {
7070
+ includeSuffixed: true
7071
+ });
7072
+ }
6710
7073
  const containers = await this.docker.listContainers({
6711
7074
  all: true,
6712
- filters: { label: [`openship.build=${sessionId}`] }
7075
+ filters: { label: ["openship.build"] }
6713
7076
  });
6714
7077
  for (const c of containers) {
7078
+ const buildId = c.Labels?.["openship.build"];
7079
+ if (!buildId || !cancelCovers(sessionId, buildId)) continue;
6715
7080
  try {
6716
7081
  await this.docker.getContainer(c.Id).remove({ force: true });
6717
7082
  } catch {
@@ -6732,7 +7097,7 @@ var DockerRuntime = class _DockerRuntime {
6732
7097
  }
6733
7098
  const containerName = `openship-${config.runtimeName || config.projectId}-${config.deploymentId}`;
6734
7099
  const env = [
6735
- `PORT=${config.port}`,
7100
+ ...config.portless ? [] : [`PORT=${config.port}`],
6736
7101
  `NODE_ENV=${config.environment === "production" ? "production" : "development"}`,
6737
7102
  ...Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`)
6738
7103
  ];
@@ -6796,7 +7161,9 @@ var DockerRuntime = class _DockerRuntime {
6796
7161
  // rebuild, exactly like a compose service.
6797
7162
  ...networkId ? { service: config.networkAlias } : {}
6798
7163
  }),
6799
- ExposedPorts: { [`${config.port}/tcp`]: {} },
7164
+ // A worker exposes and publishes no port (#538-B); everything else exposes
7165
+ // its app port for the loopback publish below.
7166
+ ...config.portless ? {} : { ExposedPorts: { [`${config.port}/tcp`]: {} } },
6800
7167
  ...networkId ? { NetworkingConfig: { EndpointsConfig: { [networkId]: { Aliases: aliases } } } } : {},
6801
7168
  HostConfig: {
6802
7169
  RestartPolicy: restartPolicy,
@@ -6814,10 +7181,13 @@ var DockerRuntime = class _DockerRuntime {
6814
7181
  // app directly, bypassing the edge's SSL/rate-limit/rules (and Docker's
6815
7182
  // iptables bypass ufw). A pinned `config.hostPort` (loopback-port route
6816
7183
  // strategy) is stable across redeploys; otherwise a random loopback port.
6817
- PortBindings: {
6818
- [`${config.port}/tcp`]: [
6819
- { HostIp: "127.0.0.1", HostPort: config.hostPort ? String(config.hostPort) : "" }
6820
- ]
7184
+ // A worker (config.portless) binds no host port at all (#538-B).
7185
+ ...config.portless ? {} : {
7186
+ PortBindings: {
7187
+ [`${config.port}/tcp`]: [
7188
+ { HostIp: "127.0.0.1", HostPort: config.hostPort ? String(config.hostPort) : "" }
7189
+ ]
7190
+ }
6821
7191
  }
6822
7192
  }
6823
7193
  });
@@ -6869,6 +7239,7 @@ var DockerRuntime = class _DockerRuntime {
6869
7239
  }
6870
7240
  const container = this.docker.getContainer(containerId);
6871
7241
  try {
7242
+ await gracefulStopForGrace(container);
6872
7243
  await container.remove({ force: true });
6873
7244
  } catch (err) {
6874
7245
  if (!isDockerNotFoundError(err)) throw err;
@@ -7135,13 +7506,39 @@ var DockerRuntime = class _DockerRuntime {
7135
7506
  }
7136
7507
  const executor = this.connectionOptions?.executor;
7137
7508
  if (executor) {
7138
- await executor.exec(`docker pull ${sq(ref)}`, { timeout: 10 * 6e4 });
7509
+ const timeout = 10 * 6e4;
7510
+ const auth = await this.connectionOptions?.resolveRegistryAuth?.(ref);
7511
+ const config = auth ? dockerConfigJsonFor(auth) : null;
7512
+ if (!config) {
7513
+ await executor.exec(`docker pull ${sq(ref)}`, { timeout });
7514
+ return;
7515
+ }
7516
+ const dir = `/tmp/openship-pull-${crypto.randomUUID()}`;
7517
+ try {
7518
+ await executor.exec(`mkdir -m 700 ${sq(dir)}`);
7519
+ await executor.writeFile(`${dir}/config.json`, config);
7520
+ await executor.exec(`chmod 600 ${sq(dir)}/config.json`);
7521
+ await executor.exec(
7522
+ `DOCKER_CONFIG=${sq(dir)} docker pull ${sq(ref)}; rc=$?; rm -rf ${sq(dir)}; exit $rc`,
7523
+ { timeout }
7524
+ );
7525
+ } finally {
7526
+ await executor.exec(`rm -rf ${sq(dir)}`).catch(() => {
7527
+ });
7528
+ }
7139
7529
  return;
7140
7530
  }
7141
- const stream = await this.docker.pull(ref);
7142
- await new Promise((resolve, reject) => {
7143
- this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
7144
- });
7531
+ const authconfig = this.connectionOptions?.resolveRegistryAuth ? await this.connectionOptions.resolveRegistryAuth(ref) : await resolveDockerAuth(ref);
7532
+ try {
7533
+ const stream = await this.docker.pull(ref, authconfig ? { authconfig } : {});
7534
+ await new Promise((resolve, reject) => {
7535
+ this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
7536
+ });
7537
+ } catch (err) {
7538
+ throw new Error(
7539
+ `Failed to pull ${ref} from ${registryForImage(ref)}: ${safeErrorMessage(err)}`
7540
+ );
7541
+ }
7145
7542
  }
7146
7543
  /** Is this image tag present on THIS daemon? Distinguishes a locally-built
7147
7544
  * image (must be transferred cross-server) from a registry tag (the target
@@ -7731,16 +8128,17 @@ var DockerRuntime = class _DockerRuntime {
7731
8128
  const network = this.docker.getNetwork(networkId);
7732
8129
  for (const m of members) {
7733
8130
  if (!m.containerId) continue;
8131
+ const aliases = m.aliases.filter(Boolean);
7734
8132
  try {
7735
8133
  await network.connect({
7736
8134
  Container: m.containerId,
7737
- EndpointConfig: m.alias ? { Aliases: [m.alias] } : {}
8135
+ EndpointConfig: aliases.length ? { Aliases: aliases } : {}
7738
8136
  });
7739
8137
  } catch (err) {
7740
8138
  const msg = err?.message ?? "";
7741
8139
  if (!/already exists|already connected/i.test(msg)) {
7742
8140
  console.warn(
7743
- `[docker] group join failed for ${m.containerId.slice(0, 12)} (${m.alias}): ${msg}`
8141
+ `[docker] group join failed for ${m.containerId.slice(0, 12)} (${aliases.join(", ")}): ${msg}`
7744
8142
  );
7745
8143
  }
7746
8144
  }
@@ -7802,6 +8200,20 @@ var DockerRuntime = class _DockerRuntime {
7802
8200
  * the narrow case where the network's identity changed out-of-band (docker
7803
8201
  * network prune/rm, daemon/host rebuild) and surviving containers fell off it.
7804
8202
  */
8203
+ /**
8204
+ * A container that can never be attached to a network, because it doesn't own a
8205
+ * network namespace to attach (compose `network_mode: container:…`/`none`, or a
8206
+ * migration-adopted `host`).
8207
+ *
8208
+ * Both membership loops below are best-effort and swallow errors, so without this
8209
+ * they'd still work — they'd just log "connect failed" for such a container on
8210
+ * every single deploy, forever, over something that is correct by design. Read
8211
+ * from `HostConfig.NetworkMode`, which `/containers/json` reports.
8212
+ */
8213
+ cannotJoinNetworks(container) {
8214
+ const mode = container.HostConfig?.NetworkMode;
8215
+ return !!mode && (mode.startsWith("container:") || mode === "none" || mode === "host");
8216
+ }
7805
8217
  async reconcileNetworkMembership(networkId, projectId) {
7806
8218
  let containers;
7807
8219
  try {
@@ -7818,6 +8230,7 @@ var DockerRuntime = class _DockerRuntime {
7818
8230
  (n) => n?.NetworkID === networkId
7819
8231
  );
7820
8232
  if (onNetwork) continue;
8233
+ if (this.cannotJoinNetworks(c)) continue;
7821
8234
  const service = c.Labels?.["openship.service"];
7822
8235
  try {
7823
8236
  await network.connect({
@@ -7867,6 +8280,7 @@ var DockerRuntime = class _DockerRuntime {
7867
8280
  (n) => n?.NetworkID === netId
7868
8281
  );
7869
8282
  if (onNetwork) continue;
8283
+ if (this.cannotJoinNetworks(c)) continue;
7870
8284
  try {
7871
8285
  await network.connect({ Container: c.Id });
7872
8286
  } catch (err) {
@@ -7901,6 +8315,7 @@ var DockerRuntime = class _DockerRuntime {
7901
8315
  const containerName = `openship-${config.slug}-${config.serviceName}`;
7902
8316
  try {
7903
8317
  const existing = this.docker.getContainer(containerName);
8318
+ await gracefulStopForGrace(existing);
7904
8319
  await existing.remove({ force: true });
7905
8320
  } catch {
7906
8321
  }
@@ -7909,6 +8324,11 @@ var DockerRuntime = class _DockerRuntime {
7909
8324
  ...Object.entries(config.environment).map(([k, v]) => `${k}=${v}`)
7910
8325
  ];
7911
8326
  const cmd = resolveComposeCmd(config);
8327
+ const entrypoint = resolveComposeEntrypoint(config.advanced);
8328
+ const sharedNetwork = config.namespaces?.network;
8329
+ const sharesNetns = !!sharedNetwork && sharedNetwork !== "none";
8330
+ const ownsProjectEndpoint = ownsNetworkEndpoint(sharedNetwork);
8331
+ const sharedPid = config.namespaces?.pid;
7912
8332
  const { exposedPorts, portBindings } = parsePortBindings(config.ports);
7913
8333
  if (!config.namespaceVolumes) {
7914
8334
  await this.assertNoForeignNamedVolumeCollision(config);
@@ -7917,6 +8337,7 @@ var DockerRuntime = class _DockerRuntime {
7917
8337
  const binds = scopedBinds.length > 0 ? scopedBinds : void 0;
7918
8338
  const restartPolicy = resolveRestartPolicy(config.restart);
7919
8339
  const healthcheck = toDockerHealthcheck(config.advanced?.healthcheck);
8340
+ const stopConfig = toStopConfig(config.advanced);
7920
8341
  log({
7921
8342
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7922
8343
  message: `Creating service container ${containerName} from ${config.image}...
@@ -7929,6 +8350,23 @@ var DockerRuntime = class _DockerRuntime {
7929
8350
  `,
7930
8351
  level: "info"
7931
8352
  });
8353
+ if (sharedNetwork) {
8354
+ log({
8355
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8356
+ message: `Network namespace: ${sharedNetwork} \u2014 this container gets no interface of its own` + (sharesNetns ? `, so its ports, project-network alias, and hostname come from the service it shares. Publish and route on THAT service instead.
8357
+ ` : ` and no network access at all.
8358
+ `),
8359
+ level: "warn"
8360
+ });
8361
+ }
8362
+ if (sharedPid) {
8363
+ log({
8364
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8365
+ message: `PID namespace: ${sharedPid}
8366
+ `,
8367
+ level: "info"
8368
+ });
8369
+ }
7932
8370
  if (!config.image.startsWith("openship/")) {
7933
8371
  try {
7934
8372
  log({
@@ -7952,8 +8390,16 @@ var DockerRuntime = class _DockerRuntime {
7952
8390
  name: containerName,
7953
8391
  Image: config.image,
7954
8392
  Cmd: cmd,
8393
+ // Entrypoint (#575). SPREAD rather than assigned, because `undefined` and `[]`
8394
+ // must reach Docker differently: absent leaves the image's own ENTRYPOINT
8395
+ // alone, while `Entrypoint: []` clears it. Assigning `undefined` would send the
8396
+ // key, and the same conditional-spread shape is why `toStopConfig` returns an
8397
+ // object instead of nullable fields.
8398
+ ...entrypoint === void 0 ? {} : { Entrypoint: entrypoint },
7955
8399
  Env: env,
7956
- Hostname: config.serviceName,
8400
+ // Docker rejects an explicit hostname when the network namespace is another
8401
+ // container's — that container owns the UTS identity too.
8402
+ ...sharesNetns ? {} : { Hostname: config.serviceName },
7957
8403
  Labels: {
7958
8404
  ...this.labels({
7959
8405
  deploymentId: config.deploymentId,
@@ -7962,23 +8408,31 @@ var DockerRuntime = class _DockerRuntime {
7962
8408
  "openship.service": config.serviceName
7963
8409
  },
7964
8410
  ...healthcheck && { Healthcheck: healthcheck },
7965
- ExposedPorts: exposedPorts,
8411
+ ...stopConfig,
8412
+ ...ownsProjectEndpoint ? { ExposedPorts: exposedPorts } : {},
7966
8413
  HostConfig: {
7967
8414
  RestartPolicy: restartPolicy,
7968
8415
  ...dockerResourceLimits(config.resources),
7969
- PortBindings: portBindings,
8416
+ ...ownsProjectEndpoint ? { PortBindings: portBindings } : {},
7970
8417
  Binds: binds,
7971
- NetworkMode: group.id
8418
+ // The project network is the default; a compose `network_mode` replaces it.
8419
+ NetworkMode: sharedNetwork ?? group.id,
8420
+ ...sharedPid ? { PidMode: sharedPid } : {}
7972
8421
  },
7973
- NetworkingConfig: {
7974
- EndpointsConfig: {
7975
- [group.id]: {
7976
- // Default service name plus any operator-chosen aliases; both
7977
- // resolve to this container on the project network.
7978
- Aliases: buildNetworkAliases(config.serviceName, config.extraAliases)
8422
+ // A declared network mode means there is no endpoint to name — omit the
8423
+ // block entirely rather than send an empty one (and never contradict
8424
+ // `NetworkMode: none` by attaching a network anyway).
8425
+ ...ownsProjectEndpoint ? {
8426
+ NetworkingConfig: {
8427
+ EndpointsConfig: {
8428
+ [group.id]: {
8429
+ // Default service name plus any operator-chosen aliases; both
8430
+ // resolve to this container on the project network.
8431
+ Aliases: buildNetworkAliases(config.serviceName, config.extraAliases)
8432
+ }
7979
8433
  }
7980
8434
  }
7981
- }
8435
+ } : {}
7982
8436
  });
7983
8437
  try {
7984
8438
  await container.start();
@@ -8034,12 +8488,17 @@ export {
8034
8488
  startExecStream,
8035
8489
  startAttachStream,
8036
8490
  daemonConnectionFrom,
8491
+ DEFAULT_DOCKER_SOCKET_PATH,
8492
+ resolveLocalDockerSocketPath,
8037
8493
  ownsBuiltImage,
8038
8494
  buildInContainerExecCmd,
8039
8495
  resolveExecExitCode,
8040
8496
  buildNetworkAliases,
8041
8497
  parsePortBindings,
8042
8498
  packBuildContext,
8499
+ toDockerHealthcheck,
8500
+ toStopConfig,
8501
+ gracefulStopForGrace,
8043
8502
  splitDockerFrames,
8044
8503
  parseContainerEventLine,
8045
8504
  DockerRuntime