openship 0.6.6 → 0.6.8

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 (72) hide show
  1. package/dist/bare-FA66N7GC.js +29 -0
  2. package/dist/{chunk-77QDF6UD.js → chunk-3MP6ACKM.js} +587 -156
  3. package/dist/{chunk-YIMOCZBZ.js → chunk-5DJOB7IX.js} +3 -3
  4. package/dist/{chunk-WVAML4H6.js → chunk-5I3COAFH.js} +274 -65
  5. package/dist/{chunk-5DMWSTOM.js → chunk-5UXHT6ED.js} +940 -224
  6. package/dist/{chunk-LUKYPVJ4.js → chunk-6GU6JSK6.js} +329 -466
  7. package/dist/{chunk-RSR7PFIQ.js → chunk-7PEXM2UI.js} +213 -45
  8. package/dist/{chunk-VTGPEC6B.js → chunk-7YBWXEM6.js} +6 -4
  9. package/dist/chunk-AHJYP7DG.js +938 -0
  10. package/dist/chunk-BO6CY4V2.js +265 -0
  11. package/dist/{chunk-FE7ZZQSE.js → chunk-D3OEMXYP.js} +3 -3
  12. package/dist/chunk-D6HU4VUZ.js +249 -0
  13. package/dist/{chunk-LFE5KQ7Y.js → chunk-ENKJ7XIF.js} +152 -57
  14. package/dist/{chunk-YMMQRE5J.js → chunk-GNOMEMNI.js} +1 -1
  15. package/dist/{chunk-QVW76WDL.js → chunk-IAMOUDHY.js} +190 -64
  16. package/dist/{chunk-OBPKYXQL.js → chunk-MUWVC5R4.js} +219 -58
  17. package/dist/chunk-N5YZMO33.js +111 -0
  18. package/dist/{chunk-A4GXYE4L.js → chunk-NIYD7MD2.js} +206 -52
  19. package/dist/{chunk-UVNMQM7W.js → chunk-NPSRGIHV.js} +8 -6
  20. package/dist/{chunk-2GWYYHOR.js → chunk-NRU2EBYZ.js} +2 -2
  21. package/dist/{chunk-CBLD2HS5.js → chunk-NUT6HKK6.js} +8 -5
  22. package/dist/{chunk-ZFJXPCQP.js → chunk-P7RCYELG.js} +4 -3
  23. package/dist/chunk-R4VDPFLS.js +106 -0
  24. package/dist/{chunk-HKRQXPTI.js → chunk-R5DC5PEF.js} +1 -1
  25. package/dist/{chunk-NK5N5LXN.js → chunk-RCMERVBL.js} +24 -9
  26. package/dist/{chunk-RXQBD4ZY.js → chunk-RHLNVZMS.js} +2 -2
  27. package/dist/chunk-SK4FFCFR.js +70 -0
  28. package/dist/{chunk-4I2BM2DT.js → chunk-T46BQOLY.js} +3 -3
  29. package/dist/{chunk-6FH4AXVY.js → chunk-T6DVUX4F.js} +1 -1
  30. package/dist/{chunk-PCPVTH6M.js → chunk-TAZZZKYO.js} +6 -4
  31. package/dist/{chunk-MLYSEZQB.js → chunk-UAWY6X7V.js} +43 -72
  32. package/dist/{chunk-Q7WGTQQ5.js → chunk-VAI5VOOI.js} +1 -1
  33. package/dist/{chunk-UJ6JZM65.js → chunk-WEZFTL4V.js} +1325 -513
  34. package/dist/{chunk-2YM3NI2E.js → chunk-X53JXRS4.js} +38 -44
  35. package/dist/{cloud-KRTZ2IL5.js → cloud-2CRDKOWQ.js} +1 -1
  36. package/dist/cloud-REV3DPJH.js +42 -0
  37. package/dist/{detect-OIE5V3VZ.js → detect-BBFT5GEU.js} +9 -6
  38. package/dist/{docker-VTR2GK5Y.js → docker-EG3QSE6I.js} +11 -8
  39. package/dist/{docker-edge-executor-22IBXNYJ.js → docker-edge-executor-E5TOTLMR.js} +8 -6
  40. package/dist/edge-import-YJVJUGYC.js +56 -0
  41. package/dist/{edge-preflight-GBNCPNVU.js → edge-preflight-QUW4BM7N.js} +15 -12
  42. package/dist/{ensure-container-edge-KPI2CVXJ.js → ensure-container-edge-FOEFQ3PI.js} +15 -10
  43. package/dist/{executor-7LXXZPBR.js → executor-NTWDJ3DB.js} +8 -6
  44. package/dist/host-channel-preflight-OMCZMMKR.js +54 -0
  45. package/dist/index.js +198 -76
  46. package/dist/{local-executor-WOFZD72X.js → local-executor-OQ2GRN7K.js} +3 -2
  47. package/dist/{nginx-KUFMLMAC.js → nginx-4U4C2XUS.js} +11 -8
  48. package/dist/{noop-NJ72F46T.js → noop-CLLJHCPL.js} +1 -1
  49. package/dist/{openresty-lua-ATTH35BE.js → openresty-lua-LLWT5XQW.js} +8 -5
  50. package/dist/{privilege-I4W3ZNZK.js → privilege-MNDVMDST.js} +6 -5
  51. package/dist/server/index.js +40049 -30378
  52. package/dist/server/migrations/0109_service_build_args.sql +9 -0
  53. package/dist/server/migrations/0110_service_deployment_host_ports.sql +18 -0
  54. package/dist/server/migrations/0111_host_port_claim.sql +381 -0
  55. package/dist/server/migrations/0112_backup_run_execution_lease.sql +12 -0
  56. package/dist/server/migrations/0113_docker_migration_execution_lease.sql +11 -0
  57. package/dist/server/migrations/0114_build_session_execution_lease_index.sql +6 -0
  58. package/dist/server/migrations/0115_orphan_target_key.sql +3 -0
  59. package/dist/server/migrations/0116_domain_ssl_challenge.sql +2 -0
  60. package/dist/server/migrations/meta/_journal.json +56 -0
  61. package/dist/setup-IFIMPQZJ.js +25 -0
  62. package/dist/{ssh-client-JBMA7SN4.js → ssh-client-2XWZCMGI.js} +3 -2
  63. package/dist/up-HQS7IR7H.js +61 -0
  64. package/package.json +1 -1
  65. package/dist/bare-OXJVW3AK.js +0 -25
  66. package/dist/chunk-LH2PLRQO.js +0 -434
  67. package/dist/chunk-SM26HDS2.js +0 -185
  68. package/dist/cloud-75VIT4KV.js +0 -37
  69. package/dist/edge-import-WJUB4M7F.js +0 -52
  70. package/dist/host-channel-preflight-KE6OONYK.js +0 -50
  71. package/dist/setup-XGLGUUFW.js +0 -22
  72. package/dist/up-K4BV7466.js +0 -57
@@ -3,24 +3,38 @@
3
3
  import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
+ BuildKitTraceDecoder,
6
7
  createDockerBuildContext,
7
- generateDockerfile,
8
+ missingContextDockerfileMessage,
8
9
  prepareSourceTree,
9
- resolveDockerfileCandidates,
10
- resolveServiceDockerfile,
11
- staticBuilderOutputPath
12
- } from "./chunk-LUKYPVJ4.js";
10
+ resolveDockerBuildArgs,
11
+ resolveServiceDockerfile
12
+ } from "./chunk-6GU6JSK6.js";
13
+ import {
14
+ isArtifactPathRef,
15
+ removeManagedArtifact
16
+ } from "./chunk-SK4FFCFR.js";
13
17
  import {
14
18
  BuildCancelledError,
15
19
  BuildLogger,
16
20
  assembleGitClone,
21
+ builderContextRoot,
22
+ dockerBuildContextDirectory,
23
+ droppedRuntimeEnvMessage,
24
+ generateDockerfile,
25
+ isExcludedDocRootEntry,
17
26
  killProcessesUnderDir,
18
27
  materializeGitSsh,
28
+ normalizeDockerRelativePath,
19
29
  parseLogLevel,
30
+ resolveContextDockerfileCandidates,
31
+ resolveDockerfileCandidates,
20
32
  shellGitSshWriter,
33
+ splitRuntimeEnv,
21
34
  sq,
35
+ staticBuilderOutputPath,
22
36
  transferLocalDirectory
23
- } from "./chunk-LH2PLRQO.js";
37
+ } from "./chunk-AHJYP7DG.js";
24
38
  import {
25
39
  DOCKER_DIAL_STDIO_COMMAND,
26
40
  attachDialStdioDiagnostics,
@@ -29,23 +43,28 @@ import {
29
43
  execSshCommand,
30
44
  openDockerDialStdioChannel,
31
45
  openSshUnixSocket
32
- } from "./chunk-PCPVTH6M.js";
46
+ } from "./chunk-TAZZZKYO.js";
33
47
  import {
34
48
  dirOf,
35
49
  ensureOwnedDir
36
- } from "./chunk-UVNMQM7W.js";
50
+ } from "./chunk-NPSRGIHV.js";
51
+ import {
52
+ OPENSHIP_LABEL
53
+ } from "./chunk-D6HU4VUZ.js";
54
+ import {
55
+ isRuntimeNotFoundError
56
+ } from "./chunk-N5YZMO33.js";
37
57
  import {
38
58
  formatCpuCores,
39
59
  formatMemoryMb,
40
60
  hasCpuLimit,
41
61
  hasMemoryLimit,
42
- isRuntimeNotFoundError,
43
62
  ownsNetworkEndpoint,
44
63
  registryConfigKeys,
45
64
  registryForImage,
46
65
  safeErrorMessage,
47
66
  withTimeout
48
- } from "./chunk-UJ6JZM65.js";
67
+ } from "./chunk-WEZFTL4V.js";
49
68
  import {
50
69
  __commonJS,
51
70
  __require,
@@ -4609,6 +4628,7 @@ var tarFs = __toESM(require_tar_fs(), 1);
4609
4628
  import Dockerode from "dockerode";
4610
4629
  import { createGzip } from "zlib";
4611
4630
  import { PassThrough, Writable } from "stream";
4631
+ import { relative, sep } from "path";
4612
4632
 
4613
4633
  // ../../packages/adapters/src/runtime/docker-auth.ts
4614
4634
  import { readFile } from "fs/promises";
@@ -4712,6 +4732,10 @@ var MODE_SUFFIX = /:(ro|rw|z|Z|nocopy)$/;
4712
4732
  function scopedVolumeName(slug, name) {
4713
4733
  return `openship-${slug}-${name}`;
4714
4734
  }
4735
+ function ensureScopedVolumeName(slug, source) {
4736
+ const prefix = `openship-${slug}-`;
4737
+ return source.startsWith(prefix) ? source : scopedVolumeName(slug, source);
4738
+ }
4715
4739
  function isHostPathSource(source) {
4716
4740
  return source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.startsWith("~");
4717
4741
  }
@@ -4931,7 +4955,6 @@ import { join as join2 } from "path";
4931
4955
  import net2 from "net";
4932
4956
  var DEFAULT_REMOTE_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
4933
4957
  var resolvedDockerSocketPathCache = /* @__PURE__ */ new WeakMap();
4934
- var STREAMLOCAL_PROBE_TIMEOUT_MS = 8e3;
4935
4958
  var STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS = 3e3;
4936
4959
  var DIAL_STDIO_ANSWER_TIMEOUT_MS = 3e3;
4937
4960
  var VERIFY_BUFFER_CAP_BYTES = 8 * 1024 * 1024;
@@ -5257,12 +5280,12 @@ function createDockerSshBridge(opts) {
5257
5280
  try {
5258
5281
  probe = await withTimeout(
5259
5282
  openStreamlocalUpstream(opts),
5260
- STREAMLOCAL_PROBE_TIMEOUT_MS,
5261
- `streamlocal open timed out after ${STREAMLOCAL_PROBE_TIMEOUT_MS / 1e3}s`
5283
+ STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS,
5284
+ `streamlocal open timed out after ${STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS / 1e3}s`
5262
5285
  );
5263
5286
  const stream = probe;
5264
5287
  const flowed = await new Promise((resolve) => {
5265
- const timer = setTimeout(() => resolve(false), STREAMLOCAL_PROBE_TIMEOUT_MS);
5288
+ const timer = setTimeout(() => resolve(false), STREAMLOCAL_DATA_VERIFY_TIMEOUT_MS);
5266
5289
  const settle = (ok) => {
5267
5290
  clearTimeout(timer);
5268
5291
  resolve(ok);
@@ -5512,7 +5535,7 @@ function resolveDockerTransport(opts) {
5512
5535
  // ../../packages/adapters/src/runtime/docker.ts
5513
5536
  var isDockerNotFoundError = isRuntimeNotFoundError;
5514
5537
  function ownsBuiltImage(imageRef) {
5515
- return imageRef.startsWith("openship/");
5538
+ return /^openship\/[a-z0-9][a-z0-9._-]*:bld_[A-Za-z0-9_-]+$/.test(imageRef);
5516
5539
  }
5517
5540
  function clampShellWindow(value, fallback, min, max) {
5518
5541
  const n = Number.isFinite(value) ? Number(value) : fallback;
@@ -5580,14 +5603,7 @@ var IN_CONTAINER_EXEC_WATCHDOG = [
5580
5603
  ].join("\n");
5581
5604
  function buildInContainerExecCmd(command, timeoutMs) {
5582
5605
  const seconds = Math.max(1, Math.ceil(timeoutMs / 1e3));
5583
- return [
5584
- "sh",
5585
- "-c",
5586
- IN_CONTAINER_EXEC_WATCHDOG,
5587
- "openship-exec",
5588
- command,
5589
- String(seconds)
5590
- ];
5606
+ return ["sh", "-c", IN_CONTAINER_EXEC_WATCHDOG, "openship-exec", command, String(seconds)];
5591
5607
  }
5592
5608
  async function resolveExecExitCode(exec, label) {
5593
5609
  let info = await exec.inspect();
@@ -5701,9 +5717,10 @@ function parsePortBindings(portSpecs) {
5701
5717
  }
5702
5718
  return { exposedPorts, portBindings };
5703
5719
  }
5704
- function packBuildContext(contextDir, entries, cancelSignal) {
5720
+ function packBuildContext(contextDir, entries, cancelSignal, ignoreContextPath) {
5705
5721
  const controller = new AbortController();
5706
- const pack2 = tarFs.pack(contextDir, { entries });
5722
+ const ignore = ignoreContextPath ? (absolutePath) => ignoreContextPath(relative(contextDir, absolutePath).split(sep).join("/")) : void 0;
5723
+ const pack2 = tarFs.pack(contextDir, { entries, ignore });
5707
5724
  const body = pack2.pipe(createGzip());
5708
5725
  let contextError = null;
5709
5726
  const capture = (err) => {
@@ -5721,7 +5738,7 @@ function packBuildContext(contextDir, entries, cancelSignal) {
5721
5738
  var DURATION_UNITS_NS = {
5722
5739
  ns: 1,
5723
5740
  us: 1e3,
5724
- "\xB5s": 1e3,
5741
+ \u00B5s: 1e3,
5725
5742
  ms: 1e6,
5726
5743
  s: 1e9,
5727
5744
  m: 6e10,
@@ -5829,20 +5846,45 @@ function firstNetworkIp(networks) {
5829
5846
  }
5830
5847
  function extractNetworkInfo(data) {
5831
5848
  let ip;
5832
- for (const net3 of Object.values(data.NetworkSettings.Networks ?? {})) {
5849
+ for (const net3 of Object.values(data.NetworkSettings?.Networks ?? {})) {
5833
5850
  if (net3.IPAddress) {
5834
5851
  ip = net3.IPAddress;
5835
5852
  break;
5836
5853
  }
5837
5854
  }
5855
+ const hostPortByContainerPort = {};
5838
5856
  let hostPort;
5839
- for (const bindings of Object.values(data.NetworkSettings.Ports ?? {})) {
5840
- if (bindings?.[0]?.HostPort) {
5841
- hostPort = parseInt(bindings[0].HostPort, 10);
5842
- break;
5857
+ const liveBindings = data.NetworkSettings?.Ports ?? {};
5858
+ const configuredBindings = data.HostConfig?.PortBindings ?? {};
5859
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(liveBindings), ...Object.keys(configuredBindings)])) {
5860
+ const [containerRaw, protocol = "tcp"] = key.split("/");
5861
+ if (protocol.toLowerCase() !== "tcp") continue;
5862
+ const containerPort = Number(containerRaw);
5863
+ if (!Number.isSafeInteger(containerPort) || containerPort < 1 || containerPort > 65535) {
5864
+ continue;
5865
+ }
5866
+ const live = liveBindings[key];
5867
+ const bindings = live && live.some((binding) => binding?.HostPort) ? live : configuredBindings[key];
5868
+ const candidates = (bindings ?? []).map((binding, index) => {
5869
+ const port = Number(binding?.HostPort);
5870
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return null;
5871
+ const ip2 = binding?.HostIp?.trim().replace(/^\[|\]$/g, "") ?? "";
5872
+ const priority = ip2 === "127.0.0.1" || ip2 === "::1" ? 3 : /^127(?:\.\d{1,3}){3}$/.test(ip2) ? 2 : ip2 && ip2 !== "0.0.0.0" && ip2 !== "::" ? 1 : 0;
5873
+ return { index, port, priority };
5874
+ }).filter(
5875
+ (candidate) => Boolean(candidate)
5876
+ ).sort((left, right) => right.priority - left.priority || left.index - right.index);
5877
+ const selected = candidates[0]?.port;
5878
+ if (selected !== void 0) {
5879
+ hostPortByContainerPort[containerPort] = selected;
5880
+ hostPort ??= selected;
5843
5881
  }
5844
5882
  }
5845
- return { ip, hostPort };
5883
+ return {
5884
+ ip,
5885
+ hostPort,
5886
+ ...Object.keys(hostPortByContainerPort).length ? { hostPortByContainerPort } : {}
5887
+ };
5846
5888
  }
5847
5889
  function parseLoadedImageRef(output) {
5848
5890
  const matches = [...(output || "").matchAll(/Loaded image(?: ID)?:\s*(\S+)/gi)];
@@ -5891,6 +5933,7 @@ var DockerRuntime = class _DockerRuntime {
5891
5933
  name = "docker";
5892
5934
  capabilities = /* @__PURE__ */ new Set([
5893
5935
  "build",
5936
+ "prebuiltImage",
5894
5937
  "deploy",
5895
5938
  "multiServiceDeploy",
5896
5939
  "stop",
@@ -5999,11 +6042,11 @@ var DockerRuntime = class _DockerRuntime {
5999
6042
  /** Labels applied to both build images and deploy containers. */
6000
6043
  labels(config) {
6001
6044
  const l = {
6002
- "openship.project": config.projectId
6045
+ [OPENSHIP_LABEL.project]: config.projectId
6003
6046
  };
6004
- if (config.deploymentId) l["openship.deployment"] = config.deploymentId;
6005
- if (config.sessionId) l["openship.build"] = config.sessionId;
6006
- if (config.service) l["openship.service"] = config.service;
6047
+ if (config.deploymentId) l[OPENSHIP_LABEL.deployment] = config.deploymentId;
6048
+ if (config.sessionId) l[OPENSHIP_LABEL.build] = config.sessionId;
6049
+ if (config.service) l[OPENSHIP_LABEL.service] = config.service;
6007
6050
  return l;
6008
6051
  }
6009
6052
  // ── Build lifecycle ────────────────────────────────────────────────────
@@ -6045,12 +6088,20 @@ var DockerRuntime = class _DockerRuntime {
6045
6088
  logger.log(`[${step}] ${label} ${message}`);
6046
6089
  logger.step(step, status, message);
6047
6090
  }
6048
- handleBuildEvent(event, logger) {
6091
+ handleBuildEvent(event, logger, trace) {
6049
6092
  const errorMessage = event.errorDetail?.message ?? event.error;
6050
6093
  if (errorMessage) {
6051
6094
  logger.log(errorMessage, "error");
6052
6095
  return errorMessage;
6053
6096
  }
6097
+ if (event.id === "moby.buildkit.trace" && typeof event.aux === "string") {
6098
+ let hint = null;
6099
+ for (const line of trace?.push(event.aux) ?? []) {
6100
+ logger.log(line.message, line.level === "error" ? "error" : parseLogLevel(line.message));
6101
+ hint ??= this.extractBuildFailureHint(line.message);
6102
+ }
6103
+ return hint;
6104
+ }
6054
6105
  if (event.stream) {
6055
6106
  const line = event.stream.trim();
6056
6107
  if (!line) return null;
@@ -6107,6 +6158,12 @@ var DockerRuntime = class _DockerRuntime {
6107
6158
  if (/returned a non-zero code:\s*\d+/i.test(line)) {
6108
6159
  return line;
6109
6160
  }
6161
+ if (/requires? BuildKit|BuildKit is enabled but the buildx component/i.test(line)) {
6162
+ return `${line} \u2014 this Dockerfile needs BuildKit. Install the docker buildx plugin on the build host (\`docker-buildx-plugin\`), or remove the BuildKit-only instruction.`;
6163
+ }
6164
+ if (/buildkit (is )?(not (supported|enabled)|disabled)|builder version.*not supported/i.test(line)) {
6165
+ return `${line} \u2014 this Dockerfile needs BuildKit but this Docker daemon has it disabled. Enable it ("features": {"buildkit": true} in daemon.json) or remove the BuildKit-only instruction.`;
6166
+ }
6110
6167
  if (/\/workspace\/package\.json/i.test(line) && /ENOENT/i.test(line)) {
6111
6168
  return "Docker build ran from /workspace but package.json was not found there. The configured rootDirectory is likely empty or incorrect.";
6112
6169
  }
@@ -6158,26 +6215,69 @@ var DockerRuntime = class _DockerRuntime {
6158
6215
  { excludes: [] }
6159
6216
  );
6160
6217
  }
6218
+ /**
6219
+ * Is BuildKit usable on the remote host? Probed, never assumed: a plain `docker`
6220
+ * CLI without the buildx plugin HARD-FAILS when BuildKit is requested explicitly
6221
+ * ("BuildKit is enabled but the buildx component is missing or broken"), while an
6222
+ * unadorned `docker build` on the same box falls back to the legacy builder and
6223
+ * works — the same trap {@link buildImage} in system/managed-image.ts documents.
6224
+ * So we ask first, and only then opt in.
6225
+ *
6226
+ * Memoized per adapter instance (one instance ≈ one target daemon), so a compose
6227
+ * batch pays a single `docker buildx version` for N images.
6228
+ */
6229
+ buildKitProbe = null;
6230
+ async remoteBuildKitAvailable() {
6231
+ const executor = this.connectionOptions?.executor;
6232
+ if (!executor) return false;
6233
+ const probe = this.buildKitProbe ??= executor.exec("docker buildx version >/dev/null 2>&1 && echo OPENSHIP_BUILDKIT_OK || true").then((out) => out.split(/\r?\n/).some((line) => line.trim() === "OPENSHIP_BUILDKIT_OK")).catch(() => false);
6234
+ const available = await probe;
6235
+ if (!available) this.buildKitProbe = null;
6236
+ return available;
6237
+ }
6161
6238
  /**
6162
6239
  * Run native `docker build` on the remote host against an already-transferred
6163
- * context dir. One image; `dockerfileName` selects which Dockerfile in the
6164
- * shared tree to use (so N services can build from one transferred context).
6240
+ * context dir. One image; `dockerfileName` selects which Dockerfile to use (so N
6241
+ * services can build from one transferred tree) and is relative to
6242
+ * `remoteBuildDir` — the build CONTEXT, which for a compose service is its own
6243
+ * `build:` directory inside that tree, not the tree root (#634).
6165
6244
  */
6166
- async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log, signal) {
6245
+ async buildImageOnRemote(config, remoteBuildDir, dockerfileName, tag, log, opts) {
6246
+ const signal = opts?.signal;
6167
6247
  const executor = this.connectionOptions?.executor;
6168
6248
  if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
6169
- const buildArgs = Object.entries({
6170
- ...config.envVars,
6171
- NODE_ENV: "production"
6172
- }).filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)).map(([k, v]) => `--build-arg ${sq(`${k}=${v}`)}`).join(" ");
6249
+ const resolvedBuildArgs = resolveDockerBuildArgs(config);
6250
+ const buildArgs = Object.entries(resolvedBuildArgs).map(([k, v]) => `--build-arg ${sq(`${k}=${v}`)}`).join(" ");
6173
6251
  const labelArgs = Object.entries(
6174
6252
  this.labels({ projectId: config.projectId, sessionId: config.sessionId })
6175
6253
  ).map(([k, v]) => `--label ${sq(`${k}=${v}`)}`).join(" ");
6176
6254
  const dockerfileFlag = dockerfileName && dockerfileName !== "Dockerfile" ? ` -f ${sq(dockerfileName)}` : "";
6177
- const buildCmd = `cd ${sq(remoteContextDir)} && docker build --progress=plain -t ${sq(tag)}${dockerfileFlag} ${labelArgs} ${buildArgs} --force-rm .`;
6178
- log.log(`Running on remote: ${buildCmd}`);
6255
+ const buildKit = await this.remoteBuildKitAvailable();
6256
+ const builderEnv = buildKit ? "DOCKER_BUILDKIT=1 " : "";
6257
+ const builderFlags = buildKit ? " --progress=plain" : " --force-rm";
6258
+ if (buildKit) {
6259
+ log.log("Builder: BuildKit (docker buildx detected on the host)");
6260
+ } else if (opts?.requiresBuildKit) {
6261
+ log.log(
6262
+ "This Dockerfile needs BuildKit, but the build host's docker CLI has no buildx plugin. Install it (`docker-buildx-plugin`, or Docker's official packages) \u2014 the build below will use the legacy builder and is likely to fail.",
6263
+ "warn"
6264
+ );
6265
+ } else {
6266
+ log.log(
6267
+ "Builder: legacy (docker buildx not available on the host) \u2014 BuildKit-only Dockerfile syntax such as `RUN --mount` will not work"
6268
+ );
6269
+ }
6270
+ const buildCmd = `cd ${sq(remoteBuildDir)} && ${builderEnv}docker build${builderFlags} -t ${sq(tag)}${dockerfileFlag} ${labelArgs} ${buildArgs} .`;
6271
+ log.log(
6272
+ `Running Docker build on remote (${Object.keys(resolvedBuildArgs).length} build argument${Object.keys(resolvedBuildArgs).length === 1 ? "" : "s"}; values hidden).`
6273
+ );
6179
6274
  log.log("\u2500\u2500\u2500 docker build output \u2500\u2500\u2500");
6180
- this.emitDockerStep(log, "install", "running", "Running install inside container (docker build)");
6275
+ this.emitDockerStep(
6276
+ log,
6277
+ "install",
6278
+ "running",
6279
+ "Running install inside container (docker build)"
6280
+ );
6181
6281
  const { code } = await executor.streamExec(
6182
6282
  buildCmd,
6183
6283
  (entry) => {
@@ -6238,7 +6338,11 @@ var DockerRuntime = class _DockerRuntime {
6238
6338
  config.gitSsh
6239
6339
  );
6240
6340
  }
6241
- const { cloneUrl, gitEnv: GIT_ENV, credFlag: CRED } = assembleGitClone({
6341
+ const {
6342
+ cloneUrl,
6343
+ gitEnv: GIT_ENV,
6344
+ credFlag: CRED
6345
+ } = assembleGitClone({
6242
6346
  repoUrl: config.repoUrl,
6243
6347
  gitToken: config.gitToken,
6244
6348
  gitCredentialHelperPath: config.gitCredentialHelperPath,
@@ -6264,8 +6368,11 @@ var DockerRuntime = class _DockerRuntime {
6264
6368
  `${GIT_ENV} git ${CRED} clone --progress --depth 50 --branch ${sq(config.branch)} ${sq(cloneUrl)} ${dir} && cd ${dir} && git ${CRED} -c advice.detachedHead=false checkout ${sq(config.commitSha)}`
6265
6369
  );
6266
6370
  } catch {
6267
- log.log(`Commit ${config.commitSha} not in the shallow clone; unshallowing and retrying.
6268
- `, "warn");
6371
+ log.log(
6372
+ `Commit ${config.commitSha} not in the shallow clone; unshallowing and retrying.
6373
+ `,
6374
+ "warn"
6375
+ );
6269
6376
  await run(
6270
6377
  `cd ${dir} && ${GIT_ENV} git ${CRED} fetch --progress --unshallow && git ${CRED} -c advice.detachedHead=false checkout ${sq(config.commitSha)}`
6271
6378
  );
@@ -6286,15 +6393,50 @@ var DockerRuntime = class _DockerRuntime {
6286
6393
  * (clone-on-server). Mirrors resolveServiceDockerfile but probes the remote
6287
6394
  * tree with `test -f` instead of the local FS: a repository Dockerfile
6288
6395
  * candidate is used when present; otherwise a Dockerfile is generated locally
6289
- * (pure fn) and written to the remote tree. Returns the dockerfile path
6290
- * relative to `remoteContextDir`.
6396
+ * (pure fn) and written to the remote tree.
6397
+ *
6398
+ * Returns the build CONTEXT dir on the host plus the Dockerfile path relative to
6399
+ * it — the same pair {@link resolveServiceDockerfile} produces locally, because
6400
+ * these two resolvers are one behaviour with two probes and a narrowed context
6401
+ * learned by only one of them would leave #634 reproducible on this target.
6291
6402
  */
6292
- async resolveRemoteDockerfile(config, remoteContextDir, generatedName, requireRepositoryDockerfile) {
6403
+ async resolveRemoteDockerfile(config, remoteContextDir, generatedName, requireRepositoryDockerfile, log) {
6293
6404
  const executor = this.connectionOptions?.executor;
6294
6405
  if (!executor) throw new Error("Clone-on-server requires an SSH executor on connectionOptions");
6295
- for (const candidate of resolveDockerfileCandidates(config.rootDirectory, config.dockerfilePath)) {
6296
- const out = await executor.exec(`test -f ${sq(`${remoteContextDir}/${candidate}`)} && echo yes || true`).catch(() => "");
6297
- if (out.trim() === "yes") return candidate;
6406
+ const contextSubdir = dockerBuildContextDirectory(config);
6407
+ const remoteBuildDir = contextSubdir ? `${remoteContextDir}/${contextSubdir}` : remoteContextDir;
6408
+ if (contextSubdir) {
6409
+ const real = await executor.exec(`cd ${sq(remoteBuildDir)} 2>/dev/null && pwd -P || true`).then((out) => out.trim()).catch(() => "");
6410
+ const root = await executor.exec(`cd ${sq(remoteContextDir)} && pwd -P`).then((out) => out.trim()).catch(() => "");
6411
+ if (!real) {
6412
+ throw new Error(
6413
+ `Build context "${contextSubdir}" is not a directory in the cloned source. Check the service's build path.`
6414
+ );
6415
+ }
6416
+ if (!root || real !== root && !real.startsWith(`${root}/`)) {
6417
+ throw new Error(`Build context "${contextSubdir}" resolves outside the cloned source.`);
6418
+ }
6419
+ }
6420
+ const candidates = contextSubdir ? resolveContextDockerfileCandidates(contextSubdir, config.dockerfilePath) : resolveDockerfileCandidates(config.rootDirectory, config.dockerfilePath);
6421
+ for (const candidate of candidates) {
6422
+ const out = await executor.exec(`test -f ${sq(`${remoteBuildDir}/${candidate}`)} && echo yes || true`).catch(() => "");
6423
+ if (out.trim() === "yes") {
6424
+ const explicit = normalizeDockerRelativePath(config.dockerfilePath);
6425
+ if (contextSubdir && explicit && candidate === "Dockerfile" && explicit !== "Dockerfile" && explicit !== `${contextSubdir}/Dockerfile`) {
6426
+ log?.log(
6427
+ `Configured Dockerfile "${config.dockerfilePath}" was not found in build context "${contextSubdir}" \u2014 using ${contextSubdir}/Dockerfile instead.`,
6428
+ "warn"
6429
+ );
6430
+ }
6431
+ return { remoteBuildDir, dockerfileName: candidate };
6432
+ }
6433
+ }
6434
+ if (contextSubdir) {
6435
+ throw new Error(
6436
+ missingContextDockerfileMessage(contextSubdir, config.dockerfilePath, {
6437
+ generatedRecipeRefused: !requireRepositoryDockerfile
6438
+ })
6439
+ );
6298
6440
  }
6299
6441
  if (requireRepositoryDockerfile) {
6300
6442
  const expected = config.dockerfilePath?.trim() || "Dockerfile";
@@ -6303,7 +6445,7 @@ var DockerRuntime = class _DockerRuntime {
6303
6445
  );
6304
6446
  }
6305
6447
  await executor.writeFile(`${remoteContextDir}/${generatedName}`, generateDockerfile(config));
6306
- return generatedName;
6448
+ return { remoteBuildDir, dockerfileName: generatedName };
6307
6449
  }
6308
6450
  async buildViaSshTarPipe(config, buildContext, tag, log, signal) {
6309
6451
  const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
@@ -6311,11 +6453,11 @@ var DockerRuntime = class _DockerRuntime {
6311
6453
  await this.transferBuildContext(buildContext.contextDir, remoteContextDir, log);
6312
6454
  await this.buildImageOnRemote(
6313
6455
  config,
6314
- remoteContextDir,
6456
+ buildContext.contextSubdir ? `${remoteContextDir}/${buildContext.contextSubdir}` : remoteContextDir,
6315
6457
  buildContext.dockerfileName,
6316
6458
  tag,
6317
6459
  log,
6318
- signal
6460
+ { signal, requiresBuildKit: buildContext.requiresBuildKit }
6319
6461
  );
6320
6462
  } finally {
6321
6463
  this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
@@ -6323,6 +6465,41 @@ var DockerRuntime = class _DockerRuntime {
6323
6465
  await buildContext.cleanup();
6324
6466
  }
6325
6467
  }
6468
+ /**
6469
+ * Pick the Engine-API builder for ONE dockerode build, for BOTH dockerode call
6470
+ * sites (single image and compose batch) — they are the same decision and a
6471
+ * builder learned by only one of them is the "two executors" trap all over again.
6472
+ *
6473
+ * The classic builder is kept as the DEFAULT deliberately. It is what every
6474
+ * self-hosted local build has always used; switching wholesale would change layer
6475
+ * caching for everyone and silently drop the build CPU/memory caps, which BuildKit
6476
+ * ignores. So BuildKit is opted into only where the classic builder cannot work at
6477
+ * all — a Dockerfile whose syntax it rejects (#634).
6478
+ *
6479
+ * Through the ENGINE API (`version=2`), not by shelling out to `docker build`,
6480
+ * which is the first alternative that comes to mind and is not available: the API
6481
+ * image ships `openssh-client rsync git` and no docker CLI (apps/api/Dockerfile),
6482
+ * and the transport may be a TCP socket with no shell at the other end at all.
6483
+ * Nothing needs the buildx plugin either — BuildKit lives in the daemon.
6484
+ *
6485
+ * Asymmetric with the remote CLI path on purpose: there, BuildKit is taken whenever
6486
+ * buildx exists, because that command passes no --memory/--cpus (so there is no cap
6487
+ * to lose) and on docker ≥23 the CLI already defaults to it — the prefix mostly
6488
+ * buys the log line and the buildx-missing warning.
6489
+ */
6490
+ selectDockerodeBuilder(config, requiresBuildKit, log) {
6491
+ if (!requiresBuildKit) return { options: {} };
6492
+ log.log(
6493
+ "Dockerfile uses BuildKit-only syntax (RUN --mount, a syntax directive or a heredoc) \u2014 building with BuildKit."
6494
+ );
6495
+ if (Object.keys(dockerBuildResourceLimits(config.resources)).length > 0) {
6496
+ log.log(
6497
+ "Build CPU/memory limits are not enforced under BuildKit \u2014 this build runs uncapped.",
6498
+ "warn"
6499
+ );
6500
+ }
6501
+ return { options: { version: "2" }, trace: new BuildKitTraceDecoder() };
6502
+ }
6326
6503
  /**
6327
6504
  * Dockerode build path. Used for local socket and TCP transports, plus
6328
6505
  * SSH transports that didn't get an executor wired in (shouldn't happen
@@ -6335,32 +6512,34 @@ var DockerRuntime = class _DockerRuntime {
6335
6512
  async buildViaDockerode(config, buildContext, tag, log, cancelSignal) {
6336
6513
  log.log(`Streaming build context to Docker daemon - image tag: ${tag}`);
6337
6514
  const { body, abortSignal, takeError } = packBuildContext(
6338
- buildContext.contextDir,
6515
+ buildContext.buildContextDir,
6339
6516
  buildContext.contextEntries,
6340
- cancelSignal
6517
+ cancelSignal,
6518
+ buildContext.ignoreContextPath
6341
6519
  );
6520
+ const builder = this.selectDockerodeBuilder(config, buildContext.requiresBuildKit, log);
6342
6521
  try {
6343
6522
  const stream = await this.docker.buildImage(body, {
6344
6523
  t: tag,
6345
6524
  dockerfile: buildContext.dockerfileName,
6346
6525
  labels: this.labels({ projectId: config.projectId, sessionId: config.sessionId }),
6347
- buildargs: {
6348
- ...config.envVars,
6349
- NODE_ENV: "production"
6350
- },
6526
+ buildargs: resolveDockerBuildArgs(config),
6351
6527
  // Omitted entirely unless the project set a build cap — a self-hosted
6352
6528
  // build should be free to use the machine (a production build often
6353
6529
  // needs several GB). Opt-in only; see dockerBuildResourceLimits.
6354
6530
  ...dockerBuildResourceLimits(config.resources),
6355
6531
  forcerm: true,
6532
+ ...builder.options,
6356
6533
  abortSignal
6357
6534
  });
6358
6535
  log.log("Connected to Docker daemon. Build output follows:");
6359
- await this.streamDockerodeBuild(stream, log);
6536
+ await this.streamDockerodeBuild(stream, log, builder.trace);
6360
6537
  log.log("Docker daemon finished streaming build output. Finalizing image...\n");
6361
6538
  } catch (err) {
6362
6539
  const contextErr = takeError();
6363
- throw contextErr ? new Error(`Failed to read Docker build context: ${safeErrorMessage(contextErr)}`, { cause: contextErr }) : err;
6540
+ throw contextErr ? new Error(`Failed to read Docker build context: ${safeErrorMessage(contextErr)}`, {
6541
+ cause: contextErr
6542
+ }) : err;
6364
6543
  } finally {
6365
6544
  await buildContext.cleanup();
6366
6545
  }
@@ -6370,7 +6549,7 @@ var DockerRuntime = class _DockerRuntime {
6370
6549
  * idle timeout + keepalive, and throw on a fatal build event. Shared by the
6371
6550
  * single-image dockerode path and the local batch build (buildImages).
6372
6551
  */
6373
- async streamDockerodeBuild(stream, log) {
6552
+ async streamDockerodeBuild(stream, log, trace) {
6374
6553
  let fatalBuildError = null;
6375
6554
  await new Promise((resolve, reject) => {
6376
6555
  let settled = false;
@@ -6409,9 +6588,11 @@ var DockerRuntime = class _DockerRuntime {
6409
6588
  }, 6e4);
6410
6589
  if (keepaliveTimer.unref) keepaliveTimer.unref();
6411
6590
  idleTimer = setTimeout(() => {
6412
- fail(new Error(
6413
- "Docker build produced no output for 30 minutes. This usually means the remote server cannot reach the package registry, has broken DNS, or the Docker daemon stalled during the build."
6414
- ));
6591
+ fail(
6592
+ new Error(
6593
+ "Docker build produced no output for 30 minutes. This usually means the remote server cannot reach the package registry, has broken DNS, or the Docker daemon stalled during the build."
6594
+ )
6595
+ );
6415
6596
  }, DOCKER_BUILD_IDLE_TIMEOUT_MS);
6416
6597
  if (idleTimer.unref) idleTimer.unref();
6417
6598
  };
@@ -6427,7 +6608,7 @@ var DockerRuntime = class _DockerRuntime {
6427
6608
  },
6428
6609
  (event) => {
6429
6610
  resetIdleTimer();
6430
- fatalBuildError ??= this.handleBuildEvent(event, log);
6611
+ fatalBuildError ??= this.handleBuildEvent(event, log, trace);
6431
6612
  }
6432
6613
  );
6433
6614
  });
@@ -6496,20 +6677,16 @@ var DockerRuntime = class _DockerRuntime {
6496
6677
  await this.cloneSourceOnRemote(config, remoteContextDir, log);
6497
6678
  if (abort.signal.aborted) throw new BuildCancelledError();
6498
6679
  this.emitDockerStep(log, "clone", "completed", "Source cloned on the server");
6499
- const dockerfileName = await this.resolveRemoteDockerfile(
6680
+ const { remoteBuildDir, dockerfileName } = await this.resolveRemoteDockerfile(
6500
6681
  config,
6501
6682
  remoteContextDir,
6502
6683
  "Dockerfile.openship",
6503
- config.stack === "docker"
6504
- );
6505
- await this.buildImageOnRemote(
6506
- config,
6507
- remoteContextDir,
6508
- dockerfileName,
6509
- tag,
6510
- log,
6511
- abort.signal
6684
+ config.stack === "docker",
6685
+ log
6512
6686
  );
6687
+ await this.buildImageOnRemote(config, remoteBuildDir, dockerfileName, tag, log, {
6688
+ signal: abort.signal
6689
+ });
6513
6690
  } finally {
6514
6691
  sshExecutor.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6515
6692
  });
@@ -6519,7 +6696,12 @@ var DockerRuntime = class _DockerRuntime {
6519
6696
  log.log(`Image ${tag} is ready.
6520
6697
  `);
6521
6698
  log.step("build", "completed", `Finalizing image ${tag}`);
6522
- return { sessionId: config.sessionId, status: "deploying", imageRef: tag, durationMs: Date.now() - startTime };
6699
+ return {
6700
+ sessionId: config.sessionId,
6701
+ status: "deploying",
6702
+ imageRef: tag,
6703
+ durationMs: Date.now() - startTime
6704
+ };
6523
6705
  }
6524
6706
  this.emitDockerStep(log, "clone", "running", "Preparing Docker build context...");
6525
6707
  const buildContext = await createDockerBuildContext(config, {
@@ -6530,16 +6712,13 @@ var DockerRuntime = class _DockerRuntime {
6530
6712
  try {
6531
6713
  const sizeBytes = await this.estimateContextSize(buildContext.contextDir);
6532
6714
  const sizeMB = (sizeBytes / 1024 / 1024).toFixed(1);
6533
- this.emitDockerStep(
6534
- log,
6535
- "clone",
6536
- "completed",
6537
- `Docker build context ready (${sizeMB} MB)`
6538
- );
6715
+ this.emitDockerStep(log, "clone", "completed", `Docker build context ready (${sizeMB} MB)`);
6539
6716
  } catch {
6540
6717
  this.emitDockerStep(log, "clone", "completed", "Docker build context ready");
6541
6718
  }
6542
- if (buildContext.rootDirectory) {
6719
+ if (buildContext.contextSubdir) {
6720
+ log.log(`Using Docker build context: ${buildContext.contextSubdir}`);
6721
+ } else if (buildContext.rootDirectory) {
6543
6722
  log.log(`Using Docker build root: ${buildContext.rootDirectory}`);
6544
6723
  }
6545
6724
  if (buildContext.usesRepositoryDockerfile) {
@@ -6579,7 +6758,75 @@ var DockerRuntime = class _DockerRuntime {
6579
6758
  if (abort.signal.aborted || err instanceof BuildCancelledError) return cancelled();
6580
6759
  const msg = safeErrorMessage(err);
6581
6760
  log.step("build", "failed", `Docker build failed: ${msg}`);
6582
- return { sessionId: config.sessionId, status: "failed", durationMs: Date.now() - startTime, errorMessage: `Docker build failed: ${msg}` };
6761
+ return {
6762
+ sessionId: config.sessionId,
6763
+ status: "failed",
6764
+ durationMs: Date.now() - startTime,
6765
+ errorMessage: `Docker build failed: ${msg}`
6766
+ };
6767
+ } finally {
6768
+ releaseDockerBuild(config.sessionId, abort);
6769
+ }
6770
+ }
6771
+ /**
6772
+ * Acquire a registry image without ever treating it as a Dockerfile builder.
6773
+ *
6774
+ * The returned digest is preferred over the requested tag so a later deploy
6775
+ * runs exactly the bytes acquired here even if a mutable tag moves between
6776
+ * prepare and deploy. The image is deliberately non-owned: it came from a
6777
+ * registry and may be shared by unrelated containers on the target daemon, so
6778
+ * deployment cleanup must never remove it as if Openship had built it.
6779
+ */
6780
+ async prepareImage(config, logger) {
6781
+ const log = logger ?? new BuildLogger();
6782
+ const startTime = Date.now();
6783
+ const abort = registerDockerBuild(config.sessionId);
6784
+ const cancelled = () => {
6785
+ log.step("build", "failed", "Image pull cancelled");
6786
+ return {
6787
+ sessionId: config.sessionId,
6788
+ status: "cancelled",
6789
+ durationMs: Date.now() - startTime,
6790
+ artifactOwned: false
6791
+ };
6792
+ };
6793
+ try {
6794
+ if (abort.signal.aborted) return cancelled();
6795
+ const requestedRef = config.imageRef.trim();
6796
+ if (!requestedRef) throw new Error("Prebuilt image reference is required");
6797
+ log.step("build", "running", `Pulling prebuilt image ${requestedRef}`);
6798
+ try {
6799
+ await this.ensureDockerFeature(log);
6800
+ } catch (featureErr) {
6801
+ throw new Error(this.formatDockerConnectivityError(featureErr));
6802
+ }
6803
+ await this.pullImage(requestedRef, { force: config.forcePull });
6804
+ if (abort.signal.aborted) return cancelled();
6805
+ const deployRef = await this.resolveImageDigest(requestedRef).catch(() => void 0) ?? requestedRef;
6806
+ log.log(
6807
+ deployRef === requestedRef ? `Image ${requestedRef} is ready.
6808
+ ` : `Image ${requestedRef} is ready as ${deployRef}.
6809
+ `
6810
+ );
6811
+ log.step("build", "completed", `Prebuilt image ${deployRef} is ready`);
6812
+ return {
6813
+ sessionId: config.sessionId,
6814
+ status: "deploying",
6815
+ imageRef: deployRef,
6816
+ durationMs: Date.now() - startTime,
6817
+ artifactOwned: false
6818
+ };
6819
+ } catch (err) {
6820
+ if (abort.signal.aborted) return cancelled();
6821
+ const message = safeErrorMessage(err);
6822
+ log.step("build", "failed", `Image pull failed: ${message}`);
6823
+ return {
6824
+ sessionId: config.sessionId,
6825
+ status: "failed",
6826
+ durationMs: Date.now() - startTime,
6827
+ errorMessage: `Image pull failed: ${message}`,
6828
+ artifactOwned: false
6829
+ };
6583
6830
  } finally {
6584
6831
  releaseDockerBuild(config.sessionId, abort);
6585
6832
  }
@@ -6608,6 +6855,7 @@ var DockerRuntime = class _DockerRuntime {
6608
6855
  await this.extractDocRootViaDaemon(tag, docRoot, hostOutDir);
6609
6856
  }
6610
6857
  }
6858
+ await this.pruneBuildFilesFromDocRoot(config, hostOutDir, sshExecutor ?? null, log);
6611
6859
  log.log(`Static files ready at ${hostOutDir}
6612
6860
  `);
6613
6861
  } finally {
@@ -6692,6 +6940,74 @@ var DockerRuntime = class _DockerRuntime {
6692
6940
  * expensive one: every extractor can succeed having copied nothing, which deploys
6693
6941
  * green and then 404s the entire site.
6694
6942
  */
6943
+ /**
6944
+ * Names that must never end up in a public doc-root, matched at its TOP LEVEL
6945
+ * only.
6946
+ *
6947
+ * `.git` is the repository — serving it publishes full source history. The
6948
+ * Dockerfiles and `.dockerignore` are OUR build inputs, and one of them is
6949
+ * literally written into the tree by `resolveServiceDockerfile`.
6950
+ */
6951
+ static DOC_ROOT_EXCLUDED = [
6952
+ ".dockerignore",
6953
+ ".git",
6954
+ "Dockerfile",
6955
+ "Dockerfile.*"
6956
+ ];
6957
+ /**
6958
+ * Remove build inputs from an extracted static doc-root, then re-assert the
6959
+ * contract that it still holds something servable.
6960
+ *
6961
+ * Only runs when the doc-root IS the build context — `outputDirectory` empty or
6962
+ * "." — which is the zero-build "just files, index.html at the root" project. A
6963
+ * real output directory (`dist`, `build`) is written by the build and contains
6964
+ * none of this, so the prune is a no-op there.
6965
+ *
6966
+ * Two distinct problems, one fix. The build context is pruned by `.dockerignore`
6967
+ * and THEN the generated recipe is written into it, so for these projects the
6968
+ * published site contained `.dockerignore` + `Dockerfile.openship…` — and any
6969
+ * tracked `.git` — at their own public URLs. Worse, those files count as
6970
+ * content: a repo with an allowlist `.dockerignore` (`*` + `!src`) extracted to
6971
+ * a tree holding ONLY the two build files, which passed the non-empty check and
6972
+ * deployed green as a site that 404s every request. Pruning first and
6973
+ * re-checking after is what makes that check mean "there is something to serve".
6974
+ */
6975
+ async pruneBuildFilesFromDocRoot(config, hostOutDir, sshExecutor, log) {
6976
+ if (staticBuilderOutputPath(config) !== builderContextRoot(config)) return;
6977
+ if (sshExecutor) {
6978
+ const globs = _DockerRuntime.DOC_ROOT_EXCLUDED.map((name) => `${sq(hostOutDir)}/${name}`).join(
6979
+ " "
6980
+ );
6981
+ await sshExecutor.exec(`rm -rf ${globs}`).catch(() => {
6982
+ });
6983
+ log.log(`Excluded build files from the published output (${hostOutDir}).
6984
+ `);
6985
+ const listing = (await sshExecutor.exec(`ls -A ${sq(hostOutDir)} | head -1`).catch(() => "x")).trim();
6986
+ this.assertDocRootServable(!listing, hostOutDir);
6987
+ return;
6988
+ }
6989
+ const { readdir, rm } = await import("fs/promises");
6990
+ const { join: join3 } = await import("path");
6991
+ const entries = await readdir(hostOutDir).catch(() => null);
6992
+ if (entries === null) return;
6993
+ const doomed = entries.filter((name) => isExcludedDocRootEntry(name));
6994
+ for (const name of doomed) {
6995
+ await rm(join3(hostOutDir, name), { recursive: true, force: true }).catch(() => {
6996
+ });
6997
+ }
6998
+ if (doomed.length > 0) {
6999
+ log.log(`Excluded build files from the published output: ${doomed.join(", ")}
7000
+ `);
7001
+ }
7002
+ this.assertDocRootServable(entries.length === doomed.length, hostOutDir);
7003
+ }
7004
+ /** Post-prune half of the doc-root contract: build inputs are not content. */
7005
+ assertDocRootServable(isEmpty, hostOutDir) {
7006
+ if (!isEmpty) return;
7007
+ throw new Error(
7008
+ `the static output at ${hostOutDir} contains only build files (Dockerfile / .dockerignore) and nothing to serve \u2014 check the project's output directory setting and its .dockerignore.`
7009
+ );
7010
+ }
6695
7011
  assertDocRootFilled(isEmpty, docRoot) {
6696
7012
  if (!isEmpty) return;
6697
7013
  throw new Error(
@@ -6783,7 +7099,9 @@ var DockerRuntime = class _DockerRuntime {
6783
7099
  `static extract failed copying ${docRoot} out of the build container (exit ${status.StatusCode})${logs ? `: ${logs.slice(-500)}` : ""}`
6784
7100
  );
6785
7101
  }
6786
- const entries = (await readdir(hostOutDir).catch(() => [])).filter((e) => e !== probe);
7102
+ const entries = (await readdir(hostOutDir).catch(() => [])).filter(
7103
+ (e) => e !== probe
7104
+ );
6787
7105
  this.assertDocRootFilled(entries.length === 0, docRoot);
6788
7106
  return true;
6789
7107
  } finally {
@@ -6878,7 +7196,25 @@ var DockerRuntime = class _DockerRuntime {
6878
7196
  prepareLogger.step("clone", "completed", "Source cloned on the server");
6879
7197
  } else {
6880
7198
  prepareLogger.step("clone", "running", "Preparing shared build context...");
6881
- tree = await prepareSourceTree(source, { onLog: prepareLogger.callback });
7199
+ tree = await prepareSourceTree(source, {
7200
+ onLog: prepareLogger.callback,
7201
+ // Any service building from its OWN `build:` directory makes the source
7202
+ // root's `.dockerignore` the wrong file to prune this SHARED tree with —
7203
+ // it would delete files the other contexts need. Those builds filter their
7204
+ // own context instead; see SourceTree.dockerignorePruned.
7205
+ //
7206
+ // A malformed value throws here; `true` is the safe answer for it, because
7207
+ // it only skips a destructive prune. Letting the throw out would fail the
7208
+ // WHOLE batch, while per-spec resolution below reports it against the one
7209
+ // service that owns it.
7210
+ perServiceBuildContexts: specs.some((spec) => {
7211
+ try {
7212
+ return Boolean(dockerBuildContextDirectory(spec.config));
7213
+ } catch {
7214
+ return true;
7215
+ }
7216
+ })
7217
+ });
6882
7218
  }
6883
7219
  const resolvedList = await Promise.all(
6884
7220
  specs.map(async (spec) => {
@@ -6886,34 +7222,54 @@ var DockerRuntime = class _DockerRuntime {
6886
7222
  const requireRepo = spec.requireRepositoryDockerfile ?? spec.config.stack === "docker";
6887
7223
  try {
6888
7224
  if (cloneOnServer) {
6889
- const dockerfileName = await this.resolveRemoteDockerfile(
7225
+ const { remoteBuildDir, dockerfileName } = await this.resolveRemoteDockerfile(
6890
7226
  spec.config,
6891
7227
  remoteContextDir,
6892
7228
  generatedName,
6893
- requireRepo
7229
+ requireRepo,
7230
+ spec.logger
6894
7231
  );
6895
7232
  return {
6896
7233
  spec,
6897
7234
  dockerfileName,
7235
+ remoteBuildSubdir: dockerBuildContextDirectory(spec.config),
7236
+ remoteBuildDir,
7237
+ buildContextDir: null,
6898
7238
  contextEntries: null,
7239
+ ignoreContextPath: void 0,
7240
+ // Only the dockerode branch consults it, and clone-on-server never
7241
+ // reaches that branch (it is SSH by construction).
7242
+ requiresBuildKit: false,
6899
7243
  error: null
6900
7244
  };
6901
7245
  }
6902
7246
  const resolved = await resolveServiceDockerfile(tree.contextDir, spec.config, {
6903
7247
  requireRepositoryDockerfile: requireRepo,
6904
- generatedName
7248
+ generatedName,
7249
+ dockerignorePruned: tree.dockerignorePruned,
7250
+ onLog: spec.logger.callback
6905
7251
  });
6906
7252
  return {
6907
7253
  spec,
6908
7254
  dockerfileName: resolved.dockerfileName,
7255
+ remoteBuildSubdir: resolved.contextSubdir,
7256
+ remoteBuildDir: resolved.contextSubdir ? `${remoteContextDir}/${resolved.contextSubdir}` : remoteContextDir,
7257
+ buildContextDir: resolved.buildContextDir,
6909
7258
  contextEntries: resolved.contextEntries,
7259
+ ignoreContextPath: resolved.ignoreContextPath,
7260
+ requiresBuildKit: resolved.requiresBuildKit,
6910
7261
  error: null
6911
7262
  };
6912
7263
  } catch (err) {
6913
7264
  return {
6914
7265
  spec,
6915
7266
  dockerfileName: null,
7267
+ remoteBuildSubdir: "",
7268
+ remoteBuildDir: remoteContextDir,
7269
+ buildContextDir: null,
6916
7270
  contextEntries: null,
7271
+ ignoreContextPath: void 0,
7272
+ requiresBuildKit: false,
6917
7273
  error: safeErrorMessage(err)
6918
7274
  };
6919
7275
  }
@@ -6935,7 +7291,17 @@ var DockerRuntime = class _DockerRuntime {
6935
7291
  }
6936
7292
  }
6937
7293
  const results = [];
6938
- for (const { spec, dockerfileName, contextEntries, error } of resolvedList) {
7294
+ for (const {
7295
+ spec,
7296
+ dockerfileName,
7297
+ remoteBuildDir,
7298
+ remoteBuildSubdir,
7299
+ buildContextDir,
7300
+ contextEntries,
7301
+ ignoreContextPath,
7302
+ requiresBuildKit,
7303
+ error
7304
+ } of resolvedList) {
6939
7305
  const startedAt = Date.now();
6940
7306
  const tag = this.imageTag(spec.config.slug, spec.config.sessionId);
6941
7307
  const signal = abortControllers.get(spec.config.sessionId)?.signal;
@@ -6958,21 +7324,27 @@ var DockerRuntime = class _DockerRuntime {
6958
7324
  }
6959
7325
  spec.onStart?.();
6960
7326
  try {
7327
+ if (remoteBuildSubdir) {
7328
+ spec.logger.log(`Using Docker build context: ${remoteBuildSubdir}
7329
+ `);
7330
+ }
6961
7331
  if (isSsh) {
6962
7332
  await this.buildImageOnRemote(
6963
7333
  spec.config,
6964
- remoteContextDir,
7334
+ remoteBuildDir,
6965
7335
  dockerfileName,
6966
7336
  tag,
6967
7337
  spec.logger,
6968
- signal
7338
+ { signal, requiresBuildKit }
6969
7339
  );
6970
7340
  } else {
6971
7341
  const { body, abortSignal, takeError } = packBuildContext(
6972
- tree.contextDir,
7342
+ buildContextDir ?? tree.contextDir,
6973
7343
  contextEntries ?? [],
6974
- signal
7344
+ signal,
7345
+ ignoreContextPath
6975
7346
  );
7347
+ const builder = this.selectDockerodeBuilder(spec.config, requiresBuildKit, spec.logger);
6976
7348
  try {
6977
7349
  const stream = await this.docker.buildImage(body, {
6978
7350
  t: tag,
@@ -6981,15 +7353,19 @@ var DockerRuntime = class _DockerRuntime {
6981
7353
  projectId: spec.config.projectId,
6982
7354
  sessionId: spec.config.sessionId
6983
7355
  }),
6984
- buildargs: { ...spec.config.envVars, NODE_ENV: "production" },
7356
+ buildargs: resolveDockerBuildArgs(spec.config),
6985
7357
  ...dockerBuildResourceLimits(spec.config.resources),
6986
7358
  forcerm: true,
7359
+ ...builder.options,
6987
7360
  abortSignal
6988
7361
  });
6989
- await this.streamDockerodeBuild(stream, spec.logger);
7362
+ await this.streamDockerodeBuild(stream, spec.logger, builder.trace);
6990
7363
  } catch (err) {
6991
7364
  const contextErr = takeError();
6992
- throw contextErr ? new Error(`Failed to read Docker build context: ${safeErrorMessage(contextErr)}`, { cause: contextErr }) : err;
7365
+ throw contextErr ? new Error(
7366
+ `Failed to read Docker build context: ${safeErrorMessage(contextErr)}`,
7367
+ { cause: contextErr }
7368
+ ) : err;
6993
7369
  }
6994
7370
  }
6995
7371
  if (isCancelled(spec.config.sessionId)) {
@@ -7072,10 +7448,10 @@ var DockerRuntime = class _DockerRuntime {
7072
7448
  }
7073
7449
  const containers = await this.docker.listContainers({
7074
7450
  all: true,
7075
- filters: { label: ["openship.build"] }
7451
+ filters: { label: [OPENSHIP_LABEL.build] }
7076
7452
  });
7077
7453
  for (const c of containers) {
7078
- const buildId = c.Labels?.["openship.build"];
7454
+ const buildId = c.Labels?.[OPENSHIP_LABEL.build];
7079
7455
  if (!buildId || !cancelCovers(sessionId, buildId)) continue;
7080
7456
  try {
7081
7457
  await this.docker.getContainer(c.Id).remove({ force: true });
@@ -7096,10 +7472,18 @@ var DockerRuntime = class _DockerRuntime {
7096
7472
  throw new Error("Docker deploy requires an imageRef (built image tag)");
7097
7473
  }
7098
7474
  const containerName = `openship-${config.runtimeName || config.projectId}-${config.deploymentId}`;
7475
+ const projectEnv = splitRuntimeEnv(config.envVars);
7476
+ if (projectEnv.dropped.length > 0) {
7477
+ log({
7478
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7479
+ level: "warn",
7480
+ message: droppedRuntimeEnvMessage(projectEnv.dropped)
7481
+ });
7482
+ }
7099
7483
  const env = [
7100
7484
  ...config.portless ? [] : [`PORT=${config.port}`],
7101
7485
  `NODE_ENV=${config.environment === "production" ? "production" : "development"}`,
7102
- ...Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`)
7486
+ ...projectEnv.entries.map(([k, v]) => `${k}=${v}`)
7103
7487
  ];
7104
7488
  const cmd = config.startCommand ? ["sh", "-c", config.startCommand] : void 0;
7105
7489
  const restartPolicy = resolveRestartPolicy(config.restartPolicy);
@@ -7217,6 +7601,11 @@ var DockerRuntime = class _DockerRuntime {
7217
7601
  await container.restart();
7218
7602
  }
7219
7603
  async removeImage(imageRef) {
7604
+ if (isArtifactPathRef(imageRef)) {
7605
+ throw new Error(
7606
+ `"${imageRef}" is a host directory, not an image reference \u2014 remove it with destroy().`
7607
+ );
7608
+ }
7220
7609
  const image = this.docker.getImage(imageRef);
7221
7610
  try {
7222
7611
  await image.remove({ force: true });
@@ -7225,16 +7614,18 @@ var DockerRuntime = class _DockerRuntime {
7225
7614
  }
7226
7615
  }
7227
7616
  async destroy(containerId) {
7228
- if (containerId.startsWith("/")) {
7229
- const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
7230
- if (executor) {
7231
- await executor.exec(`rm -rf ${sq(containerId)}`).catch(() => {
7232
- });
7233
- } else {
7234
- const { rm } = await import("fs/promises");
7235
- await rm(containerId, { recursive: true, force: true }).catch(() => {
7236
- });
7617
+ if (isArtifactPathRef(containerId)) {
7618
+ if (this.transport.kind === "ssh") {
7619
+ const executor = this.connectionOptions?.executor;
7620
+ if (!executor) {
7621
+ throw new Error(
7622
+ `Cannot remove ${containerId}: the SSH transport has no command executor, and removing it locally would delete the wrong machine's files.`
7623
+ );
7624
+ }
7625
+ await removeManagedArtifact(executor, containerId);
7626
+ return;
7237
7627
  }
7628
+ await removeManagedArtifact(null, containerId);
7238
7629
  return;
7239
7630
  }
7240
7631
  const container = this.docker.getContainer(containerId);
@@ -7274,8 +7665,8 @@ var DockerRuntime = class _DockerRuntime {
7274
7665
  return images.map((img) => ({
7275
7666
  id: img.Id,
7276
7667
  repoTags: (img.RepoTags ?? []).filter((t) => t && t !== "<none>:<none>"),
7277
- buildId: img.Labels?.["openship.build"],
7278
- deploymentId: img.Labels?.["openship.deployment"],
7668
+ buildId: img.Labels?.[OPENSHIP_LABEL.build],
7669
+ deploymentId: img.Labels?.[OPENSHIP_LABEL.deployment],
7279
7670
  size: img.Size ?? 0
7280
7671
  }));
7281
7672
  }
@@ -7320,7 +7711,7 @@ var DockerRuntime = class _DockerRuntime {
7320
7711
  return containers.map((c) => ({
7321
7712
  containerId: c.Id,
7322
7713
  status: stateMap[(c.State ?? "").toLowerCase().trim()] ?? "stopped",
7323
- serviceName: c.Labels?.["openship.service"]
7714
+ serviceName: c.Labels?.[OPENSHIP_LABEL.service]
7324
7715
  }));
7325
7716
  }
7326
7717
  // ── Rollback primitives (retention half only) ────────────────────────
@@ -7349,17 +7740,18 @@ var DockerRuntime = class _DockerRuntime {
7349
7740
  }
7350
7741
  }
7351
7742
  async purge(deployment) {
7743
+ const failures = [];
7352
7744
  if (deployment.containerId) {
7353
- try {
7354
- await this.destroy(deployment.containerId);
7355
- } catch {
7356
- }
7745
+ await this.destroy(deployment.containerId).catch((err) => failures.push(err));
7357
7746
  }
7358
7747
  if (deployment.imageRef && ownsBuiltImage(deployment.imageRef)) {
7359
- try {
7360
- await this.removeImage(deployment.imageRef);
7361
- } catch {
7362
- }
7748
+ await this.removeImage(deployment.imageRef).catch((err) => failures.push(err));
7749
+ }
7750
+ if (failures.length === 1) throw failures[0];
7751
+ if (failures.length > 1) {
7752
+ throw new Error(
7753
+ failures.map((err) => err instanceof Error ? err.message : String(err)).join("; ")
7754
+ );
7363
7755
  }
7364
7756
  }
7365
7757
  /**
@@ -7367,7 +7759,9 @@ var DockerRuntime = class _DockerRuntime {
7367
7759
  * the ones that survive `container.remove()` and would otherwise leak.
7368
7760
  * Anonymous volumes are auto-removed with `{ v: true }` and don't need to
7369
7761
  * be enumerated. Bind mounts and tmpfs are skipped (the user manages them
7370
- * outside our control). Returns [] if the container is already gone.
7762
+ * outside our control). Returns [] only if the container is already gone;
7763
+ * transport and permission failures propagate so an explicit volume wipe
7764
+ * never destroys the last copy of its mount inventory.
7371
7765
  */
7372
7766
  async inspectNamedVolumes(containerId) {
7373
7767
  try {
@@ -7375,16 +7769,18 @@ var DockerRuntime = class _DockerRuntime {
7375
7769
  const data = await container.inspect();
7376
7770
  const mounts = data.Mounts ?? [];
7377
7771
  return mounts.filter((m) => m.Type === "volume" && typeof m.Name === "string" && m.Name.length > 0).map((m) => m.Name);
7378
- } catch {
7379
- return [];
7772
+ } catch (err) {
7773
+ if (isDockerNotFoundError(err)) return [];
7774
+ throw err;
7380
7775
  }
7381
7776
  }
7382
- /** Remove a named volume by name. Best-effort - already-gone is fine. */
7777
+ /** Remove a named volume by name. Already-gone is idempotent success. */
7383
7778
  async removeVolume(name) {
7384
7779
  try {
7385
7780
  const volume = this.docker.getVolume(name);
7386
7781
  await volume.remove({ force: true });
7387
- } catch {
7782
+ } catch (err) {
7783
+ if (!isDockerNotFoundError(err)) throw err;
7388
7784
  }
7389
7785
  }
7390
7786
  // ── Docker discovery (label-agnostic) ────────────────────────────────────
@@ -7598,7 +7994,8 @@ var DockerRuntime = class _DockerRuntime {
7598
7994
  const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
7599
7995
  if (executor?.execWithInput) {
7600
7996
  const { code, stderr, stdout } = await executor.execWithInput(`docker load`, body);
7601
- if (code !== 0) throw new Error(`docker load exited ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
7997
+ if (code !== 0)
7998
+ throw new Error(`docker load exited ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
7602
7999
  return parseLoadedImageRef(stdout);
7603
8000
  }
7604
8001
  const stream = await this.docker.loadImage(body);
@@ -7677,7 +8074,7 @@ var DockerRuntime = class _DockerRuntime {
7677
8074
  };
7678
8075
  const startedAt = data.State.StartedAt;
7679
8076
  const uptimeSeconds = startedAt && data.State.Running ? Math.floor((Date.now() - new Date(startedAt).getTime()) / 1e3) : void 0;
7680
- const { ip, hostPort } = extractNetworkInfo(data);
8077
+ const { ip, hostPort, hostPortByContainerPort } = extractNetworkInfo(data);
7681
8078
  let status;
7682
8079
  if (data.State.Running) {
7683
8080
  status = "running";
@@ -7692,6 +8089,7 @@ var DockerRuntime = class _DockerRuntime {
7692
8089
  status,
7693
8090
  ip,
7694
8091
  hostPort,
8092
+ ...hostPortByContainerPort ? { hostPortByContainerPort } : {},
7695
8093
  uptimeSeconds: uptimeSeconds && uptimeSeconds > 0 ? uptimeSeconds : void 0
7696
8094
  };
7697
8095
  }
@@ -7855,7 +8253,11 @@ var DockerRuntime = class _DockerRuntime {
7855
8253
  });
7856
8254
  stream.on("end", () => {
7857
8255
  if (buffer && !destroyed) {
7858
- onLog({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), message: buffer, level: parseLogLevel(buffer) });
8256
+ onLog({
8257
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8258
+ message: buffer,
8259
+ level: parseLogLevel(buffer)
8260
+ });
7859
8261
  buffer = "";
7860
8262
  }
7861
8263
  });
@@ -8070,15 +8472,9 @@ var DockerRuntime = class _DockerRuntime {
8070
8472
  async inContainerExecutor(containerId) {
8071
8473
  return {
8072
8474
  exec: async (command, opts) => {
8073
- const { exitCode, stdout, stderr } = await this.execInContainer(
8074
- containerId,
8075
- command,
8076
- opts
8077
- );
8475
+ const { exitCode, stdout, stderr } = await this.execInContainer(containerId, command, opts);
8078
8476
  if (exitCode !== 0) {
8079
- throw new Error(
8080
- stdout.trim() || stderr.trim() || `exec exited with code ${exitCode}`
8081
- );
8477
+ throw new Error(stdout.trim() || stderr.trim() || `exec exited with code ${exitCode}`);
8082
8478
  }
8083
8479
  return stdout;
8084
8480
  }
@@ -8177,12 +8573,13 @@ var DockerRuntime = class _DockerRuntime {
8177
8573
  const ownNames = /* @__PURE__ */ new Set();
8178
8574
  const foreign = /* @__PURE__ */ new Map();
8179
8575
  for (const c of containers) {
8180
- const owner = c.Labels?.["openship.project"];
8576
+ const owner = c.Labels?.[OPENSHIP_LABEL.project];
8181
8577
  if (!owner) continue;
8182
8578
  for (const m of c.Mounts ?? []) {
8183
8579
  if (m.Type !== "volume" || !m.Name || !named.has(m.Name)) continue;
8184
8580
  if (owner === config.projectId) ownNames.add(m.Name);
8185
- else if (!foreign.has(m.Name)) foreign.set(m.Name, c.Names?.[0]?.replace(/^\//, "") ?? owner);
8581
+ else if (!foreign.has(m.Name))
8582
+ foreign.set(m.Name, c.Names?.[0]?.replace(/^\//, "") ?? owner);
8186
8583
  }
8187
8584
  }
8188
8585
  for (const [name, other] of foreign) {
@@ -8231,7 +8628,7 @@ var DockerRuntime = class _DockerRuntime {
8231
8628
  );
8232
8629
  if (onNetwork) continue;
8233
8630
  if (this.cannotJoinNetworks(c)) continue;
8234
- const service = c.Labels?.["openship.service"];
8631
+ const service = c.Labels?.[OPENSHIP_LABEL.service];
8235
8632
  try {
8236
8633
  await network.connect({
8237
8634
  Container: c.Id,
@@ -8255,7 +8652,7 @@ var DockerRuntime = class _DockerRuntime {
8255
8652
  * doesn't exist (source not deployed) is skipped and nothing here ever throws —
8256
8653
  * a link networking failure must never fail the consumer's deploy.
8257
8654
  */
8258
- async attachToExternalNetworks(projectId, networkNames) {
8655
+ async attachToExternalNetworks(projectId, networkNames, extraContainerIds = []) {
8259
8656
  if (networkNames.length === 0) return;
8260
8657
  let containers;
8261
8658
  try {
@@ -8266,6 +8663,22 @@ var DockerRuntime = class _DockerRuntime {
8266
8663
  } catch {
8267
8664
  return;
8268
8665
  }
8666
+ if (extraContainerIds.length > 0) {
8667
+ const seen = new Set(containers.map((c) => c.Id));
8668
+ for (const id of extraContainerIds) {
8669
+ if (seen.has(id)) continue;
8670
+ try {
8671
+ const info = await this.docker.getContainer(id).inspect();
8672
+ containers.push({
8673
+ Id: info.Id,
8674
+ NetworkSettings: { Networks: info.NetworkSettings?.Networks ?? {} },
8675
+ HostConfig: { NetworkMode: info.HostConfig?.NetworkMode }
8676
+ });
8677
+ seen.add(info.Id);
8678
+ } catch {
8679
+ }
8680
+ }
8681
+ }
8269
8682
  for (const name of networkNames) {
8270
8683
  const network = this.docker.getNetwork(name);
8271
8684
  let netId;
@@ -8286,21 +8699,20 @@ var DockerRuntime = class _DockerRuntime {
8286
8699
  } catch (err) {
8287
8700
  const msg = err?.message ?? "";
8288
8701
  if (!/already exists|already connected/i.test(msg)) {
8289
- console.warn(
8290
- `[docker] link-connect failed for ${c.Id.slice(0, 12)} \u2192 ${name}: ${msg}`
8291
- );
8702
+ console.warn(`[docker] link-connect failed for ${c.Id.slice(0, 12)} \u2192 ${name}: ${msg}`);
8292
8703
  }
8293
8704
  }
8294
8705
  }
8295
8706
  }
8296
8707
  }
8297
- /** Remove a project network (best-effort). */
8708
+ /** Remove a project network. Already-gone is idempotent success. */
8298
8709
  async removeNetwork(slug) {
8299
8710
  const networkName = `openship-${slug}`;
8300
8711
  try {
8301
8712
  const network = this.docker.getNetwork(networkName);
8302
8713
  await network.remove();
8303
- } catch {
8714
+ } catch (err) {
8715
+ if (!isDockerNotFoundError(err)) throw err;
8304
8716
  }
8305
8717
  }
8306
8718
  /**
@@ -8319,9 +8731,17 @@ var DockerRuntime = class _DockerRuntime {
8319
8731
  await existing.remove({ force: true });
8320
8732
  } catch {
8321
8733
  }
8734
+ const serviceEnv = splitRuntimeEnv(config.environment);
8735
+ if (serviceEnv.dropped.length > 0) {
8736
+ log({
8737
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8738
+ level: "warn",
8739
+ message: `[${config.serviceName}] ${droppedRuntimeEnvMessage(serviceEnv.dropped)}`
8740
+ });
8741
+ }
8322
8742
  const env = [
8323
8743
  ...config.publicPort && config.environment.PORT === void 0 ? [`PORT=${config.publicPort}`] : [],
8324
- ...Object.entries(config.environment).map(([k, v]) => `${k}=${v}`)
8744
+ ...serviceEnv.entries.map(([k, v]) => `${k}=${v}`)
8325
8745
  ];
8326
8746
  const cmd = resolveComposeCmd(config);
8327
8747
  const entrypoint = resolveComposeEntrypoint(config.advanced);
@@ -8405,7 +8825,7 @@ var DockerRuntime = class _DockerRuntime {
8405
8825
  deploymentId: config.deploymentId,
8406
8826
  projectId: config.projectId
8407
8827
  }),
8408
- "openship.service": config.serviceName
8828
+ [OPENSHIP_LABEL.service]: config.serviceName
8409
8829
  },
8410
8830
  ...healthcheck && { Healthcheck: healthcheck },
8411
8831
  ...stopConfig,
@@ -8444,7 +8864,7 @@ var DockerRuntime = class _DockerRuntime {
8444
8864
  throw startErr;
8445
8865
  }
8446
8866
  const data = await container.inspect();
8447
- const { ip, hostPort } = extractNetworkInfo(data);
8867
+ const { ip, hostPort, hostPortByContainerPort } = extractNetworkInfo(data);
8448
8868
  const imageDigest = await this.resolveImageDigest(config.image).catch(() => void 0);
8449
8869
  log({
8450
8870
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8457,6 +8877,7 @@ var DockerRuntime = class _DockerRuntime {
8457
8877
  status: "running",
8458
8878
  ip,
8459
8879
  hostPort,
8880
+ ...hostPortByContainerPort ? { hostPortByContainerPort } : {},
8460
8881
  imageDigest
8461
8882
  };
8462
8883
  }
@@ -8467,6 +8888,16 @@ var DockerRuntime = class _DockerRuntime {
8467
8888
  * RepoDigests) or any inspect error. Works local + SSH (plain inspect, no
8468
8889
  * followProgress). Never throws.
8469
8890
  */
8891
+ /**
8892
+ * PUBLIC because it is the only anchor the update scanner has.
8893
+ *
8894
+ * `resolveDeployedDrift` reads `service_deployment.image_digest` to tell a moved mutable
8895
+ * tag (`:latest`, `:16`) from an unchanged one — a REF comparison can't, since the ref
8896
+ * string is identical either way. A deploy records it here; an ADOPTED stack records
8897
+ * nothing, and an adopted stack is image-mode by definition (registry images, no repo),
8898
+ * so every migrated `postgres:16`/`redis:7` project was told "up to date" forever. The
8899
+ * adopt paths need to read it for a container they did not create.
8900
+ */
8470
8901
  async resolveImageDigest(ref) {
8471
8902
  const info = await this.docker.getImage(ref).inspect();
8472
8903
  const repoDigests = info?.RepoDigests ?? [];
@@ -8483,7 +8914,7 @@ var DockerRuntime = class _DockerRuntime {
8483
8914
  };
8484
8915
 
8485
8916
  export {
8486
- scopedVolumeName,
8917
+ ensureScopedVolumeName,
8487
8918
  isHostPathSource,
8488
8919
  startExecStream,
8489
8920
  startAttachStream,