openship 0.6.6 → 0.6.7

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 (58) hide show
  1. package/dist/bare-7QD623QV.js +29 -0
  2. package/dist/{chunk-Q7WGTQQ5.js → chunk-2ANV5ZQ5.js} +1 -1
  3. package/dist/{chunk-UJ6JZM65.js → chunk-2VSW6Q6C.js} +601 -382
  4. package/dist/{chunk-PCPVTH6M.js → chunk-5T4UVOM3.js} +6 -4
  5. package/dist/{chunk-YMMQRE5J.js → chunk-BLEXTYRK.js} +1 -1
  6. package/dist/{chunk-OBPKYXQL.js → chunk-CIJV43LH.js} +71 -32
  7. package/dist/{chunk-NK5N5LXN.js → chunk-DDFUESPJ.js} +24 -9
  8. package/dist/{chunk-6FH4AXVY.js → chunk-E2X26PXR.js} +1 -1
  9. package/dist/{chunk-HKRQXPTI.js → chunk-FYPFEDPZ.js} +1 -1
  10. package/dist/{chunk-UVNMQM7W.js → chunk-FZ6QCF6B.js} +8 -6
  11. package/dist/{chunk-MLYSEZQB.js → chunk-GGIAIGUL.js} +15 -66
  12. package/dist/{chunk-2YM3NI2E.js → chunk-GIPJNNI5.js} +38 -44
  13. package/dist/{chunk-ZFJXPCQP.js → chunk-H3WT3A6R.js} +4 -3
  14. package/dist/chunk-H4FS5BED.js +936 -0
  15. package/dist/{chunk-CBLD2HS5.js → chunk-J2XM3QSP.js} +8 -5
  16. package/dist/{chunk-FE7ZZQSE.js → chunk-J3K6XABC.js} +3 -3
  17. package/dist/{chunk-77QDF6UD.js → chunk-LHFXSC36.js} +428 -93
  18. package/dist/{chunk-YIMOCZBZ.js → chunk-MEE6OZFP.js} +3 -3
  19. package/dist/chunk-N5YZMO33.js +111 -0
  20. package/dist/{chunk-QVW76WDL.js → chunk-NRTJRNAE.js} +58 -16
  21. package/dist/{chunk-WVAML4H6.js → chunk-PPEBIY5F.js} +247 -61
  22. package/dist/{chunk-4I2BM2DT.js → chunk-PVP4RGWT.js} +3 -3
  23. package/dist/{chunk-RSR7PFIQ.js → chunk-QHMDCYZ3.js} +4 -4
  24. package/dist/chunk-R4VDPFLS.js +106 -0
  25. package/dist/{chunk-VTGPEC6B.js → chunk-RDXT4BQB.js} +6 -4
  26. package/dist/{chunk-LFE5KQ7Y.js → chunk-S6B2Z3OO.js} +142 -57
  27. package/dist/chunk-UWEV6XTS.js +249 -0
  28. package/dist/{chunk-5DMWSTOM.js → chunk-VQM6JHJB.js} +940 -224
  29. package/dist/{chunk-SM26HDS2.js → chunk-VSTFHUCV.js} +44 -50
  30. package/dist/chunk-WU54BXFE.js +70 -0
  31. package/dist/{chunk-LUKYPVJ4.js → chunk-XWHWTHEL.js} +313 -469
  32. package/dist/{chunk-A4GXYE4L.js → chunk-ZPJ74LXE.js} +22 -20
  33. package/dist/cloud-KNNABE3O.js +40 -0
  34. package/dist/{detect-OIE5V3VZ.js → detect-G4DR3U2N.js} +9 -6
  35. package/dist/{docker-VTR2GK5Y.js → docker-YOVPCIB4.js} +11 -8
  36. package/dist/{docker-edge-executor-22IBXNYJ.js → docker-edge-executor-QFXD3337.js} +8 -6
  37. package/dist/edge-import-CRFVSR6P.js +56 -0
  38. package/dist/{edge-preflight-GBNCPNVU.js → edge-preflight-MXERVAMG.js} +15 -12
  39. package/dist/{ensure-container-edge-KPI2CVXJ.js → ensure-container-edge-F4JUNQIK.js} +13 -10
  40. package/dist/{executor-7LXXZPBR.js → executor-CVPAYYHR.js} +8 -6
  41. package/dist/host-channel-preflight-QTDNGLCK.js +54 -0
  42. package/dist/index.js +84 -48
  43. package/dist/{local-executor-WOFZD72X.js → local-executor-ODC6RANU.js} +3 -2
  44. package/dist/{nginx-KUFMLMAC.js → nginx-KLFF43TC.js} +11 -8
  45. package/dist/{openresty-lua-ATTH35BE.js → openresty-lua-VKWUV4GB.js} +6 -5
  46. package/dist/{privilege-I4W3ZNZK.js → privilege-7D3SSOL3.js} +6 -5
  47. package/dist/server/index.js +8813 -5232
  48. package/dist/setup-I5AJC6NJ.js +25 -0
  49. package/dist/{ssh-client-JBMA7SN4.js → ssh-client-625VWGKP.js} +3 -2
  50. package/dist/up-QXTOOVXC.js +61 -0
  51. package/package.json +1 -1
  52. package/dist/bare-OXJVW3AK.js +0 -25
  53. package/dist/chunk-LH2PLRQO.js +0 -434
  54. package/dist/cloud-75VIT4KV.js +0 -37
  55. package/dist/edge-import-WJUB4M7F.js +0 -52
  56. package/dist/host-channel-preflight-KE6OONYK.js +0 -50
  57. package/dist/setup-XGLGUUFW.js +0 -22
  58. package/dist/up-K4BV7466.js +0 -57
@@ -3,24 +3,37 @@
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
+ resolveServiceDockerfile
11
+ } from "./chunk-XWHWTHEL.js";
12
+ import {
13
+ isArtifactPathRef,
14
+ removeManagedArtifact
15
+ } from "./chunk-WU54BXFE.js";
13
16
  import {
14
17
  BuildCancelledError,
15
18
  BuildLogger,
16
19
  assembleGitClone,
20
+ builderContextRoot,
21
+ dockerBuildContextDirectory,
22
+ droppedRuntimeEnvMessage,
23
+ generateDockerfile,
24
+ isExcludedDocRootEntry,
17
25
  killProcessesUnderDir,
18
26
  materializeGitSsh,
27
+ normalizeDockerRelativePath,
19
28
  parseLogLevel,
29
+ resolveContextDockerfileCandidates,
30
+ resolveDockerfileCandidates,
20
31
  shellGitSshWriter,
32
+ splitRuntimeEnv,
21
33
  sq,
34
+ staticBuilderOutputPath,
22
35
  transferLocalDirectory
23
- } from "./chunk-LH2PLRQO.js";
36
+ } from "./chunk-H4FS5BED.js";
24
37
  import {
25
38
  DOCKER_DIAL_STDIO_COMMAND,
26
39
  attachDialStdioDiagnostics,
@@ -29,23 +42,28 @@ import {
29
42
  execSshCommand,
30
43
  openDockerDialStdioChannel,
31
44
  openSshUnixSocket
32
- } from "./chunk-PCPVTH6M.js";
45
+ } from "./chunk-5T4UVOM3.js";
33
46
  import {
34
47
  dirOf,
35
48
  ensureOwnedDir
36
- } from "./chunk-UVNMQM7W.js";
49
+ } from "./chunk-FZ6QCF6B.js";
50
+ import {
51
+ OPENSHIP_LABEL
52
+ } from "./chunk-UWEV6XTS.js";
53
+ import {
54
+ isRuntimeNotFoundError
55
+ } from "./chunk-N5YZMO33.js";
37
56
  import {
38
57
  formatCpuCores,
39
58
  formatMemoryMb,
40
59
  hasCpuLimit,
41
60
  hasMemoryLimit,
42
- isRuntimeNotFoundError,
43
61
  ownsNetworkEndpoint,
44
62
  registryConfigKeys,
45
63
  registryForImage,
46
64
  safeErrorMessage,
47
65
  withTimeout
48
- } from "./chunk-UJ6JZM65.js";
66
+ } from "./chunk-2VSW6Q6C.js";
49
67
  import {
50
68
  __commonJS,
51
69
  __require,
@@ -4609,6 +4627,7 @@ var tarFs = __toESM(require_tar_fs(), 1);
4609
4627
  import Dockerode from "dockerode";
4610
4628
  import { createGzip } from "zlib";
4611
4629
  import { PassThrough, Writable } from "stream";
4630
+ import { relative, sep } from "path";
4612
4631
 
4613
4632
  // ../../packages/adapters/src/runtime/docker-auth.ts
4614
4633
  import { readFile } from "fs/promises";
@@ -4712,6 +4731,10 @@ var MODE_SUFFIX = /:(ro|rw|z|Z|nocopy)$/;
4712
4731
  function scopedVolumeName(slug, name) {
4713
4732
  return `openship-${slug}-${name}`;
4714
4733
  }
4734
+ function ensureScopedVolumeName(slug, source) {
4735
+ const prefix = `openship-${slug}-`;
4736
+ return source.startsWith(prefix) ? source : scopedVolumeName(slug, source);
4737
+ }
4715
4738
  function isHostPathSource(source) {
4716
4739
  return source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.startsWith("~");
4717
4740
  }
@@ -5701,9 +5724,10 @@ function parsePortBindings(portSpecs) {
5701
5724
  }
5702
5725
  return { exposedPorts, portBindings };
5703
5726
  }
5704
- function packBuildContext(contextDir, entries, cancelSignal) {
5727
+ function packBuildContext(contextDir, entries, cancelSignal, ignoreContextPath) {
5705
5728
  const controller = new AbortController();
5706
- const pack2 = tarFs.pack(contextDir, { entries });
5729
+ const ignore = ignoreContextPath ? (absolutePath) => ignoreContextPath(relative(contextDir, absolutePath).split(sep).join("/")) : void 0;
5730
+ const pack2 = tarFs.pack(contextDir, { entries, ignore });
5707
5731
  const body = pack2.pipe(createGzip());
5708
5732
  let contextError = null;
5709
5733
  const capture = (err) => {
@@ -5835,14 +5859,22 @@ function extractNetworkInfo(data) {
5835
5859
  break;
5836
5860
  }
5837
5861
  }
5862
+ const hostPortByContainerPort = {};
5838
5863
  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;
5843
- }
5864
+ for (const [key, bindings] of Object.entries(data.NetworkSettings.Ports ?? {})) {
5865
+ const bound = bindings?.[0]?.HostPort;
5866
+ if (!bound) continue;
5867
+ const parsed = parseInt(bound, 10);
5868
+ if (!Number.isFinite(parsed)) continue;
5869
+ const containerPort = parseInt(key.split("/")[0] ?? "", 10);
5870
+ if (Number.isFinite(containerPort)) hostPortByContainerPort[containerPort] = parsed;
5871
+ hostPort ??= parsed;
5844
5872
  }
5845
- return { ip, hostPort };
5873
+ return {
5874
+ ip,
5875
+ hostPort,
5876
+ ...Object.keys(hostPortByContainerPort).length ? { hostPortByContainerPort } : {}
5877
+ };
5846
5878
  }
5847
5879
  function parseLoadedImageRef(output) {
5848
5880
  const matches = [...(output || "").matchAll(/Loaded image(?: ID)?:\s*(\S+)/gi)];
@@ -5999,11 +6031,11 @@ var DockerRuntime = class _DockerRuntime {
5999
6031
  /** Labels applied to both build images and deploy containers. */
6000
6032
  labels(config) {
6001
6033
  const l = {
6002
- "openship.project": config.projectId
6034
+ [OPENSHIP_LABEL.project]: config.projectId
6003
6035
  };
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;
6036
+ if (config.deploymentId) l[OPENSHIP_LABEL.deployment] = config.deploymentId;
6037
+ if (config.sessionId) l[OPENSHIP_LABEL.build] = config.sessionId;
6038
+ if (config.service) l[OPENSHIP_LABEL.service] = config.service;
6007
6039
  return l;
6008
6040
  }
6009
6041
  // ── Build lifecycle ────────────────────────────────────────────────────
@@ -6045,12 +6077,20 @@ var DockerRuntime = class _DockerRuntime {
6045
6077
  logger.log(`[${step}] ${label} ${message}`);
6046
6078
  logger.step(step, status, message);
6047
6079
  }
6048
- handleBuildEvent(event, logger) {
6080
+ handleBuildEvent(event, logger, trace) {
6049
6081
  const errorMessage = event.errorDetail?.message ?? event.error;
6050
6082
  if (errorMessage) {
6051
6083
  logger.log(errorMessage, "error");
6052
6084
  return errorMessage;
6053
6085
  }
6086
+ if (event.id === "moby.buildkit.trace" && typeof event.aux === "string") {
6087
+ let hint = null;
6088
+ for (const line of trace?.push(event.aux) ?? []) {
6089
+ logger.log(line.message, line.level === "error" ? "error" : parseLogLevel(line.message));
6090
+ hint ??= this.extractBuildFailureHint(line.message);
6091
+ }
6092
+ return hint;
6093
+ }
6054
6094
  if (event.stream) {
6055
6095
  const line = event.stream.trim();
6056
6096
  if (!line) return null;
@@ -6107,6 +6147,12 @@ var DockerRuntime = class _DockerRuntime {
6107
6147
  if (/returned a non-zero code:\s*\d+/i.test(line)) {
6108
6148
  return line;
6109
6149
  }
6150
+ if (/requires? BuildKit|BuildKit is enabled but the buildx component/i.test(line)) {
6151
+ 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.`;
6152
+ }
6153
+ if (/buildkit (is )?(not (supported|enabled)|disabled)|builder version.*not supported/i.test(line)) {
6154
+ 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.`;
6155
+ }
6110
6156
  if (/\/workspace\/package\.json/i.test(line) && /ENOENT/i.test(line)) {
6111
6157
  return "Docker build ran from /workspace but package.json was not found there. The configured rootDirectory is likely empty or incorrect.";
6112
6158
  }
@@ -6158,12 +6204,35 @@ var DockerRuntime = class _DockerRuntime {
6158
6204
  { excludes: [] }
6159
6205
  );
6160
6206
  }
6207
+ /**
6208
+ * Is BuildKit usable on the remote host? Probed, never assumed: a plain `docker`
6209
+ * CLI without the buildx plugin HARD-FAILS when BuildKit is requested explicitly
6210
+ * ("BuildKit is enabled but the buildx component is missing or broken"), while an
6211
+ * unadorned `docker build` on the same box falls back to the legacy builder and
6212
+ * works — the same trap {@link buildImage} in system/managed-image.ts documents.
6213
+ * So we ask first, and only then opt in.
6214
+ *
6215
+ * Memoized per adapter instance (one instance ≈ one target daemon), so a compose
6216
+ * batch pays a single `docker buildx version` for N images.
6217
+ */
6218
+ buildKitProbe = null;
6219
+ async remoteBuildKitAvailable() {
6220
+ const executor = this.connectionOptions?.executor;
6221
+ if (!executor) return false;
6222
+ 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);
6223
+ const available = await probe;
6224
+ if (!available) this.buildKitProbe = null;
6225
+ return available;
6226
+ }
6161
6227
  /**
6162
6228
  * 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).
6229
+ * context dir. One image; `dockerfileName` selects which Dockerfile to use (so N
6230
+ * services can build from one transferred tree) and is relative to
6231
+ * `remoteBuildDir` — the build CONTEXT, which for a compose service is its own
6232
+ * `build:` directory inside that tree, not the tree root (#634).
6165
6233
  */
6166
- async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log, signal) {
6234
+ async buildImageOnRemote(config, remoteBuildDir, dockerfileName, tag, log, opts) {
6235
+ const signal = opts?.signal;
6167
6236
  const executor = this.connectionOptions?.executor;
6168
6237
  if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
6169
6238
  const buildArgs = Object.entries({
@@ -6174,7 +6243,22 @@ var DockerRuntime = class _DockerRuntime {
6174
6243
  this.labels({ projectId: config.projectId, sessionId: config.sessionId })
6175
6244
  ).map(([k, v]) => `--label ${sq(`${k}=${v}`)}`).join(" ");
6176
6245
  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 .`;
6246
+ const buildKit = await this.remoteBuildKitAvailable();
6247
+ const builderEnv = buildKit ? "DOCKER_BUILDKIT=1 " : "";
6248
+ const builderFlags = buildKit ? " --progress=plain" : " --force-rm";
6249
+ if (buildKit) {
6250
+ log.log("Builder: BuildKit (docker buildx detected on the host)");
6251
+ } else if (opts?.requiresBuildKit) {
6252
+ log.log(
6253
+ "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.",
6254
+ "warn"
6255
+ );
6256
+ } else {
6257
+ log.log(
6258
+ "Builder: legacy (docker buildx not available on the host) \u2014 BuildKit-only Dockerfile syntax such as `RUN --mount` will not work"
6259
+ );
6260
+ }
6261
+ const buildCmd = `cd ${sq(remoteBuildDir)} && ${builderEnv}docker build${builderFlags} -t ${sq(tag)}${dockerfileFlag} ${labelArgs} ${buildArgs} .`;
6178
6262
  log.log(`Running on remote: ${buildCmd}`);
6179
6263
  log.log("\u2500\u2500\u2500 docker build output \u2500\u2500\u2500");
6180
6264
  this.emitDockerStep(log, "install", "running", "Running install inside container (docker build)");
@@ -6286,15 +6370,50 @@ var DockerRuntime = class _DockerRuntime {
6286
6370
  * (clone-on-server). Mirrors resolveServiceDockerfile but probes the remote
6287
6371
  * tree with `test -f` instead of the local FS: a repository Dockerfile
6288
6372
  * 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`.
6373
+ * (pure fn) and written to the remote tree.
6374
+ *
6375
+ * Returns the build CONTEXT dir on the host plus the Dockerfile path relative to
6376
+ * it — the same pair {@link resolveServiceDockerfile} produces locally, because
6377
+ * these two resolvers are one behaviour with two probes and a narrowed context
6378
+ * learned by only one of them would leave #634 reproducible on this target.
6291
6379
  */
6292
- async resolveRemoteDockerfile(config, remoteContextDir, generatedName, requireRepositoryDockerfile) {
6380
+ async resolveRemoteDockerfile(config, remoteContextDir, generatedName, requireRepositoryDockerfile, log) {
6293
6381
  const executor = this.connectionOptions?.executor;
6294
6382
  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;
6383
+ const contextSubdir = dockerBuildContextDirectory(config);
6384
+ const remoteBuildDir = contextSubdir ? `${remoteContextDir}/${contextSubdir}` : remoteContextDir;
6385
+ if (contextSubdir) {
6386
+ const real = await executor.exec(`cd ${sq(remoteBuildDir)} 2>/dev/null && pwd -P || true`).then((out) => out.trim()).catch(() => "");
6387
+ const root = await executor.exec(`cd ${sq(remoteContextDir)} && pwd -P`).then((out) => out.trim()).catch(() => "");
6388
+ if (!real) {
6389
+ throw new Error(
6390
+ `Build context "${contextSubdir}" is not a directory in the cloned source. Check the service's build path.`
6391
+ );
6392
+ }
6393
+ if (!root || real !== root && !real.startsWith(`${root}/`)) {
6394
+ throw new Error(`Build context "${contextSubdir}" resolves outside the cloned source.`);
6395
+ }
6396
+ }
6397
+ const candidates = contextSubdir ? resolveContextDockerfileCandidates(contextSubdir, config.dockerfilePath) : resolveDockerfileCandidates(config.rootDirectory, config.dockerfilePath);
6398
+ for (const candidate of candidates) {
6399
+ const out = await executor.exec(`test -f ${sq(`${remoteBuildDir}/${candidate}`)} && echo yes || true`).catch(() => "");
6400
+ if (out.trim() === "yes") {
6401
+ const explicit = normalizeDockerRelativePath(config.dockerfilePath);
6402
+ if (contextSubdir && explicit && candidate === "Dockerfile" && explicit !== "Dockerfile" && explicit !== `${contextSubdir}/Dockerfile`) {
6403
+ log?.log(
6404
+ `Configured Dockerfile "${config.dockerfilePath}" was not found in build context "${contextSubdir}" \u2014 using ${contextSubdir}/Dockerfile instead.`,
6405
+ "warn"
6406
+ );
6407
+ }
6408
+ return { remoteBuildDir, dockerfileName: candidate };
6409
+ }
6410
+ }
6411
+ if (contextSubdir) {
6412
+ throw new Error(
6413
+ missingContextDockerfileMessage(contextSubdir, config.dockerfilePath, {
6414
+ generatedRecipeRefused: !requireRepositoryDockerfile
6415
+ })
6416
+ );
6298
6417
  }
6299
6418
  if (requireRepositoryDockerfile) {
6300
6419
  const expected = config.dockerfilePath?.trim() || "Dockerfile";
@@ -6303,7 +6422,7 @@ var DockerRuntime = class _DockerRuntime {
6303
6422
  );
6304
6423
  }
6305
6424
  await executor.writeFile(`${remoteContextDir}/${generatedName}`, generateDockerfile(config));
6306
- return generatedName;
6425
+ return { remoteBuildDir, dockerfileName: generatedName };
6307
6426
  }
6308
6427
  async buildViaSshTarPipe(config, buildContext, tag, log, signal) {
6309
6428
  const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
@@ -6311,11 +6430,11 @@ var DockerRuntime = class _DockerRuntime {
6311
6430
  await this.transferBuildContext(buildContext.contextDir, remoteContextDir, log);
6312
6431
  await this.buildImageOnRemote(
6313
6432
  config,
6314
- remoteContextDir,
6433
+ buildContext.contextSubdir ? `${remoteContextDir}/${buildContext.contextSubdir}` : remoteContextDir,
6315
6434
  buildContext.dockerfileName,
6316
6435
  tag,
6317
6436
  log,
6318
- signal
6437
+ { signal, requiresBuildKit: buildContext.requiresBuildKit }
6319
6438
  );
6320
6439
  } finally {
6321
6440
  this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
@@ -6323,6 +6442,41 @@ var DockerRuntime = class _DockerRuntime {
6323
6442
  await buildContext.cleanup();
6324
6443
  }
6325
6444
  }
6445
+ /**
6446
+ * Pick the Engine-API builder for ONE dockerode build, for BOTH dockerode call
6447
+ * sites (single image and compose batch) — they are the same decision and a
6448
+ * builder learned by only one of them is the "two executors" trap all over again.
6449
+ *
6450
+ * The classic builder is kept as the DEFAULT deliberately. It is what every
6451
+ * self-hosted local build has always used; switching wholesale would change layer
6452
+ * caching for everyone and silently drop the build CPU/memory caps, which BuildKit
6453
+ * ignores. So BuildKit is opted into only where the classic builder cannot work at
6454
+ * all — a Dockerfile whose syntax it rejects (#634).
6455
+ *
6456
+ * Through the ENGINE API (`version=2`), not by shelling out to `docker build`,
6457
+ * which is the first alternative that comes to mind and is not available: the API
6458
+ * image ships `openssh-client rsync git` and no docker CLI (apps/api/Dockerfile),
6459
+ * and the transport may be a TCP socket with no shell at the other end at all.
6460
+ * Nothing needs the buildx plugin either — BuildKit lives in the daemon.
6461
+ *
6462
+ * Asymmetric with the remote CLI path on purpose: there, BuildKit is taken whenever
6463
+ * buildx exists, because that command passes no --memory/--cpus (so there is no cap
6464
+ * to lose) and on docker ≥23 the CLI already defaults to it — the prefix mostly
6465
+ * buys the log line and the buildx-missing warning.
6466
+ */
6467
+ selectDockerodeBuilder(config, requiresBuildKit, log) {
6468
+ if (!requiresBuildKit) return { options: {} };
6469
+ log.log(
6470
+ "Dockerfile uses BuildKit-only syntax (RUN --mount, a syntax directive or a heredoc) \u2014 building with BuildKit."
6471
+ );
6472
+ if (Object.keys(dockerBuildResourceLimits(config.resources)).length > 0) {
6473
+ log.log(
6474
+ "Build CPU/memory limits are not enforced under BuildKit \u2014 this build runs uncapped.",
6475
+ "warn"
6476
+ );
6477
+ }
6478
+ return { options: { version: "2" }, trace: new BuildKitTraceDecoder() };
6479
+ }
6326
6480
  /**
6327
6481
  * Dockerode build path. Used for local socket and TCP transports, plus
6328
6482
  * SSH transports that didn't get an executor wired in (shouldn't happen
@@ -6335,10 +6489,12 @@ var DockerRuntime = class _DockerRuntime {
6335
6489
  async buildViaDockerode(config, buildContext, tag, log, cancelSignal) {
6336
6490
  log.log(`Streaming build context to Docker daemon - image tag: ${tag}`);
6337
6491
  const { body, abortSignal, takeError } = packBuildContext(
6338
- buildContext.contextDir,
6492
+ buildContext.buildContextDir,
6339
6493
  buildContext.contextEntries,
6340
- cancelSignal
6494
+ cancelSignal,
6495
+ buildContext.ignoreContextPath
6341
6496
  );
6497
+ const builder = this.selectDockerodeBuilder(config, buildContext.requiresBuildKit, log);
6342
6498
  try {
6343
6499
  const stream = await this.docker.buildImage(body, {
6344
6500
  t: tag,
@@ -6353,10 +6509,11 @@ var DockerRuntime = class _DockerRuntime {
6353
6509
  // needs several GB). Opt-in only; see dockerBuildResourceLimits.
6354
6510
  ...dockerBuildResourceLimits(config.resources),
6355
6511
  forcerm: true,
6512
+ ...builder.options,
6356
6513
  abortSignal
6357
6514
  });
6358
6515
  log.log("Connected to Docker daemon. Build output follows:");
6359
- await this.streamDockerodeBuild(stream, log);
6516
+ await this.streamDockerodeBuild(stream, log, builder.trace);
6360
6517
  log.log("Docker daemon finished streaming build output. Finalizing image...\n");
6361
6518
  } catch (err) {
6362
6519
  const contextErr = takeError();
@@ -6370,7 +6527,7 @@ var DockerRuntime = class _DockerRuntime {
6370
6527
  * idle timeout + keepalive, and throw on a fatal build event. Shared by the
6371
6528
  * single-image dockerode path and the local batch build (buildImages).
6372
6529
  */
6373
- async streamDockerodeBuild(stream, log) {
6530
+ async streamDockerodeBuild(stream, log, trace) {
6374
6531
  let fatalBuildError = null;
6375
6532
  await new Promise((resolve, reject) => {
6376
6533
  let settled = false;
@@ -6427,7 +6584,7 @@ var DockerRuntime = class _DockerRuntime {
6427
6584
  },
6428
6585
  (event) => {
6429
6586
  resetIdleTimer();
6430
- fatalBuildError ??= this.handleBuildEvent(event, log);
6587
+ fatalBuildError ??= this.handleBuildEvent(event, log, trace);
6431
6588
  }
6432
6589
  );
6433
6590
  });
@@ -6496,20 +6653,16 @@ var DockerRuntime = class _DockerRuntime {
6496
6653
  await this.cloneSourceOnRemote(config, remoteContextDir, log);
6497
6654
  if (abort.signal.aborted) throw new BuildCancelledError();
6498
6655
  this.emitDockerStep(log, "clone", "completed", "Source cloned on the server");
6499
- const dockerfileName = await this.resolveRemoteDockerfile(
6656
+ const { remoteBuildDir, dockerfileName } = await this.resolveRemoteDockerfile(
6500
6657
  config,
6501
6658
  remoteContextDir,
6502
6659
  "Dockerfile.openship",
6503
- config.stack === "docker"
6504
- );
6505
- await this.buildImageOnRemote(
6506
- config,
6507
- remoteContextDir,
6508
- dockerfileName,
6509
- tag,
6510
- log,
6511
- abort.signal
6660
+ config.stack === "docker",
6661
+ log
6512
6662
  );
6663
+ await this.buildImageOnRemote(config, remoteBuildDir, dockerfileName, tag, log, {
6664
+ signal: abort.signal
6665
+ });
6513
6666
  } finally {
6514
6667
  sshExecutor.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6515
6668
  });
@@ -6539,7 +6692,9 @@ var DockerRuntime = class _DockerRuntime {
6539
6692
  } catch {
6540
6693
  this.emitDockerStep(log, "clone", "completed", "Docker build context ready");
6541
6694
  }
6542
- if (buildContext.rootDirectory) {
6695
+ if (buildContext.contextSubdir) {
6696
+ log.log(`Using Docker build context: ${buildContext.contextSubdir}`);
6697
+ } else if (buildContext.rootDirectory) {
6543
6698
  log.log(`Using Docker build root: ${buildContext.rootDirectory}`);
6544
6699
  }
6545
6700
  if (buildContext.usesRepositoryDockerfile) {
@@ -6608,6 +6763,7 @@ var DockerRuntime = class _DockerRuntime {
6608
6763
  await this.extractDocRootViaDaemon(tag, docRoot, hostOutDir);
6609
6764
  }
6610
6765
  }
6766
+ await this.pruneBuildFilesFromDocRoot(config, hostOutDir, sshExecutor ?? null, log);
6611
6767
  log.log(`Static files ready at ${hostOutDir}
6612
6768
  `);
6613
6769
  } finally {
@@ -6692,6 +6848,74 @@ var DockerRuntime = class _DockerRuntime {
6692
6848
  * expensive one: every extractor can succeed having copied nothing, which deploys
6693
6849
  * green and then 404s the entire site.
6694
6850
  */
6851
+ /**
6852
+ * Names that must never end up in a public doc-root, matched at its TOP LEVEL
6853
+ * only.
6854
+ *
6855
+ * `.git` is the repository — serving it publishes full source history. The
6856
+ * Dockerfiles and `.dockerignore` are OUR build inputs, and one of them is
6857
+ * literally written into the tree by `resolveServiceDockerfile`.
6858
+ */
6859
+ static DOC_ROOT_EXCLUDED = [
6860
+ ".dockerignore",
6861
+ ".git",
6862
+ "Dockerfile",
6863
+ "Dockerfile.*"
6864
+ ];
6865
+ /**
6866
+ * Remove build inputs from an extracted static doc-root, then re-assert the
6867
+ * contract that it still holds something servable.
6868
+ *
6869
+ * Only runs when the doc-root IS the build context — `outputDirectory` empty or
6870
+ * "." — which is the zero-build "just files, index.html at the root" project. A
6871
+ * real output directory (`dist`, `build`) is written by the build and contains
6872
+ * none of this, so the prune is a no-op there.
6873
+ *
6874
+ * Two distinct problems, one fix. The build context is pruned by `.dockerignore`
6875
+ * and THEN the generated recipe is written into it, so for these projects the
6876
+ * published site contained `.dockerignore` + `Dockerfile.openship…` — and any
6877
+ * tracked `.git` — at their own public URLs. Worse, those files count as
6878
+ * content: a repo with an allowlist `.dockerignore` (`*` + `!src`) extracted to
6879
+ * a tree holding ONLY the two build files, which passed the non-empty check and
6880
+ * deployed green as a site that 404s every request. Pruning first and
6881
+ * re-checking after is what makes that check mean "there is something to serve".
6882
+ */
6883
+ async pruneBuildFilesFromDocRoot(config, hostOutDir, sshExecutor, log) {
6884
+ if (staticBuilderOutputPath(config) !== builderContextRoot(config)) return;
6885
+ if (sshExecutor) {
6886
+ const globs = _DockerRuntime.DOC_ROOT_EXCLUDED.map(
6887
+ (name) => `${sq(hostOutDir)}/${name}`
6888
+ ).join(" ");
6889
+ await sshExecutor.exec(`rm -rf ${globs}`).catch(() => {
6890
+ });
6891
+ log.log(`Excluded build files from the published output (${hostOutDir}).
6892
+ `);
6893
+ const listing = (await sshExecutor.exec(`ls -A ${sq(hostOutDir)} | head -1`).catch(() => "x")).trim();
6894
+ this.assertDocRootServable(!listing, hostOutDir);
6895
+ return;
6896
+ }
6897
+ const { readdir, rm } = await import("fs/promises");
6898
+ const { join: join3 } = await import("path");
6899
+ const entries = await readdir(hostOutDir).catch(() => null);
6900
+ if (entries === null) return;
6901
+ const doomed = entries.filter((name) => isExcludedDocRootEntry(name));
6902
+ for (const name of doomed) {
6903
+ await rm(join3(hostOutDir, name), { recursive: true, force: true }).catch(() => {
6904
+ });
6905
+ }
6906
+ if (doomed.length > 0) {
6907
+ log.log(`Excluded build files from the published output: ${doomed.join(", ")}
6908
+ `);
6909
+ }
6910
+ this.assertDocRootServable(entries.length === doomed.length, hostOutDir);
6911
+ }
6912
+ /** Post-prune half of the doc-root contract: build inputs are not content. */
6913
+ assertDocRootServable(isEmpty, hostOutDir) {
6914
+ if (!isEmpty) return;
6915
+ throw new Error(
6916
+ `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.`
6917
+ );
6918
+ }
6695
6919
  assertDocRootFilled(isEmpty, docRoot) {
6696
6920
  if (!isEmpty) return;
6697
6921
  throw new Error(
@@ -6878,7 +7102,25 @@ var DockerRuntime = class _DockerRuntime {
6878
7102
  prepareLogger.step("clone", "completed", "Source cloned on the server");
6879
7103
  } else {
6880
7104
  prepareLogger.step("clone", "running", "Preparing shared build context...");
6881
- tree = await prepareSourceTree(source, { onLog: prepareLogger.callback });
7105
+ tree = await prepareSourceTree(source, {
7106
+ onLog: prepareLogger.callback,
7107
+ // Any service building from its OWN `build:` directory makes the source
7108
+ // root's `.dockerignore` the wrong file to prune this SHARED tree with —
7109
+ // it would delete files the other contexts need. Those builds filter their
7110
+ // own context instead; see SourceTree.dockerignorePruned.
7111
+ //
7112
+ // A malformed value throws here; `true` is the safe answer for it, because
7113
+ // it only skips a destructive prune. Letting the throw out would fail the
7114
+ // WHOLE batch, while per-spec resolution below reports it against the one
7115
+ // service that owns it.
7116
+ perServiceBuildContexts: specs.some((spec) => {
7117
+ try {
7118
+ return Boolean(dockerBuildContextDirectory(spec.config));
7119
+ } catch {
7120
+ return true;
7121
+ }
7122
+ })
7123
+ });
6882
7124
  }
6883
7125
  const resolvedList = await Promise.all(
6884
7126
  specs.map(async (spec) => {
@@ -6886,34 +7128,54 @@ var DockerRuntime = class _DockerRuntime {
6886
7128
  const requireRepo = spec.requireRepositoryDockerfile ?? spec.config.stack === "docker";
6887
7129
  try {
6888
7130
  if (cloneOnServer) {
6889
- const dockerfileName = await this.resolveRemoteDockerfile(
7131
+ const { remoteBuildDir, dockerfileName } = await this.resolveRemoteDockerfile(
6890
7132
  spec.config,
6891
7133
  remoteContextDir,
6892
7134
  generatedName,
6893
- requireRepo
7135
+ requireRepo,
7136
+ spec.logger
6894
7137
  );
6895
7138
  return {
6896
7139
  spec,
6897
7140
  dockerfileName,
7141
+ remoteBuildSubdir: dockerBuildContextDirectory(spec.config),
7142
+ remoteBuildDir,
7143
+ buildContextDir: null,
6898
7144
  contextEntries: null,
7145
+ ignoreContextPath: void 0,
7146
+ // Only the dockerode branch consults it, and clone-on-server never
7147
+ // reaches that branch (it is SSH by construction).
7148
+ requiresBuildKit: false,
6899
7149
  error: null
6900
7150
  };
6901
7151
  }
6902
7152
  const resolved = await resolveServiceDockerfile(tree.contextDir, spec.config, {
6903
7153
  requireRepositoryDockerfile: requireRepo,
6904
- generatedName
7154
+ generatedName,
7155
+ dockerignorePruned: tree.dockerignorePruned,
7156
+ onLog: spec.logger.callback
6905
7157
  });
6906
7158
  return {
6907
7159
  spec,
6908
7160
  dockerfileName: resolved.dockerfileName,
7161
+ remoteBuildSubdir: resolved.contextSubdir,
7162
+ remoteBuildDir: resolved.contextSubdir ? `${remoteContextDir}/${resolved.contextSubdir}` : remoteContextDir,
7163
+ buildContextDir: resolved.buildContextDir,
6909
7164
  contextEntries: resolved.contextEntries,
7165
+ ignoreContextPath: resolved.ignoreContextPath,
7166
+ requiresBuildKit: resolved.requiresBuildKit,
6910
7167
  error: null
6911
7168
  };
6912
7169
  } catch (err) {
6913
7170
  return {
6914
7171
  spec,
6915
7172
  dockerfileName: null,
7173
+ remoteBuildSubdir: "",
7174
+ remoteBuildDir: remoteContextDir,
7175
+ buildContextDir: null,
6916
7176
  contextEntries: null,
7177
+ ignoreContextPath: void 0,
7178
+ requiresBuildKit: false,
6917
7179
  error: safeErrorMessage(err)
6918
7180
  };
6919
7181
  }
@@ -6935,7 +7197,17 @@ var DockerRuntime = class _DockerRuntime {
6935
7197
  }
6936
7198
  }
6937
7199
  const results = [];
6938
- for (const { spec, dockerfileName, contextEntries, error } of resolvedList) {
7200
+ for (const {
7201
+ spec,
7202
+ dockerfileName,
7203
+ remoteBuildDir,
7204
+ remoteBuildSubdir,
7205
+ buildContextDir,
7206
+ contextEntries,
7207
+ ignoreContextPath,
7208
+ requiresBuildKit,
7209
+ error
7210
+ } of resolvedList) {
6939
7211
  const startedAt = Date.now();
6940
7212
  const tag = this.imageTag(spec.config.slug, spec.config.sessionId);
6941
7213
  const signal = abortControllers.get(spec.config.sessionId)?.signal;
@@ -6958,20 +7230,30 @@ var DockerRuntime = class _DockerRuntime {
6958
7230
  }
6959
7231
  spec.onStart?.();
6960
7232
  try {
7233
+ if (remoteBuildSubdir) {
7234
+ spec.logger.log(`Using Docker build context: ${remoteBuildSubdir}
7235
+ `);
7236
+ }
6961
7237
  if (isSsh) {
6962
7238
  await this.buildImageOnRemote(
6963
7239
  spec.config,
6964
- remoteContextDir,
7240
+ remoteBuildDir,
6965
7241
  dockerfileName,
6966
7242
  tag,
6967
7243
  spec.logger,
6968
- signal
7244
+ { signal, requiresBuildKit }
6969
7245
  );
6970
7246
  } else {
6971
7247
  const { body, abortSignal, takeError } = packBuildContext(
6972
- tree.contextDir,
7248
+ buildContextDir ?? tree.contextDir,
6973
7249
  contextEntries ?? [],
6974
- signal
7250
+ signal,
7251
+ ignoreContextPath
7252
+ );
7253
+ const builder = this.selectDockerodeBuilder(
7254
+ spec.config,
7255
+ requiresBuildKit,
7256
+ spec.logger
6975
7257
  );
6976
7258
  try {
6977
7259
  const stream = await this.docker.buildImage(body, {
@@ -6984,9 +7266,10 @@ var DockerRuntime = class _DockerRuntime {
6984
7266
  buildargs: { ...spec.config.envVars, NODE_ENV: "production" },
6985
7267
  ...dockerBuildResourceLimits(spec.config.resources),
6986
7268
  forcerm: true,
7269
+ ...builder.options,
6987
7270
  abortSignal
6988
7271
  });
6989
- await this.streamDockerodeBuild(stream, spec.logger);
7272
+ await this.streamDockerodeBuild(stream, spec.logger, builder.trace);
6990
7273
  } catch (err) {
6991
7274
  const contextErr = takeError();
6992
7275
  throw contextErr ? new Error(`Failed to read Docker build context: ${safeErrorMessage(contextErr)}`, { cause: contextErr }) : err;
@@ -7072,10 +7355,10 @@ var DockerRuntime = class _DockerRuntime {
7072
7355
  }
7073
7356
  const containers = await this.docker.listContainers({
7074
7357
  all: true,
7075
- filters: { label: ["openship.build"] }
7358
+ filters: { label: [OPENSHIP_LABEL.build] }
7076
7359
  });
7077
7360
  for (const c of containers) {
7078
- const buildId = c.Labels?.["openship.build"];
7361
+ const buildId = c.Labels?.[OPENSHIP_LABEL.build];
7079
7362
  if (!buildId || !cancelCovers(sessionId, buildId)) continue;
7080
7363
  try {
7081
7364
  await this.docker.getContainer(c.Id).remove({ force: true });
@@ -7096,10 +7379,18 @@ var DockerRuntime = class _DockerRuntime {
7096
7379
  throw new Error("Docker deploy requires an imageRef (built image tag)");
7097
7380
  }
7098
7381
  const containerName = `openship-${config.runtimeName || config.projectId}-${config.deploymentId}`;
7382
+ const projectEnv = splitRuntimeEnv(config.envVars);
7383
+ if (projectEnv.dropped.length > 0) {
7384
+ log({
7385
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7386
+ level: "warn",
7387
+ message: droppedRuntimeEnvMessage(projectEnv.dropped)
7388
+ });
7389
+ }
7099
7390
  const env = [
7100
7391
  ...config.portless ? [] : [`PORT=${config.port}`],
7101
7392
  `NODE_ENV=${config.environment === "production" ? "production" : "development"}`,
7102
- ...Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`)
7393
+ ...projectEnv.entries.map(([k, v]) => `${k}=${v}`)
7103
7394
  ];
7104
7395
  const cmd = config.startCommand ? ["sh", "-c", config.startCommand] : void 0;
7105
7396
  const restartPolicy = resolveRestartPolicy(config.restartPolicy);
@@ -7217,6 +7508,11 @@ var DockerRuntime = class _DockerRuntime {
7217
7508
  await container.restart();
7218
7509
  }
7219
7510
  async removeImage(imageRef) {
7511
+ if (isArtifactPathRef(imageRef)) {
7512
+ throw new Error(
7513
+ `"${imageRef}" is a host directory, not an image reference \u2014 remove it with destroy().`
7514
+ );
7515
+ }
7220
7516
  const image = this.docker.getImage(imageRef);
7221
7517
  try {
7222
7518
  await image.remove({ force: true });
@@ -7225,16 +7521,18 @@ var DockerRuntime = class _DockerRuntime {
7225
7521
  }
7226
7522
  }
7227
7523
  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
- });
7524
+ if (isArtifactPathRef(containerId)) {
7525
+ if (this.transport.kind === "ssh") {
7526
+ const executor = this.connectionOptions?.executor;
7527
+ if (!executor) {
7528
+ throw new Error(
7529
+ `Cannot remove ${containerId}: the SSH transport has no command executor, and removing it locally would delete the wrong machine's files.`
7530
+ );
7531
+ }
7532
+ await removeManagedArtifact(executor, containerId);
7533
+ return;
7237
7534
  }
7535
+ await removeManagedArtifact(null, containerId);
7238
7536
  return;
7239
7537
  }
7240
7538
  const container = this.docker.getContainer(containerId);
@@ -7274,8 +7572,8 @@ var DockerRuntime = class _DockerRuntime {
7274
7572
  return images.map((img) => ({
7275
7573
  id: img.Id,
7276
7574
  repoTags: (img.RepoTags ?? []).filter((t) => t && t !== "<none>:<none>"),
7277
- buildId: img.Labels?.["openship.build"],
7278
- deploymentId: img.Labels?.["openship.deployment"],
7575
+ buildId: img.Labels?.[OPENSHIP_LABEL.build],
7576
+ deploymentId: img.Labels?.[OPENSHIP_LABEL.deployment],
7279
7577
  size: img.Size ?? 0
7280
7578
  }));
7281
7579
  }
@@ -7320,7 +7618,7 @@ var DockerRuntime = class _DockerRuntime {
7320
7618
  return containers.map((c) => ({
7321
7619
  containerId: c.Id,
7322
7620
  status: stateMap[(c.State ?? "").toLowerCase().trim()] ?? "stopped",
7323
- serviceName: c.Labels?.["openship.service"]
7621
+ serviceName: c.Labels?.[OPENSHIP_LABEL.service]
7324
7622
  }));
7325
7623
  }
7326
7624
  // ── Rollback primitives (retention half only) ────────────────────────
@@ -7349,17 +7647,18 @@ var DockerRuntime = class _DockerRuntime {
7349
7647
  }
7350
7648
  }
7351
7649
  async purge(deployment) {
7650
+ const failures = [];
7352
7651
  if (deployment.containerId) {
7353
- try {
7354
- await this.destroy(deployment.containerId);
7355
- } catch {
7356
- }
7652
+ await this.destroy(deployment.containerId).catch((err) => failures.push(err));
7357
7653
  }
7358
7654
  if (deployment.imageRef && ownsBuiltImage(deployment.imageRef)) {
7359
- try {
7360
- await this.removeImage(deployment.imageRef);
7361
- } catch {
7362
- }
7655
+ await this.removeImage(deployment.imageRef).catch((err) => failures.push(err));
7656
+ }
7657
+ if (failures.length === 1) throw failures[0];
7658
+ if (failures.length > 1) {
7659
+ throw new Error(
7660
+ failures.map((err) => err instanceof Error ? err.message : String(err)).join("; ")
7661
+ );
7363
7662
  }
7364
7663
  }
7365
7664
  /**
@@ -7677,7 +7976,7 @@ var DockerRuntime = class _DockerRuntime {
7677
7976
  };
7678
7977
  const startedAt = data.State.StartedAt;
7679
7978
  const uptimeSeconds = startedAt && data.State.Running ? Math.floor((Date.now() - new Date(startedAt).getTime()) / 1e3) : void 0;
7680
- const { ip, hostPort } = extractNetworkInfo(data);
7979
+ const { ip, hostPort, hostPortByContainerPort } = extractNetworkInfo(data);
7681
7980
  let status;
7682
7981
  if (data.State.Running) {
7683
7982
  status = "running";
@@ -7692,6 +7991,7 @@ var DockerRuntime = class _DockerRuntime {
7692
7991
  status,
7693
7992
  ip,
7694
7993
  hostPort,
7994
+ ...hostPortByContainerPort ? { hostPortByContainerPort } : {},
7695
7995
  uptimeSeconds: uptimeSeconds && uptimeSeconds > 0 ? uptimeSeconds : void 0
7696
7996
  };
7697
7997
  }
@@ -8177,7 +8477,7 @@ var DockerRuntime = class _DockerRuntime {
8177
8477
  const ownNames = /* @__PURE__ */ new Set();
8178
8478
  const foreign = /* @__PURE__ */ new Map();
8179
8479
  for (const c of containers) {
8180
- const owner = c.Labels?.["openship.project"];
8480
+ const owner = c.Labels?.[OPENSHIP_LABEL.project];
8181
8481
  if (!owner) continue;
8182
8482
  for (const m of c.Mounts ?? []) {
8183
8483
  if (m.Type !== "volume" || !m.Name || !named.has(m.Name)) continue;
@@ -8231,7 +8531,7 @@ var DockerRuntime = class _DockerRuntime {
8231
8531
  );
8232
8532
  if (onNetwork) continue;
8233
8533
  if (this.cannotJoinNetworks(c)) continue;
8234
- const service = c.Labels?.["openship.service"];
8534
+ const service = c.Labels?.[OPENSHIP_LABEL.service];
8235
8535
  try {
8236
8536
  await network.connect({
8237
8537
  Container: c.Id,
@@ -8255,7 +8555,7 @@ var DockerRuntime = class _DockerRuntime {
8255
8555
  * doesn't exist (source not deployed) is skipped and nothing here ever throws —
8256
8556
  * a link networking failure must never fail the consumer's deploy.
8257
8557
  */
8258
- async attachToExternalNetworks(projectId, networkNames) {
8558
+ async attachToExternalNetworks(projectId, networkNames, extraContainerIds = []) {
8259
8559
  if (networkNames.length === 0) return;
8260
8560
  let containers;
8261
8561
  try {
@@ -8266,6 +8566,22 @@ var DockerRuntime = class _DockerRuntime {
8266
8566
  } catch {
8267
8567
  return;
8268
8568
  }
8569
+ if (extraContainerIds.length > 0) {
8570
+ const seen = new Set(containers.map((c) => c.Id));
8571
+ for (const id of extraContainerIds) {
8572
+ if (seen.has(id)) continue;
8573
+ try {
8574
+ const info = await this.docker.getContainer(id).inspect();
8575
+ containers.push({
8576
+ Id: info.Id,
8577
+ NetworkSettings: { Networks: info.NetworkSettings?.Networks ?? {} },
8578
+ HostConfig: { NetworkMode: info.HostConfig?.NetworkMode }
8579
+ });
8580
+ seen.add(info.Id);
8581
+ } catch {
8582
+ }
8583
+ }
8584
+ }
8269
8585
  for (const name of networkNames) {
8270
8586
  const network = this.docker.getNetwork(name);
8271
8587
  let netId;
@@ -8319,9 +8635,17 @@ var DockerRuntime = class _DockerRuntime {
8319
8635
  await existing.remove({ force: true });
8320
8636
  } catch {
8321
8637
  }
8638
+ const serviceEnv = splitRuntimeEnv(config.environment);
8639
+ if (serviceEnv.dropped.length > 0) {
8640
+ log({
8641
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8642
+ level: "warn",
8643
+ message: `[${config.serviceName}] ${droppedRuntimeEnvMessage(serviceEnv.dropped)}`
8644
+ });
8645
+ }
8322
8646
  const env = [
8323
8647
  ...config.publicPort && config.environment.PORT === void 0 ? [`PORT=${config.publicPort}`] : [],
8324
- ...Object.entries(config.environment).map(([k, v]) => `${k}=${v}`)
8648
+ ...serviceEnv.entries.map(([k, v]) => `${k}=${v}`)
8325
8649
  ];
8326
8650
  const cmd = resolveComposeCmd(config);
8327
8651
  const entrypoint = resolveComposeEntrypoint(config.advanced);
@@ -8405,7 +8729,7 @@ var DockerRuntime = class _DockerRuntime {
8405
8729
  deploymentId: config.deploymentId,
8406
8730
  projectId: config.projectId
8407
8731
  }),
8408
- "openship.service": config.serviceName
8732
+ [OPENSHIP_LABEL.service]: config.serviceName
8409
8733
  },
8410
8734
  ...healthcheck && { Healthcheck: healthcheck },
8411
8735
  ...stopConfig,
@@ -8444,7 +8768,7 @@ var DockerRuntime = class _DockerRuntime {
8444
8768
  throw startErr;
8445
8769
  }
8446
8770
  const data = await container.inspect();
8447
- const { ip, hostPort } = extractNetworkInfo(data);
8771
+ const { ip, hostPort, hostPortByContainerPort } = extractNetworkInfo(data);
8448
8772
  const imageDigest = await this.resolveImageDigest(config.image).catch(() => void 0);
8449
8773
  log({
8450
8774
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8457,6 +8781,7 @@ var DockerRuntime = class _DockerRuntime {
8457
8781
  status: "running",
8458
8782
  ip,
8459
8783
  hostPort,
8784
+ ...hostPortByContainerPort ? { hostPortByContainerPort } : {},
8460
8785
  imageDigest
8461
8786
  };
8462
8787
  }
@@ -8467,6 +8792,16 @@ var DockerRuntime = class _DockerRuntime {
8467
8792
  * RepoDigests) or any inspect error. Works local + SSH (plain inspect, no
8468
8793
  * followProgress). Never throws.
8469
8794
  */
8795
+ /**
8796
+ * PUBLIC because it is the only anchor the update scanner has.
8797
+ *
8798
+ * `resolveDeployedDrift` reads `service_deployment.image_digest` to tell a moved mutable
8799
+ * tag (`:latest`, `:16`) from an unchanged one — a REF comparison can't, since the ref
8800
+ * string is identical either way. A deploy records it here; an ADOPTED stack records
8801
+ * nothing, and an adopted stack is image-mode by definition (registry images, no repo),
8802
+ * so every migrated `postgres:16`/`redis:7` project was told "up to date" forever. The
8803
+ * adopt paths need to read it for a container they did not create.
8804
+ */
8470
8805
  async resolveImageDigest(ref) {
8471
8806
  const info = await this.docker.getImage(ref).inspect();
8472
8807
  const repoDigests = info?.RepoDigests ?? [];
@@ -8483,7 +8818,7 @@ var DockerRuntime = class _DockerRuntime {
8483
8818
  };
8484
8819
 
8485
8820
  export {
8486
- scopedVolumeName,
8821
+ ensureScopedVolumeName,
8487
8822
  isHostPathSource,
8488
8823
  startExecStream,
8489
8824
  startAttachStream,