openship 0.6.0 → 0.6.5

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 (63) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-FSUAM5V5.js → bare-XQIPDO4R.js} +10 -9
  3. package/dist/{chunk-PSZSKEPA.js → chunk-3ZAGC5NT.js} +64 -17
  4. package/dist/{chunk-6ODUBKUB.js → chunk-5OUWIPOB.js} +69 -7
  5. package/dist/chunk-6HOA5JT7.js +124 -0
  6. package/dist/{chunk-TWJCLD4W.js → chunk-6JELW75A.js} +4 -4
  7. package/dist/{chunk-GUQ3QCJ7.js → chunk-7334NSJL.js} +399 -369
  8. package/dist/chunk-7S4USQYF.js +1753 -0
  9. package/dist/{chunk-3LDWWY7U.js → chunk-A7EWM2KA.js} +41 -7
  10. package/dist/chunk-ELGRMZOW.js +25 -0
  11. package/dist/chunk-EZPPTWJQ.js +292 -0
  12. package/dist/{chunk-IXM5245F.js → chunk-GRTXMUHA.js} +553 -538
  13. package/dist/chunk-HPXP6LXZ.js +428 -0
  14. package/dist/{chunk-OTLSJYAC.js → chunk-LEYSL46B.js} +2135 -85
  15. package/dist/{chunk-EMXI4UCU.js → chunk-LFCSC4IU.js} +29 -10
  16. package/dist/{chunk-JIMUYEKN.js → chunk-MSZUCNPV.js} +1 -1
  17. package/dist/{chunk-OGY63DGD.js → chunk-PZRRL5PC.js} +71 -28
  18. package/dist/{chunk-VE2YOK4I.js → chunk-QLX7NBEA.js} +1218 -588
  19. package/dist/chunk-RFWJE7ZN.js +68 -0
  20. package/dist/{chunk-VDDMADOF.js → chunk-TM2IQPYU.js} +16 -2
  21. package/dist/{chunk-3FYIXVX5.js → chunk-VDESJSG6.js} +451 -106
  22. package/dist/{chunk-B3CE2OMR.js → chunk-VHMOQL5Y.js} +276 -50
  23. package/dist/{chunk-QEH4QUBX.js → chunk-VLQ3UIVO.js} +393 -53
  24. package/dist/{chunk-VZ2ZHPS2.js → chunk-WE7BXW6L.js} +3 -9
  25. package/dist/chunk-WFRXYWK2.js +444 -0
  26. package/dist/chunk-XRNRT37W.js +316 -0
  27. package/dist/{chunk-EP2TNALB.js → chunk-XVOBL7BC.js} +31 -8
  28. package/dist/cloud-3DODOWTR.js +36 -0
  29. package/dist/{detect-ALF4GXRX.js → detect-P4CIQBRH.js} +3 -3
  30. package/dist/{docker-QECQJ5P4.js → docker-3K2NEBD6.js} +15 -8
  31. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-YZZ6P7IC.js} +5 -5
  32. package/dist/{edge-import-CQWHN2Q3.js → edge-import-MCJNVTZ2.js} +26 -23
  33. package/dist/edge-preflight-ES6FH4JK.js +42 -0
  34. package/dist/{ensure-container-edge-MJVXJQG5.js → ensure-container-edge-YPITNH45.js} +9 -8
  35. package/dist/{executor-JELARVKC.js → executor-DZGX55K2.js} +12 -5
  36. package/dist/host-channel-preflight-MUXE3KZU.js +49 -0
  37. package/dist/index.js +3668 -4063
  38. package/dist/{nginx-AXGNWRKZ.js → nginx-NSAHWGJV.js} +11 -5
  39. package/dist/node-bootstrap.js +241 -0
  40. package/dist/node-entry.js +52 -1
  41. package/dist/{openresty-lua-IE4P5YQX.js → openresty-lua-DR4D3EZ6.js} +6 -2
  42. package/dist/privilege-YTVI4SKF.js +19 -0
  43. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  44. package/dist/server/index.js +21501 -13137
  45. package/dist/server/lua/geo_country.lua +4 -3
  46. package/dist/server/lua/pipe_stream.lua +52 -39
  47. package/dist/server/lua/site_logger.lua +22 -24
  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/meta/_journal.json +49 -0
  56. package/dist/{setup-FA4PW4NU.js → setup-WUUZURHS.js} +10 -9
  57. package/dist/up-XEO3SR6Z.js +58 -0
  58. package/package.json +4 -2
  59. package/dist/chunk-IW2UKBVF.js +0 -144
  60. package/dist/chunk-ZCFVHA25.js +0 -89
  61. package/dist/cloud-E5M6VMCG.js +0 -31
  62. package/dist/server/lua/pipe_log.lua +0 -77
  63. /package/dist/{dist-es-JGBPABJI.js → dist-es-ZP5QDSJ3.js} +0 -0
@@ -9,33 +9,41 @@ import {
9
9
  resolveDockerfileCandidates,
10
10
  resolveServiceDockerfile,
11
11
  staticBuilderOutputPath
12
- } from "./chunk-EMXI4UCU.js";
12
+ } from "./chunk-LFCSC4IU.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-TM2IQPYU.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-5OUWIPOB.js";
28
33
  import {
29
- isRuntimeNotFoundError
30
- } from "./chunk-ZCFVHA25.js";
34
+ dirOf,
35
+ ensureOwnedDir
36
+ } from "./chunk-WFRXYWK2.js";
31
37
  import {
32
38
  formatCpuCores,
33
39
  formatMemoryMb,
34
40
  hasCpuLimit,
35
41
  hasMemoryLimit,
42
+ isRuntimeNotFoundError,
43
+ ownsNetworkEndpoint,
36
44
  safeErrorMessage,
37
45
  withTimeout
38
- } from "./chunk-OTLSJYAC.js";
46
+ } from "./chunk-LEYSL46B.js";
39
47
  import {
40
48
  __commonJS,
41
49
  __require,
@@ -4842,9 +4850,9 @@ import { join } from "path";
4842
4850
  import net2 from "net";
4843
4851
  var DEFAULT_REMOTE_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
4844
4852
  var resolvedDockerSocketPathCache = /* @__PURE__ */ new WeakMap();
4845
- var DOCKER_DIAL_STDIO_COMMAND = "docker system dial-stdio";
4846
4853
  var STREAMLOCAL_PROBE_TIMEOUT_MS = 8e3;
4847
4854
  var STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS = 3e3;
4855
+ var DIAL_STDIO_ANSWER_TIMEOUT_MS = 3e3;
4848
4856
  var VERIFY_BUFFER_CAP_BYTES = 8 * 1024 * 1024;
4849
4857
  function toSshConfig(opts) {
4850
4858
  return {
@@ -4895,12 +4903,8 @@ async function discoverRemoteDockerSocketPathsWithClient(client) {
4895
4903
  return normalizeSocketPathLines(lines);
4896
4904
  }
4897
4905
  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
- }
4906
+ const output = await executor.exec(DOCKER_SOCKET_DISCOVERY_SCRIPT, { timeout: 1e4 });
4907
+ return normalizeSocketPathLines(output.split(/\r?\n/));
4904
4908
  }
4905
4909
  async function discoverRemoteDockerSocketPaths(opts) {
4906
4910
  if (opts.executor) {
@@ -4923,7 +4927,13 @@ async function resolveRemoteDockerSocketPath(opts) {
4923
4927
  if (cachedPath) {
4924
4928
  return cachedPath;
4925
4929
  }
4926
- const pendingPath = discoverRemoteDockerSocketPaths(opts).then((paths) => paths[0] ?? DEFAULT_REMOTE_DOCKER_SOCKET_PATH).catch(() => DEFAULT_REMOTE_DOCKER_SOCKET_PATH);
4930
+ const pendingPath = discoverRemoteDockerSocketPaths(opts).then(
4931
+ (paths) => paths[0] ?? DEFAULT_REMOTE_DOCKER_SOCKET_PATH,
4932
+ () => {
4933
+ resolvedDockerSocketPathCache.delete(opts);
4934
+ return DEFAULT_REMOTE_DOCKER_SOCKET_PATH;
4935
+ }
4936
+ );
4927
4937
  resolvedDockerSocketPathCache.set(opts, pendingPath);
4928
4938
  return pendingPath;
4929
4939
  }
@@ -4943,35 +4953,98 @@ async function openStreamlocalUpstream(opts) {
4943
4953
  throw error;
4944
4954
  }
4945
4955
  }
4946
- function pipeThrough(client, upstream) {
4956
+ function respondBadGateway(client, reason) {
4957
+ if (client.destroyed || client.writableEnded) {
4958
+ client.destroy();
4959
+ return;
4960
+ }
4961
+ const body = JSON.stringify({ message: reason });
4962
+ try {
4963
+ client.end(
4964
+ `HTTP/1.1 502 Bad Gateway\r
4965
+ Content-Type: application/json\r
4966
+ Content-Length: ${Buffer.byteLength(body)}\r
4967
+ Connection: close\r
4968
+ \r
4969
+ ` + body
4970
+ );
4971
+ } catch {
4972
+ client.destroy();
4973
+ }
4974
+ }
4975
+ function pipeThrough(client, upstream, handoff) {
4976
+ let answered = handoff.answered;
4947
4977
  const teardown = () => {
4948
4978
  client.destroy();
4949
4979
  upstream.destroy();
4950
4980
  };
4951
4981
  client.on("error", teardown);
4952
- upstream.on("error", teardown);
4982
+ upstream.on("error", () => {
4983
+ if (!answered) respondBadGateway(client, handoff.unansweredReason());
4984
+ upstream.destroy();
4985
+ });
4953
4986
  client.once("close", () => upstream.destroy());
4954
- upstream.once("close", () => client.destroy());
4987
+ upstream.once("close", () => {
4988
+ if (!answered) {
4989
+ respondBadGateway(client, handoff.unansweredReason());
4990
+ return;
4991
+ }
4992
+ if (!client.destroyed) client.end();
4993
+ });
4994
+ upstream.on("data", () => {
4995
+ answered = true;
4996
+ });
4955
4997
  client.pipe(upstream);
4956
- upstream.pipe(client);
4998
+ upstream.pipe(client, { end: false });
4999
+ upstream.once("end", () => {
5000
+ if (answered && !client.writableEnded) client.end();
5001
+ });
4957
5002
  }
4958
5003
  function awaitFirstByte(upstream, ms) {
4959
5004
  return new Promise((resolve) => {
4960
- const finish = (chunk) => {
5005
+ const finish = (outcome) => {
4961
5006
  clearTimeout(timer);
4962
5007
  upstream.removeListener("data", onData);
4963
5008
  upstream.removeListener("error", onFail);
4964
5009
  upstream.removeListener("close", onFail);
4965
- resolve(chunk);
5010
+ resolve(outcome);
4966
5011
  };
4967
- const onData = (chunk) => finish(chunk);
4968
- const onFail = () => finish(null);
4969
- const timer = setTimeout(() => finish(null), ms);
5012
+ const onData = (chunk) => finish({ kind: "data", chunk });
5013
+ const onFail = () => finish({ kind: "closed" });
5014
+ const timer = setTimeout(() => finish({ kind: "silent" }), ms);
4970
5015
  upstream.on("data", onData);
4971
5016
  upstream.once("error", onFail);
4972
5017
  upstream.once("close", onFail);
4973
5018
  });
4974
5019
  }
5020
+ function looksLikeHttpResponse(chunk) {
5021
+ const marker = "HTTP/";
5022
+ const head = chunk.subarray(0, marker.length).toString("latin1");
5023
+ return marker.startsWith(head) || head.startsWith(marker);
5024
+ }
5025
+ 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.";
5026
+ function sentence(text) {
5027
+ const trimmed = text.trim();
5028
+ return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
5029
+ }
5030
+ function bridgeFailureReason(opts, cause) {
5031
+ const host = opts.host?.trim() || "the remote host";
5032
+ return `Could not reach the Docker daemon on ${host} over SSH: ${sentence(cause)} ${BRIDGE_FAILURE_HINT}`;
5033
+ }
5034
+ function upstreamClosedReason(opts, upstream, transport) {
5035
+ return bridgeFailureReason(
5036
+ opts,
5037
+ dialStdioDiagnostics(upstream) || `the ${transport} channel closed before answering the request`
5038
+ );
5039
+ }
5040
+ var REPLAY_SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
5041
+ function requestIsReplaySafe(chunks) {
5042
+ const head = chunks.find((c) => c.length > 0);
5043
+ if (!head) return true;
5044
+ const space = head.indexOf(32);
5045
+ if (space <= 0) return false;
5046
+ return REPLAY_SAFE_METHODS.has(head.subarray(0, space).toString("latin1"));
5047
+ }
4975
5048
  function captureClient(client) {
4976
5049
  const chunks = [];
4977
5050
  let bytes = 0;
@@ -5006,9 +5079,15 @@ function captureClient(client) {
5006
5079
  get overflowed() {
5007
5080
  return overflowed;
5008
5081
  },
5082
+ /** Whether this request may be handed to a second upstream — see
5083
+ * `requestIsReplaySafe`. Read at the moment of the decision, not at capture time. */
5084
+ get replaySafe() {
5085
+ return requestIsReplaySafe(chunks);
5086
+ },
5009
5087
  /** 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. */
5088
+ * live. Calling it again on a different upstream (the fallback) replays the
5089
+ * whole buffer — complete, because nothing is dropped until `commit`, but a
5090
+ * second dispatch to the daemon: gate that on `replaySafe`. */
5012
5091
  feed(upstream) {
5013
5092
  for (const chunk of chunks) upstream.write(chunk);
5014
5093
  live = upstream;
@@ -5019,25 +5098,30 @@ function captureClient(client) {
5019
5098
  live = null;
5020
5099
  },
5021
5100
  /** 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) {
5101
+ * bidi pipe, or destroy it if the client already went away. `handoff` tells the
5102
+ * pipe whether a response byte has already been forwarded, and what to say if
5103
+ * the upstream closes without ever producing one. */
5104
+ commit(upstream, handoff) {
5024
5105
  detach();
5025
5106
  chunks.length = 0;
5026
5107
  if (closed) {
5027
5108
  upstream.destroy();
5028
5109
  return;
5029
5110
  }
5030
- pipeThrough(client, upstream);
5111
+ pipeThrough(client, upstream, handoff);
5031
5112
  },
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() {
5113
+ /** Terminal: no usable upstream. Answer the request with `reason` (a real HTTP
5114
+ * 502 see `respondBadGateway`) instead of destroying the socket, which is
5115
+ * what turned every one of these into `socket hang up`.
5116
+ *
5117
+ * Written BEFORE detach so the socket still has an error listener during the
5118
+ * write, and never with `destroy(err)`: a server-side socket cannot transmit a
5119
+ * JS Error to its peer, and destroy(err) emits a local 'error' event that —
5120
+ * once detach() has removed this socket's listener — would be unhandled and
5121
+ * take down the process over one dead Docker request. */
5122
+ fail(reason) {
5123
+ respondBadGateway(client, reason);
5039
5124
  detach();
5040
- client.destroy();
5041
5125
  }
5042
5126
  };
5043
5127
  }
@@ -5049,12 +5133,7 @@ function createDockerSshBridge(opts) {
5049
5133
  let pooledUnreliable = false;
5050
5134
  const openDialStdioUpstream = async (forceEphemeral = false) => {
5051
5135
  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;
5136
+ return attachDialStdioDiagnostics(await opts.executor.openDockerDialStdio());
5058
5137
  }
5059
5138
  if (!dialClient) {
5060
5139
  const client = await connectSshClient(toSshConfig(opts));
@@ -5063,7 +5142,34 @@ function createDockerSshBridge(opts) {
5063
5142
  });
5064
5143
  dialClient = client;
5065
5144
  }
5066
- return openSshExecChannel(dialClient, DOCKER_DIAL_STDIO_COMMAND);
5145
+ return openDockerDialStdioChannel(dialClient);
5146
+ };
5147
+ const serveDialStdio = async (capture, client, upstream) => {
5148
+ capture.feed(upstream);
5149
+ const first = capture.overflowed ? { kind: "silent" } : await awaitFirstByte(upstream, DIAL_STDIO_ANSWER_TIMEOUT_MS);
5150
+ if (first.kind === "closed") {
5151
+ capture.stop();
5152
+ upstream.destroy();
5153
+ capture.fail(upstreamClosedReason(opts, upstream, DOCKER_DIAL_STDIO_COMMAND));
5154
+ return;
5155
+ }
5156
+ if (first.kind === "data" && !looksLikeHttpResponse(first.chunk)) {
5157
+ capture.stop();
5158
+ upstream.destroy();
5159
+ const banner = first.chunk.toString("utf8").trim().replace(/\s+/g, " ").slice(0, 300);
5160
+ capture.fail(
5161
+ bridgeFailureReason(
5162
+ opts,
5163
+ `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}\``
5164
+ )
5165
+ );
5166
+ return;
5167
+ }
5168
+ if (first.kind === "data") client.write(first.chunk);
5169
+ capture.commit(upstream, {
5170
+ answered: first.kind === "data",
5171
+ unansweredReason: () => upstreamClosedReason(opts, upstream, DOCKER_DIAL_STDIO_COMMAND)
5172
+ });
5067
5173
  };
5068
5174
  const decideMode = async () => {
5069
5175
  let probe = null;
@@ -5111,24 +5217,20 @@ function createDockerSshBridge(opts) {
5111
5217
  modeDecision ??= decideMode().then((mode) => upstreamMode = mode);
5112
5218
  return modeDecision;
5113
5219
  };
5114
- const downgradeToDialStdio = async (capture, reason) => {
5220
+ const downgradeToDialStdio = async (capture, client, reason) => {
5115
5221
  console.warn(
5116
5222
  `[docker-ssh] streamlocal unusable (${opts.host ?? "?"}): ${reason} \u2014 downgrading this bridge to dial-stdio (fresh connection) and replaying the buffered request.`
5117
5223
  );
5118
5224
  upstreamMode = "dialstdio";
5119
5225
  pooledUnreliable = true;
5120
- const upstream = await openDialStdioUpstream(true);
5121
- capture.feed(upstream);
5122
- capture.commit(upstream);
5226
+ await serveDialStdio(capture, client, await openDialStdioUpstream(true));
5123
5227
  };
5124
5228
  const bridgeClient = async (client) => {
5125
5229
  const capture = captureClient(client);
5126
5230
  try {
5127
5231
  const mode = await ensureMode();
5128
5232
  if (mode === "dialstdio") {
5129
- const upstream = await openDialStdioUpstream(pooledUnreliable);
5130
- capture.feed(upstream);
5131
- capture.commit(upstream);
5233
+ await serveDialStdio(capture, client, await openDialStdioUpstream(pooledUnreliable));
5132
5234
  return;
5133
5235
  }
5134
5236
  let channel;
@@ -5139,22 +5241,36 @@ function createDockerSshBridge(opts) {
5139
5241
  `channel open timed out after ${STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS / 1e3}s`
5140
5242
  );
5141
5243
  } catch (err) {
5142
- await downgradeToDialStdio(capture, safeErrorMessage(err));
5244
+ await downgradeToDialStdio(capture, client, safeErrorMessage(err));
5143
5245
  return;
5144
5246
  }
5145
5247
  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);
5248
+ const firstByte = capture.overflowed ? { kind: "silent" } : await awaitFirstByte(channel, STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS);
5249
+ if (capture.overflowed || firstByte.kind === "data") {
5250
+ if (firstByte.kind === "data") client.write(firstByte.chunk);
5251
+ capture.commit(channel, {
5252
+ answered: firstByte.kind === "data",
5253
+ unansweredReason: () => upstreamClosedReason(opts, channel, "streamlocal")
5254
+ });
5255
+ return;
5256
+ }
5257
+ if (firstByte.kind === "silent" && !capture.replaySafe) {
5258
+ capture.commit(channel, {
5259
+ answered: false,
5260
+ unansweredReason: () => upstreamClosedReason(opts, channel, "streamlocal")
5261
+ });
5150
5262
  return;
5151
5263
  }
5152
5264
  capture.stop();
5153
5265
  channel.destroy();
5154
- await downgradeToDialStdio(capture, "channel opened but no data flowed");
5266
+ await downgradeToDialStdio(
5267
+ capture,
5268
+ client,
5269
+ firstByte.kind === "closed" ? "channel closed before answering" : "channel opened but no data flowed"
5270
+ );
5155
5271
  } catch (err) {
5156
5272
  console.warn(`[docker-ssh] bridge client failed (${opts.host ?? "?"}): ${safeErrorMessage(err)}`);
5157
- capture.fail();
5273
+ capture.fail(bridgeFailureReason(opts, safeErrorMessage(err)));
5158
5274
  }
5159
5275
  };
5160
5276
  const server = net2.createServer((client) => {
@@ -5330,6 +5446,35 @@ var RESTART_POLICIES = {
5330
5446
  no: { Name: "no", MaximumRetryCount: 0 }
5331
5447
  };
5332
5448
  var DOCKER_BUILD_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
5449
+ var activeDockerBuilds = /* @__PURE__ */ new Map();
5450
+ var pendingDockerBuildCancellations = /* @__PURE__ */ new Map();
5451
+ var PENDING_CANCEL_TTL_MS = 10 * 60 * 1e3;
5452
+ function cancelCovers(cancelledId, sessionId) {
5453
+ return sessionId === cancelledId || sessionId.startsWith(`${cancelledId}-`);
5454
+ }
5455
+ function recordPendingCancellation(sessionId) {
5456
+ clearTimeout(pendingDockerBuildCancellations.get(sessionId));
5457
+ const timer = setTimeout(
5458
+ () => pendingDockerBuildCancellations.delete(sessionId),
5459
+ PENDING_CANCEL_TTL_MS
5460
+ );
5461
+ timer.unref?.();
5462
+ pendingDockerBuildCancellations.set(sessionId, timer);
5463
+ }
5464
+ function registerDockerBuild(sessionId) {
5465
+ const abort = new AbortController();
5466
+ for (const cancelledId of pendingDockerBuildCancellations.keys()) {
5467
+ if (cancelCovers(cancelledId, sessionId)) {
5468
+ abort.abort();
5469
+ break;
5470
+ }
5471
+ }
5472
+ activeDockerBuilds.set(sessionId, abort);
5473
+ return abort;
5474
+ }
5475
+ function releaseDockerBuild(sessionId, abort) {
5476
+ if (activeDockerBuilds.get(sessionId) === abort) activeDockerBuilds.delete(sessionId);
5477
+ }
5333
5478
  var DEFAULT_IN_CONTAINER_EXEC_TIMEOUT_MS = 12e4;
5334
5479
  var SIGTERM_EXIT_CODE = 143;
5335
5480
  var IN_CONTAINER_EXEC_WATCHDOG = [
@@ -5475,7 +5620,7 @@ function parsePortBindings(portSpecs) {
5475
5620
  }
5476
5621
  return { exposedPorts, portBindings };
5477
5622
  }
5478
- function packBuildContext(contextDir, entries) {
5623
+ function packBuildContext(contextDir, entries, cancelSignal) {
5479
5624
  const controller = new AbortController();
5480
5625
  const pack2 = tarFs.pack(contextDir, { entries });
5481
5626
  const body = pack2.pipe(createGzip());
@@ -5484,6 +5629,10 @@ function packBuildContext(contextDir, entries) {
5484
5629
  contextError ??= err instanceof Error ? err : new Error(String(err));
5485
5630
  if (!controller.signal.aborted) controller.abort();
5486
5631
  };
5632
+ if (cancelSignal) {
5633
+ if (cancelSignal.aborted) controller.abort();
5634
+ else cancelSignal.addEventListener("abort", () => controller.abort(), { once: true });
5635
+ }
5487
5636
  pack2.once("error", capture);
5488
5637
  body.once("error", capture);
5489
5638
  return { body, abortSignal: controller.signal, takeError: () => contextError };
@@ -5519,7 +5668,9 @@ function toDockerHealthcheck(hc) {
5519
5668
  if (typeof hc.test === "string" && hc.test.trim()) {
5520
5669
  Test = ["CMD-SHELL", hc.test];
5521
5670
  } else if (Array.isArray(hc.test) && hc.test.length > 0) {
5522
- Test = ["CMD", ...hc.test];
5671
+ const head = hc.test[0];
5672
+ if (head === "NONE") return { Test: ["NONE"] };
5673
+ Test = head === "CMD" || head === "CMD-SHELL" ? [...hc.test] : ["CMD", ...hc.test];
5523
5674
  }
5524
5675
  if (!Test) return void 0;
5525
5676
  const Interval = parseDurationNs(hc.interval);
@@ -5533,6 +5684,28 @@ function toDockerHealthcheck(hc) {
5533
5684
  ...StartPeriod !== void 0 && { StartPeriod }
5534
5685
  };
5535
5686
  }
5687
+ function toStopConfig(advanced) {
5688
+ const out = {};
5689
+ const signal = advanced?.stopSignal?.trim();
5690
+ if (signal) out.StopSignal = signal;
5691
+ const graceNs = parseDurationNs(advanced?.stopGracePeriod);
5692
+ if (graceNs !== void 0) {
5693
+ out.StopTimeout = graceNs <= 0 ? 0 : Math.max(1, Math.round(graceNs / 1e9));
5694
+ }
5695
+ return out;
5696
+ }
5697
+ async function gracefulStopForGrace(container) {
5698
+ let stopTimeout;
5699
+ try {
5700
+ stopTimeout = (await container.inspect()).Config.StopTimeout;
5701
+ } catch {
5702
+ return;
5703
+ }
5704
+ if (typeof stopTimeout === "number" && stopTimeout > 0) {
5705
+ await container.stop().catch(() => {
5706
+ });
5707
+ }
5708
+ }
5536
5709
  function hasDockerFrameHeader(buf, offset = 0) {
5537
5710
  return buf.length >= offset + 8 && (buf[offset] === 1 || buf[offset] === 2) && buf[offset + 1] === 0 && buf[offset + 2] === 0 && buf[offset + 3] === 0;
5538
5711
  }
@@ -5654,7 +5827,15 @@ var DockerRuntime = class _DockerRuntime {
5654
5827
  "deploymentContainerQuery",
5655
5828
  "hostContainerQuery",
5656
5829
  "stabilityProbe",
5657
- "containerEvents"
5830
+ "containerEvents",
5831
+ // Docker implements `inContainerExecutor` but had never DECLARED the capability.
5832
+ // Nothing gated on it (every caller tested for the method), so the omission was
5833
+ // invisible — but it meant the honest answer to supports("inContainerExec") was
5834
+ // wrong for the one runtime that best supports it.
5835
+ "inContainerExec",
5836
+ // A docker exec lands in the container's own namespaces, so a command run
5837
+ // through it cannot reach the host. Bare deliberately does NOT declare this.
5838
+ "isolatedExec"
5658
5839
  ]);
5659
5840
  /** Docker honors every extended compose key we currently support. */
5660
5841
  unsupportedComposeKeys = /* @__PURE__ */ new Set();
@@ -5901,7 +6082,7 @@ var DockerRuntime = class _DockerRuntime {
5901
6082
  * context dir. One image; `dockerfileName` selects which Dockerfile in the
5902
6083
  * shared tree to use (so N services can build from one transferred context).
5903
6084
  */
5904
- async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log) {
6085
+ async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log, signal) {
5905
6086
  const executor = this.connectionOptions?.executor;
5906
6087
  if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
5907
6088
  const buildArgs = Object.entries({
@@ -5916,10 +6097,15 @@ var DockerRuntime = class _DockerRuntime {
5916
6097
  log.log(`Running on remote: ${buildCmd}`);
5917
6098
  log.log("\u2500\u2500\u2500 docker build output \u2500\u2500\u2500");
5918
6099
  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
- });
6100
+ const { code } = await executor.streamExec(
6101
+ buildCmd,
6102
+ (entry) => {
6103
+ log.log(entry.message, parseLogLevel(entry.message));
6104
+ },
6105
+ { signal }
6106
+ );
5922
6107
  log.log("\u2500\u2500\u2500 end docker build output \u2500\u2500\u2500");
6108
+ if (signal?.aborted) throw new BuildCancelledError();
5923
6109
  if (code !== 0) throw new Error(`docker build exited with code ${code}`);
5924
6110
  this.emitDockerStep(log, "install", "completed", "Image build finished");
5925
6111
  }
@@ -6038,11 +6224,18 @@ var DockerRuntime = class _DockerRuntime {
6038
6224
  await executor.writeFile(`${remoteContextDir}/${generatedName}`, generateDockerfile(config));
6039
6225
  return generatedName;
6040
6226
  }
6041
- async buildViaSshTarPipe(config, buildContext, tag, log) {
6227
+ async buildViaSshTarPipe(config, buildContext, tag, log, signal) {
6042
6228
  const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
6043
6229
  try {
6044
6230
  await this.transferBuildContext(buildContext.contextDir, remoteContextDir, log);
6045
- await this.buildImageOnRemote(config, remoteContextDir, buildContext.dockerfileName, tag, log);
6231
+ await this.buildImageOnRemote(
6232
+ config,
6233
+ remoteContextDir,
6234
+ buildContext.dockerfileName,
6235
+ tag,
6236
+ log,
6237
+ signal
6238
+ );
6046
6239
  } finally {
6047
6240
  this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6048
6241
  });
@@ -6058,11 +6251,12 @@ var DockerRuntime = class _DockerRuntime {
6058
6251
  * but it's correct for local/TCP where there's no separate SSH
6059
6252
  * connection to piggyback on.
6060
6253
  */
6061
- async buildViaDockerode(config, buildContext, tag, log) {
6254
+ async buildViaDockerode(config, buildContext, tag, log, cancelSignal) {
6062
6255
  log.log(`Streaming build context to Docker daemon - image tag: ${tag}`);
6063
6256
  const { body, abortSignal, takeError } = packBuildContext(
6064
6257
  buildContext.contextDir,
6065
- buildContext.contextEntries
6258
+ buildContext.contextEntries,
6259
+ cancelSignal
6066
6260
  );
6067
6261
  try {
6068
6262
  const stream = await this.docker.buildImage(body, {
@@ -6195,7 +6389,17 @@ var DockerRuntime = class _DockerRuntime {
6195
6389
  const log = logger ?? new BuildLogger();
6196
6390
  const startTime = Date.now();
6197
6391
  const tag = this.imageTag(config.slug, config.sessionId);
6392
+ const abort = registerDockerBuild(config.sessionId);
6393
+ const cancelled = () => {
6394
+ log.step("build", "failed", "Docker build cancelled");
6395
+ return {
6396
+ sessionId: config.sessionId,
6397
+ status: "cancelled",
6398
+ durationMs: Date.now() - startTime
6399
+ };
6400
+ };
6198
6401
  try {
6402
+ if (abort.signal.aborted) return cancelled();
6199
6403
  log.log(`Build strategy: docker (${this.transport.description})
6200
6404
  `);
6201
6405
  try {
@@ -6209,6 +6413,7 @@ var DockerRuntime = class _DockerRuntime {
6209
6413
  try {
6210
6414
  this.emitDockerStep(log, "clone", "running", "Cloning source on the server...");
6211
6415
  await this.cloneSourceOnRemote(config, remoteContextDir, log);
6416
+ if (abort.signal.aborted) throw new BuildCancelledError();
6212
6417
  this.emitDockerStep(log, "clone", "completed", "Source cloned on the server");
6213
6418
  const dockerfileName = await this.resolveRemoteDockerfile(
6214
6419
  config,
@@ -6216,11 +6421,19 @@ var DockerRuntime = class _DockerRuntime {
6216
6421
  "Dockerfile.openship",
6217
6422
  config.stack === "docker"
6218
6423
  );
6219
- await this.buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log);
6424
+ await this.buildImageOnRemote(
6425
+ config,
6426
+ remoteContextDir,
6427
+ dockerfileName,
6428
+ tag,
6429
+ log,
6430
+ abort.signal
6431
+ );
6220
6432
  } finally {
6221
6433
  sshExecutor.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6222
6434
  });
6223
6435
  }
6436
+ if (abort.signal.aborted) return cancelled();
6224
6437
  await this.verifyImageBuilt(tag);
6225
6438
  log.log(`Image ${tag} is ready.
6226
6439
  `);
@@ -6232,6 +6445,7 @@ var DockerRuntime = class _DockerRuntime {
6232
6445
  requireRepositoryDockerfile: config.stack === "docker",
6233
6446
  onLog: log.callback
6234
6447
  });
6448
+ if (abort.signal.aborted) throw new BuildCancelledError();
6235
6449
  try {
6236
6450
  const sizeBytes = await this.estimateContextSize(buildContext.contextDir);
6237
6451
  const sizeMB = (sizeBytes / 1024 / 1024).toFixed(1);
@@ -6268,10 +6482,11 @@ var DockerRuntime = class _DockerRuntime {
6268
6482
  this.emitDockerStep(log, "build", "skipped", "No build command configured");
6269
6483
  }
6270
6484
  if (sshExecutor) {
6271
- await this.buildViaSshTarPipe(config, buildContext, tag, log);
6485
+ await this.buildViaSshTarPipe(config, buildContext, tag, log, abort.signal);
6272
6486
  } else {
6273
- await this.buildViaDockerode(config, buildContext, tag, log);
6487
+ await this.buildViaDockerode(config, buildContext, tag, log, abort.signal);
6274
6488
  }
6489
+ if (abort.signal.aborted) return cancelled();
6275
6490
  await this.verifyImageBuilt(tag);
6276
6491
  log.log(`Image ${tag} is ready.
6277
6492
  `);
@@ -6280,9 +6495,12 @@ var DockerRuntime = class _DockerRuntime {
6280
6495
  const durationMs = Date.now() - startTime;
6281
6496
  return { sessionId: config.sessionId, status: "deploying", imageRef: tag, durationMs };
6282
6497
  } catch (err) {
6498
+ if (abort.signal.aborted || err instanceof BuildCancelledError) return cancelled();
6283
6499
  const msg = safeErrorMessage(err);
6284
6500
  log.step("build", "failed", `Docker build failed: ${msg}`);
6285
6501
  return { sessionId: config.sessionId, status: "failed", durationMs: Date.now() - startTime, errorMessage: `Docker build failed: ${msg}` };
6502
+ } finally {
6503
+ releaseDockerBuild(config.sessionId, abort);
6286
6504
  }
6287
6505
  }
6288
6506
  /**
@@ -6344,23 +6562,33 @@ var DockerRuntime = class _DockerRuntime {
6344
6562
  }
6345
6563
  const tag = buildResult.imageRef;
6346
6564
  const extractStart = Date.now();
6565
+ const abort = registerDockerBuild(config.sessionId);
6566
+ const elapsed = () => (buildResult.durationMs ?? 0) + (Date.now() - extractStart);
6567
+ const cancelled = () => {
6568
+ log.step("build", "failed", "Static extract cancelled");
6569
+ return { sessionId: config.sessionId, status: "cancelled", durationMs: elapsed() };
6570
+ };
6347
6571
  try {
6348
6572
  await this.moveStaticBuildToHost(tag, config, hostOutDir, log);
6573
+ if (abort.signal.aborted) return cancelled();
6349
6574
  return {
6350
6575
  sessionId: config.sessionId,
6351
6576
  status: "deploying",
6352
6577
  imageRef: hostOutDir,
6353
- durationMs: (buildResult.durationMs ?? 0) + (Date.now() - extractStart)
6578
+ durationMs: elapsed()
6354
6579
  };
6355
6580
  } catch (err) {
6581
+ if (abort.signal.aborted || err instanceof BuildCancelledError) return cancelled();
6356
6582
  const msg = safeErrorMessage(err);
6357
6583
  log.step("build", "failed", `Static extract failed: ${msg}`);
6358
6584
  return {
6359
6585
  sessionId: config.sessionId,
6360
6586
  status: "failed",
6361
- durationMs: (buildResult.durationMs ?? 0) + (Date.now() - extractStart),
6587
+ durationMs: elapsed(),
6362
6588
  errorMessage: `Static extract failed: ${msg}`
6363
6589
  };
6590
+ } finally {
6591
+ releaseDockerBuild(config.sessionId, abort);
6364
6592
  }
6365
6593
  }
6366
6594
  /**
@@ -6394,6 +6622,7 @@ var DockerRuntime = class _DockerRuntime {
6394
6622
  async extractDocRootOverSsh(sshExecutor, tag, docRoot, hostOutDir) {
6395
6623
  const cid = (await sshExecutor.exec(`docker create ${sq(tag)}`)).trim();
6396
6624
  try {
6625
+ await ensureOwnedDir(sshExecutor, dirOf(hostOutDir));
6397
6626
  await sshExecutor.exec(`mkdir -p ${sq(hostOutDir)}`);
6398
6627
  await sshExecutor.exec(`docker cp ${sq(`${cid}:${docRoot}/.`)} ${sq(hostOutDir)}`);
6399
6628
  const listing = (await sshExecutor.exec(`ls -A ${sq(hostOutDir)} | head -1`).catch(() => "")).trim();
@@ -6542,16 +6771,34 @@ var DockerRuntime = class _DockerRuntime {
6542
6771
  const isSsh = this.transport.kind === "ssh" && !!this.connectionOptions?.executor;
6543
6772
  const cloneOnServer = isSsh && !!source.cloneOnServer;
6544
6773
  const remoteContextDir = `/tmp/openship-build-${source.sessionId}`;
6774
+ const abortControllers = new Map(
6775
+ specs.map((spec) => [spec.config.sessionId, registerDockerBuild(spec.config.sessionId)])
6776
+ );
6777
+ const isCancelled = (sessionId) => abortControllers.get(sessionId)?.signal.aborted === true;
6778
+ const allCancelled = () => [...abortControllers.values()].every((c) => c.signal.aborted);
6779
+ const cancelledResult = (sessionId, startedAt) => ({
6780
+ sessionId,
6781
+ status: "cancelled",
6782
+ durationMs: Date.now() - startedAt
6783
+ });
6545
6784
  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
6785
  try {
6786
+ if (allCancelled()) {
6787
+ const startedAt = Date.now();
6788
+ return specs.map((spec) => {
6789
+ const result = cancelledResult(spec.config.sessionId, startedAt);
6790
+ spec.onResult?.(result);
6791
+ return { serviceName: spec.serviceName, result };
6792
+ });
6793
+ }
6794
+ if (cloneOnServer) {
6795
+ prepareLogger.step("clone", "running", "Cloning source on the server...");
6796
+ await this.cloneSourceOnRemote(source, remoteContextDir, prepareLogger);
6797
+ prepareLogger.step("clone", "completed", "Source cloned on the server");
6798
+ } else {
6799
+ prepareLogger.step("clone", "running", "Preparing shared build context...");
6800
+ tree = await prepareSourceTree(source, { onLog: prepareLogger.callback });
6801
+ }
6555
6802
  const resolvedList = await Promise.all(
6556
6803
  specs.map(async (spec) => {
6557
6804
  const generatedName = `Dockerfile.openship.${spec.config.sessionId}`;
@@ -6602,7 +6849,7 @@ var DockerRuntime = class _DockerRuntime {
6602
6849
  } catch {
6603
6850
  prepareLogger.step("clone", "completed", "Shared build context ready");
6604
6851
  }
6605
- if (isSsh) {
6852
+ if (isSsh && !allCancelled()) {
6606
6853
  await this.transferBuildContext(tree.contextDir, remoteContextDir, prepareLogger);
6607
6854
  }
6608
6855
  }
@@ -6610,6 +6857,13 @@ var DockerRuntime = class _DockerRuntime {
6610
6857
  for (const { spec, dockerfileName, contextEntries, error } of resolvedList) {
6611
6858
  const startedAt = Date.now();
6612
6859
  const tag = this.imageTag(spec.config.slug, spec.config.sessionId);
6860
+ const signal = abortControllers.get(spec.config.sessionId)?.signal;
6861
+ if (isCancelled(spec.config.sessionId)) {
6862
+ const result = cancelledResult(spec.config.sessionId, startedAt);
6863
+ spec.onResult?.(result);
6864
+ results.push({ serviceName: spec.serviceName, result });
6865
+ continue;
6866
+ }
6613
6867
  if (error || !dockerfileName) {
6614
6868
  const result = {
6615
6869
  sessionId: spec.config.sessionId,
@@ -6629,12 +6883,14 @@ var DockerRuntime = class _DockerRuntime {
6629
6883
  remoteContextDir,
6630
6884
  dockerfileName,
6631
6885
  tag,
6632
- spec.logger
6886
+ spec.logger,
6887
+ signal
6633
6888
  );
6634
6889
  } else {
6635
6890
  const { body, abortSignal, takeError } = packBuildContext(
6636
6891
  tree.contextDir,
6637
- contextEntries ?? []
6892
+ contextEntries ?? [],
6893
+ signal
6638
6894
  );
6639
6895
  try {
6640
6896
  const stream = await this.docker.buildImage(body, {
@@ -6655,6 +6911,12 @@ var DockerRuntime = class _DockerRuntime {
6655
6911
  throw contextErr ? new Error(`Failed to read Docker build context: ${safeErrorMessage(contextErr)}`, { cause: contextErr }) : err;
6656
6912
  }
6657
6913
  }
6914
+ if (isCancelled(spec.config.sessionId)) {
6915
+ const result2 = cancelledResult(spec.config.sessionId, startedAt);
6916
+ spec.onResult?.(result2);
6917
+ results.push({ serviceName: spec.serviceName, result: result2 });
6918
+ continue;
6919
+ }
6658
6920
  await this.verifyImageBuilt(tag);
6659
6921
  if (spec.config.staticExtractOnly && spec.config.staticOutDir) {
6660
6922
  await this.moveStaticBuildToHost(
@@ -6663,7 +6925,7 @@ var DockerRuntime = class _DockerRuntime {
6663
6925
  spec.config.staticOutDir,
6664
6926
  spec.logger
6665
6927
  );
6666
- const result2 = {
6928
+ const result2 = isCancelled(spec.config.sessionId) ? cancelledResult(spec.config.sessionId, startedAt) : {
6667
6929
  sessionId: spec.config.sessionId,
6668
6930
  status: "deploying",
6669
6931
  imageRef: spec.config.staticOutDir,
@@ -6684,6 +6946,13 @@ var DockerRuntime = class _DockerRuntime {
6684
6946
  spec.onResult?.(result);
6685
6947
  results.push({ serviceName: spec.serviceName, result });
6686
6948
  } catch (err) {
6949
+ if (isCancelled(spec.config.sessionId) || err instanceof BuildCancelledError) {
6950
+ spec.logger.log("Docker build cancelled\n", "error");
6951
+ const result2 = cancelledResult(spec.config.sessionId, startedAt);
6952
+ spec.onResult?.(result2);
6953
+ results.push({ serviceName: spec.serviceName, result: result2 });
6954
+ continue;
6955
+ }
6687
6956
  const msg = safeErrorMessage(err);
6688
6957
  spec.logger.log(`Docker build failed: ${msg}
6689
6958
  `, "error");
@@ -6699,6 +6968,9 @@ var DockerRuntime = class _DockerRuntime {
6699
6968
  }
6700
6969
  return results;
6701
6970
  } finally {
6971
+ for (const [sessionId, abort] of abortControllers) {
6972
+ releaseDockerBuild(sessionId, abort);
6973
+ }
6702
6974
  if (isSsh) {
6703
6975
  this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6704
6976
  });
@@ -6707,11 +6979,23 @@ var DockerRuntime = class _DockerRuntime {
6707
6979
  }
6708
6980
  }
6709
6981
  async cancelBuild(sessionId) {
6982
+ for (const [activeId, abort] of activeDockerBuilds) {
6983
+ if (cancelCovers(sessionId, activeId)) abort.abort();
6984
+ }
6985
+ recordPendingCancellation(sessionId);
6986
+ const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : void 0;
6987
+ if (executor) {
6988
+ await killProcessesUnderDir(executor, `/tmp/openship-build-${sessionId}`, {
6989
+ includeSuffixed: true
6990
+ });
6991
+ }
6710
6992
  const containers = await this.docker.listContainers({
6711
6993
  all: true,
6712
- filters: { label: [`openship.build=${sessionId}`] }
6994
+ filters: { label: ["openship.build"] }
6713
6995
  });
6714
6996
  for (const c of containers) {
6997
+ const buildId = c.Labels?.["openship.build"];
6998
+ if (!buildId || !cancelCovers(sessionId, buildId)) continue;
6715
6999
  try {
6716
7000
  await this.docker.getContainer(c.Id).remove({ force: true });
6717
7001
  } catch {
@@ -6732,7 +7016,7 @@ var DockerRuntime = class _DockerRuntime {
6732
7016
  }
6733
7017
  const containerName = `openship-${config.runtimeName || config.projectId}-${config.deploymentId}`;
6734
7018
  const env = [
6735
- `PORT=${config.port}`,
7019
+ ...config.portless ? [] : [`PORT=${config.port}`],
6736
7020
  `NODE_ENV=${config.environment === "production" ? "production" : "development"}`,
6737
7021
  ...Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`)
6738
7022
  ];
@@ -6796,7 +7080,9 @@ var DockerRuntime = class _DockerRuntime {
6796
7080
  // rebuild, exactly like a compose service.
6797
7081
  ...networkId ? { service: config.networkAlias } : {}
6798
7082
  }),
6799
- ExposedPorts: { [`${config.port}/tcp`]: {} },
7083
+ // A worker exposes and publishes no port (#538-B); everything else exposes
7084
+ // its app port for the loopback publish below.
7085
+ ...config.portless ? {} : { ExposedPorts: { [`${config.port}/tcp`]: {} } },
6800
7086
  ...networkId ? { NetworkingConfig: { EndpointsConfig: { [networkId]: { Aliases: aliases } } } } : {},
6801
7087
  HostConfig: {
6802
7088
  RestartPolicy: restartPolicy,
@@ -6814,10 +7100,13 @@ var DockerRuntime = class _DockerRuntime {
6814
7100
  // app directly, bypassing the edge's SSL/rate-limit/rules (and Docker's
6815
7101
  // iptables bypass ufw). A pinned `config.hostPort` (loopback-port route
6816
7102
  // 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
- ]
7103
+ // A worker (config.portless) binds no host port at all (#538-B).
7104
+ ...config.portless ? {} : {
7105
+ PortBindings: {
7106
+ [`${config.port}/tcp`]: [
7107
+ { HostIp: "127.0.0.1", HostPort: config.hostPort ? String(config.hostPort) : "" }
7108
+ ]
7109
+ }
6821
7110
  }
6822
7111
  }
6823
7112
  });
@@ -6869,6 +7158,7 @@ var DockerRuntime = class _DockerRuntime {
6869
7158
  }
6870
7159
  const container = this.docker.getContainer(containerId);
6871
7160
  try {
7161
+ await gracefulStopForGrace(container);
6872
7162
  await container.remove({ force: true });
6873
7163
  } catch (err) {
6874
7164
  if (!isDockerNotFoundError(err)) throw err;
@@ -7731,16 +8021,17 @@ var DockerRuntime = class _DockerRuntime {
7731
8021
  const network = this.docker.getNetwork(networkId);
7732
8022
  for (const m of members) {
7733
8023
  if (!m.containerId) continue;
8024
+ const aliases = m.aliases.filter(Boolean);
7734
8025
  try {
7735
8026
  await network.connect({
7736
8027
  Container: m.containerId,
7737
- EndpointConfig: m.alias ? { Aliases: [m.alias] } : {}
8028
+ EndpointConfig: aliases.length ? { Aliases: aliases } : {}
7738
8029
  });
7739
8030
  } catch (err) {
7740
8031
  const msg = err?.message ?? "";
7741
8032
  if (!/already exists|already connected/i.test(msg)) {
7742
8033
  console.warn(
7743
- `[docker] group join failed for ${m.containerId.slice(0, 12)} (${m.alias}): ${msg}`
8034
+ `[docker] group join failed for ${m.containerId.slice(0, 12)} (${aliases.join(", ")}): ${msg}`
7744
8035
  );
7745
8036
  }
7746
8037
  }
@@ -7802,6 +8093,20 @@ var DockerRuntime = class _DockerRuntime {
7802
8093
  * the narrow case where the network's identity changed out-of-band (docker
7803
8094
  * network prune/rm, daemon/host rebuild) and surviving containers fell off it.
7804
8095
  */
8096
+ /**
8097
+ * A container that can never be attached to a network, because it doesn't own a
8098
+ * network namespace to attach (compose `network_mode: container:…`/`none`, or a
8099
+ * migration-adopted `host`).
8100
+ *
8101
+ * Both membership loops below are best-effort and swallow errors, so without this
8102
+ * they'd still work — they'd just log "connect failed" for such a container on
8103
+ * every single deploy, forever, over something that is correct by design. Read
8104
+ * from `HostConfig.NetworkMode`, which `/containers/json` reports.
8105
+ */
8106
+ cannotJoinNetworks(container) {
8107
+ const mode = container.HostConfig?.NetworkMode;
8108
+ return !!mode && (mode.startsWith("container:") || mode === "none" || mode === "host");
8109
+ }
7805
8110
  async reconcileNetworkMembership(networkId, projectId) {
7806
8111
  let containers;
7807
8112
  try {
@@ -7818,6 +8123,7 @@ var DockerRuntime = class _DockerRuntime {
7818
8123
  (n) => n?.NetworkID === networkId
7819
8124
  );
7820
8125
  if (onNetwork) continue;
8126
+ if (this.cannotJoinNetworks(c)) continue;
7821
8127
  const service = c.Labels?.["openship.service"];
7822
8128
  try {
7823
8129
  await network.connect({
@@ -7867,6 +8173,7 @@ var DockerRuntime = class _DockerRuntime {
7867
8173
  (n) => n?.NetworkID === netId
7868
8174
  );
7869
8175
  if (onNetwork) continue;
8176
+ if (this.cannotJoinNetworks(c)) continue;
7870
8177
  try {
7871
8178
  await network.connect({ Container: c.Id });
7872
8179
  } catch (err) {
@@ -7901,6 +8208,7 @@ var DockerRuntime = class _DockerRuntime {
7901
8208
  const containerName = `openship-${config.slug}-${config.serviceName}`;
7902
8209
  try {
7903
8210
  const existing = this.docker.getContainer(containerName);
8211
+ await gracefulStopForGrace(existing);
7904
8212
  await existing.remove({ force: true });
7905
8213
  } catch {
7906
8214
  }
@@ -7909,6 +8217,10 @@ var DockerRuntime = class _DockerRuntime {
7909
8217
  ...Object.entries(config.environment).map(([k, v]) => `${k}=${v}`)
7910
8218
  ];
7911
8219
  const cmd = resolveComposeCmd(config);
8220
+ const sharedNetwork = config.namespaces?.network;
8221
+ const sharesNetns = !!sharedNetwork && sharedNetwork !== "none";
8222
+ const ownsProjectEndpoint = ownsNetworkEndpoint(sharedNetwork);
8223
+ const sharedPid = config.namespaces?.pid;
7912
8224
  const { exposedPorts, portBindings } = parsePortBindings(config.ports);
7913
8225
  if (!config.namespaceVolumes) {
7914
8226
  await this.assertNoForeignNamedVolumeCollision(config);
@@ -7917,6 +8229,7 @@ var DockerRuntime = class _DockerRuntime {
7917
8229
  const binds = scopedBinds.length > 0 ? scopedBinds : void 0;
7918
8230
  const restartPolicy = resolveRestartPolicy(config.restart);
7919
8231
  const healthcheck = toDockerHealthcheck(config.advanced?.healthcheck);
8232
+ const stopConfig = toStopConfig(config.advanced);
7920
8233
  log({
7921
8234
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7922
8235
  message: `Creating service container ${containerName} from ${config.image}...
@@ -7929,6 +8242,23 @@ var DockerRuntime = class _DockerRuntime {
7929
8242
  `,
7930
8243
  level: "info"
7931
8244
  });
8245
+ if (sharedNetwork) {
8246
+ log({
8247
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8248
+ 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.
8249
+ ` : ` and no network access at all.
8250
+ `),
8251
+ level: "warn"
8252
+ });
8253
+ }
8254
+ if (sharedPid) {
8255
+ log({
8256
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8257
+ message: `PID namespace: ${sharedPid}
8258
+ `,
8259
+ level: "info"
8260
+ });
8261
+ }
7932
8262
  if (!config.image.startsWith("openship/")) {
7933
8263
  try {
7934
8264
  log({
@@ -7953,7 +8283,9 @@ var DockerRuntime = class _DockerRuntime {
7953
8283
  Image: config.image,
7954
8284
  Cmd: cmd,
7955
8285
  Env: env,
7956
- Hostname: config.serviceName,
8286
+ // Docker rejects an explicit hostname when the network namespace is another
8287
+ // container's — that container owns the UTS identity too.
8288
+ ...sharesNetns ? {} : { Hostname: config.serviceName },
7957
8289
  Labels: {
7958
8290
  ...this.labels({
7959
8291
  deploymentId: config.deploymentId,
@@ -7962,23 +8294,31 @@ var DockerRuntime = class _DockerRuntime {
7962
8294
  "openship.service": config.serviceName
7963
8295
  },
7964
8296
  ...healthcheck && { Healthcheck: healthcheck },
7965
- ExposedPorts: exposedPorts,
8297
+ ...stopConfig,
8298
+ ...ownsProjectEndpoint ? { ExposedPorts: exposedPorts } : {},
7966
8299
  HostConfig: {
7967
8300
  RestartPolicy: restartPolicy,
7968
8301
  ...dockerResourceLimits(config.resources),
7969
- PortBindings: portBindings,
8302
+ ...ownsProjectEndpoint ? { PortBindings: portBindings } : {},
7970
8303
  Binds: binds,
7971
- NetworkMode: group.id
8304
+ // The project network is the default; a compose `network_mode` replaces it.
8305
+ NetworkMode: sharedNetwork ?? group.id,
8306
+ ...sharedPid ? { PidMode: sharedPid } : {}
7972
8307
  },
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)
8308
+ // A declared network mode means there is no endpoint to name — omit the
8309
+ // block entirely rather than send an empty one (and never contradict
8310
+ // `NetworkMode: none` by attaching a network anyway).
8311
+ ...ownsProjectEndpoint ? {
8312
+ NetworkingConfig: {
8313
+ EndpointsConfig: {
8314
+ [group.id]: {
8315
+ // Default service name plus any operator-chosen aliases; both
8316
+ // resolve to this container on the project network.
8317
+ Aliases: buildNetworkAliases(config.serviceName, config.extraAliases)
8318
+ }
7979
8319
  }
7980
8320
  }
7981
- }
8321
+ } : {}
7982
8322
  });
7983
8323
  try {
7984
8324
  await container.start();
@@ -8034,12 +8374,17 @@ export {
8034
8374
  startExecStream,
8035
8375
  startAttachStream,
8036
8376
  daemonConnectionFrom,
8377
+ DEFAULT_DOCKER_SOCKET_PATH,
8378
+ resolveLocalDockerSocketPath,
8037
8379
  ownsBuiltImage,
8038
8380
  buildInContainerExecCmd,
8039
8381
  resolveExecExitCode,
8040
8382
  buildNetworkAliases,
8041
8383
  parsePortBindings,
8042
8384
  packBuildContext,
8385
+ toDockerHealthcheck,
8386
+ toStopConfig,
8387
+ gracefulStopForGrace,
8043
8388
  splitDockerFrames,
8044
8389
  parseContainerEventLine,
8045
8390
  DockerRuntime