openship 0.6.5 → 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 (68) hide show
  1. package/dist/bare-7QD623QV.js +29 -0
  2. package/dist/chunk-2ANV5ZQ5.js +88 -0
  3. package/dist/{chunk-LEYSL46B.js → chunk-2VSW6Q6C.js} +3911 -898
  4. package/dist/{chunk-5OUWIPOB.js → chunk-5T4UVOM3.js} +16 -86
  5. package/dist/{chunk-EZPPTWJQ.js → chunk-BLEXTYRK.js} +1 -1
  6. package/dist/{chunk-3ZAGC5NT.js → chunk-CIJV43LH.js} +83 -33
  7. package/dist/{chunk-7S4USQYF.js → chunk-DDFUESPJ.js} +67 -124
  8. package/dist/{chunk-6FH4AXVY.js → chunk-E2X26PXR.js} +1 -1
  9. package/dist/{chunk-XVOBL7BC.js → chunk-FYPFEDPZ.js} +1 -1
  10. package/dist/{chunk-WFRXYWK2.js → chunk-FZ6QCF6B.js} +8 -6
  11. package/dist/{chunk-VHMOQL5Y.js → chunk-GGIAIGUL.js} +125 -80
  12. package/dist/{chunk-WE7BXW6L.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-HPXP6LXZ.js → chunk-J2XM3QSP.js} +8 -5
  16. package/dist/{chunk-6JELW75A.js → chunk-J3K6XABC.js} +5 -5
  17. package/dist/{chunk-VDESJSG6.js → chunk-LHFXSC36.js} +558 -109
  18. package/dist/{chunk-MSZUCNPV.js → chunk-MEE6OZFP.js} +3 -3
  19. package/dist/chunk-N5YZMO33.js +111 -0
  20. package/dist/{chunk-VLQ3UIVO.js → chunk-NRTJRNAE.js} +105 -37
  21. package/dist/chunk-PPEBIY5F.js +2331 -0
  22. package/dist/{chunk-RFWJE7ZN.js → chunk-PVP4RGWT.js} +3 -3
  23. package/dist/{chunk-PZRRL5PC.js → chunk-QHMDCYZ3.js} +15 -4
  24. package/dist/chunk-R4VDPFLS.js +106 -0
  25. package/dist/chunk-RDXT4BQB.js +310 -0
  26. package/dist/{chunk-GRTXMUHA.js → chunk-S6B2Z3OO.js} +145 -60
  27. package/dist/chunk-UWEV6XTS.js +249 -0
  28. package/dist/{chunk-QLX7NBEA.js → chunk-VQM6JHJB.js} +1004 -2046
  29. package/dist/{chunk-A7EWM2KA.js → chunk-VSTFHUCV.js} +44 -50
  30. package/dist/chunk-WU54BXFE.js +70 -0
  31. package/dist/{chunk-LFCSC4IU.js → chunk-XWHWTHEL.js} +313 -469
  32. package/dist/{chunk-7334NSJL.js → chunk-ZPJ74LXE.js} +76 -38
  33. package/dist/cloud-KNNABE3O.js +40 -0
  34. package/dist/{detect-P4CIQBRH.js → detect-G4DR3U2N.js} +9 -6
  35. package/dist/{docker-3K2NEBD6.js → docker-YOVPCIB4.js} +11 -7
  36. package/dist/{docker-edge-executor-YZZ6P7IC.js → docker-edge-executor-QFXD3337.js} +8 -6
  37. package/dist/edge-import-CRFVSR6P.js +56 -0
  38. package/dist/{edge-preflight-ES6FH4JK.js → edge-preflight-MXERVAMG.js} +15 -12
  39. package/dist/{ensure-container-edge-YPITNH45.js → ensure-container-edge-F4JUNQIK.js} +13 -10
  40. package/dist/{executor-DZGX55K2.js → executor-CVPAYYHR.js} +10 -5
  41. package/dist/host-channel-preflight-QTDNGLCK.js +54 -0
  42. package/dist/index.js +139 -92
  43. package/dist/{local-executor-WOFZD72X.js → local-executor-ODC6RANU.js} +3 -2
  44. package/dist/{nginx-NSAHWGJV.js → nginx-KLFF43TC.js} +13 -8
  45. package/dist/{openresty-lua-DR4D3EZ6.js → openresty-lua-VKWUV4GB.js} +8 -5
  46. package/dist/{privilege-YTVI4SKF.js → privilege-7D3SSOL3.js} +6 -5
  47. package/dist/server/engine/samples/amavisd/amavisd.conf +25 -0
  48. package/dist/server/engine/samples/postfix/master.cf +14 -0
  49. package/dist/server/index.js +44526 -33476
  50. package/dist/server/migrations/0105_mail_inbound_rules.sql +80 -0
  51. package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
  52. package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
  53. package/dist/server/migrations/0108_credential.sql +68 -0
  54. package/dist/server/migrations/meta/_journal.json +28 -0
  55. package/dist/setup-I5AJC6NJ.js +25 -0
  56. package/dist/ssh-client-625VWGKP.js +29 -0
  57. package/dist/up-QXTOOVXC.js +61 -0
  58. package/package.json +1 -1
  59. package/dist/bare-XQIPDO4R.js +0 -24
  60. package/dist/chunk-6HOA5JT7.js +0 -124
  61. package/dist/chunk-TM2IQPYU.js +0 -434
  62. package/dist/chunk-XRNRT37W.js +0 -316
  63. package/dist/cloud-3DODOWTR.js +0 -36
  64. package/dist/edge-import-MCJNVTZ2.js +0 -51
  65. package/dist/host-channel-preflight-MUXE3KZU.js +0 -49
  66. package/dist/setup-WUUZURHS.js +0 -22
  67. package/dist/up-XEO3SR6Z.js +0 -58
  68. /package/dist/{dist-es-ZP5QDSJ3.js → dist-es-AXFRS7PS.js} +0 -0
@@ -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-LFCSC4IU.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-TM2IQPYU.js";
36
+ } from "./chunk-H4FS5BED.js";
24
37
  import {
25
38
  DOCKER_DIAL_STDIO_COMMAND,
26
39
  attachDialStdioDiagnostics,
@@ -29,21 +42,28 @@ import {
29
42
  execSshCommand,
30
43
  openDockerDialStdioChannel,
31
44
  openSshUnixSocket
32
- } from "./chunk-5OUWIPOB.js";
45
+ } from "./chunk-5T4UVOM3.js";
33
46
  import {
34
47
  dirOf,
35
48
  ensureOwnedDir
36
- } from "./chunk-WFRXYWK2.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,
62
+ registryConfigKeys,
63
+ registryForImage,
44
64
  safeErrorMessage,
45
65
  withTimeout
46
- } from "./chunk-LEYSL46B.js";
66
+ } from "./chunk-2VSW6Q6C.js";
47
67
  import {
48
68
  __commonJS,
49
69
  __require,
@@ -313,7 +333,7 @@ var require_buffer_list = __commonJS({
313
333
  }
314
334
  }, {
315
335
  key: "join",
316
- value: function join2(s) {
336
+ value: function join3(s) {
317
337
  if (this.length === 0) return "";
318
338
  var p = this.head;
319
339
  var ret = "" + p.data;
@@ -4607,6 +4627,83 @@ var tarFs = __toESM(require_tar_fs(), 1);
4607
4627
  import Dockerode from "dockerode";
4608
4628
  import { createGzip } from "zlib";
4609
4629
  import { PassThrough, Writable } from "stream";
4630
+ import { relative, sep } from "path";
4631
+
4632
+ // ../../packages/adapters/src/runtime/docker-auth.ts
4633
+ import { readFile } from "fs/promises";
4634
+ import { homedir } from "os";
4635
+ import { join } from "path";
4636
+ function firstPresent(values, keys) {
4637
+ if (!values) return void 0;
4638
+ for (const key of keys) {
4639
+ if (Object.hasOwn(values, key)) return { key, value: values[key] };
4640
+ }
4641
+ return void 0;
4642
+ }
4643
+ function dockerConfigJsonFor(auth) {
4644
+ const a = auth;
4645
+ const registry = a.serveraddress?.trim();
4646
+ if (!registry) return null;
4647
+ if (a.identitytoken) {
4648
+ return JSON.stringify({ auths: { [registry]: { identitytoken: a.identitytoken } } });
4649
+ }
4650
+ if (!a.username || !a.password) return null;
4651
+ const encoded = Buffer.from(`${a.username}:${a.password}`).toString("base64");
4652
+ return JSON.stringify({ auths: { [registry]: { auth: encoded } } });
4653
+ }
4654
+ function toAuthConfig(entry, serveraddress) {
4655
+ if (entry.identitytoken) {
4656
+ return { identitytoken: entry.identitytoken, serveraddress };
4657
+ }
4658
+ if (entry.username && entry.password) {
4659
+ return { username: entry.username, password: entry.password, serveraddress };
4660
+ }
4661
+ if (!entry.auth) return void 0;
4662
+ let decoded;
4663
+ try {
4664
+ decoded = Buffer.from(entry.auth, "base64").toString("utf8");
4665
+ } catch {
4666
+ return void 0;
4667
+ }
4668
+ const separator = decoded.indexOf(":");
4669
+ if (separator < 1) return void 0;
4670
+ return {
4671
+ username: decoded.slice(0, separator),
4672
+ password: decoded.slice(separator + 1),
4673
+ serveraddress
4674
+ };
4675
+ }
4676
+ var DockerCredentialHelperError = class extends Error {
4677
+ constructor(registry) {
4678
+ super(
4679
+ `Openship can't read the credentials for ${registry}: they are stored in a Docker credential helper, which is not available inside the Openship container. Add the credentials inline to the Docker config (docker logout ${registry}, remove the credential helper for it, then docker login ${registry}), or use a public image.`
4680
+ );
4681
+ this.name = "DockerCredentialHelperError";
4682
+ }
4683
+ };
4684
+ async function resolveDockerAuth(ref) {
4685
+ const registry = registryForImage(ref);
4686
+ const configPath = join(
4687
+ process.env.DOCKER_CONFIG || join(homedir(), ".docker"),
4688
+ "config.json"
4689
+ );
4690
+ let config;
4691
+ try {
4692
+ config = JSON.parse(await readFile(configPath, "utf8"));
4693
+ } catch {
4694
+ return void 0;
4695
+ }
4696
+ if (!config || typeof config !== "object") return void 0;
4697
+ const keys = registryConfigKeys(registry);
4698
+ const inline = firstPresent(config.auths, keys);
4699
+ const usable = inline ? toAuthConfig(inline.value, inline.key) : void 0;
4700
+ if (usable) return usable;
4701
+ const helperForRegistry = firstPresent(config.credHelpers, keys);
4702
+ if (helperForRegistry || config.credsStore && inline) {
4703
+ throw new DockerCredentialHelperError(registry);
4704
+ }
4705
+ return void 0;
4706
+ }
4610
4707
 
4611
4708
  // ../../packages/adapters/src/runtime/source-tarball.ts
4612
4709
  function githubTarballUrl(repoUrl, ref) {
@@ -4634,6 +4731,10 @@ var MODE_SUFFIX = /:(ro|rw|z|Z|nocopy)$/;
4634
4731
  function scopedVolumeName(slug, name) {
4635
4732
  return `openship-${slug}-${name}`;
4636
4733
  }
4734
+ function ensureScopedVolumeName(slug, source) {
4735
+ const prefix = `openship-${slug}-`;
4736
+ return source.startsWith(prefix) ? source : scopedVolumeName(slug, source);
4737
+ }
4637
4738
  function isHostPathSource(source) {
4638
4739
  return source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.startsWith("~");
4639
4740
  }
@@ -4792,6 +4893,9 @@ function resolveComposeCmd(config) {
4792
4893
  if (config.commandArgv != null) return config.commandArgv;
4793
4894
  return config.command ? ["sh", "-c", config.command] : void 0;
4794
4895
  }
4896
+ function resolveComposeEntrypoint(advanced) {
4897
+ return advanced?.entrypoint ?? void 0;
4898
+ }
4795
4899
 
4796
4900
  // ../../packages/adapters/src/runtime/resource-limits.ts
4797
4901
  function dockerResourceLimits(resources) {
@@ -4843,8 +4947,8 @@ function describeResourceLimits(resources) {
4843
4947
  // ../../packages/adapters/src/runtime/docker-transport.ts
4844
4948
  import { createHash } from "crypto";
4845
4949
  import { readFileSync } from "fs";
4846
- import { homedir } from "os";
4847
- import { join } from "path";
4950
+ import { homedir as homedir2 } from "os";
4951
+ import { join as join2 } from "path";
4848
4952
 
4849
4953
  // ../../packages/adapters/src/runtime/docker-ssh-agent.ts
4850
4954
  import net2 from "net";
@@ -5317,7 +5421,7 @@ function createDockerSshBridge(opts) {
5317
5421
  var DEFAULT_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
5318
5422
  var contextSocketCache = /* @__PURE__ */ new Map();
5319
5423
  function resolveDockerContextSocket(env) {
5320
- const configDir = env.DOCKER_CONFIG?.trim() || join(homedir(), ".docker");
5424
+ const configDir = env.DOCKER_CONFIG?.trim() || join2(homedir2(), ".docker");
5321
5425
  let name = env.DOCKER_CONTEXT?.trim();
5322
5426
  const cacheKey = `${configDir}\0${name ?? ""}`;
5323
5427
  const cached = contextSocketCache.get(cacheKey);
@@ -5325,13 +5429,13 @@ function resolveDockerContextSocket(env) {
5325
5429
  let socket = null;
5326
5430
  try {
5327
5431
  if (!name) {
5328
- const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"));
5432
+ const config = JSON.parse(readFileSync(join2(configDir, "config.json"), "utf8"));
5329
5433
  name = typeof config.currentContext === "string" ? config.currentContext.trim() : "";
5330
5434
  }
5331
5435
  if (name && name !== "default") {
5332
5436
  const digest = createHash("sha256").update(name).digest("hex");
5333
5437
  const meta = JSON.parse(
5334
- readFileSync(join(configDir, "contexts", "meta", digest, "meta.json"), "utf8")
5438
+ readFileSync(join2(configDir, "contexts", "meta", digest, "meta.json"), "utf8")
5335
5439
  );
5336
5440
  const host = meta.Endpoints?.docker?.Host;
5337
5441
  if (typeof host === "string" && host.startsWith("unix://")) {
@@ -5620,9 +5724,10 @@ function parsePortBindings(portSpecs) {
5620
5724
  }
5621
5725
  return { exposedPorts, portBindings };
5622
5726
  }
5623
- function packBuildContext(contextDir, entries, cancelSignal) {
5727
+ function packBuildContext(contextDir, entries, cancelSignal, ignoreContextPath) {
5624
5728
  const controller = new AbortController();
5625
- 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 });
5626
5731
  const body = pack2.pipe(createGzip());
5627
5732
  let contextError = null;
5628
5733
  const capture = (err) => {
@@ -5754,14 +5859,22 @@ function extractNetworkInfo(data) {
5754
5859
  break;
5755
5860
  }
5756
5861
  }
5862
+ const hostPortByContainerPort = {};
5757
5863
  let hostPort;
5758
- for (const bindings of Object.values(data.NetworkSettings.Ports ?? {})) {
5759
- if (bindings?.[0]?.HostPort) {
5760
- hostPort = parseInt(bindings[0].HostPort, 10);
5761
- break;
5762
- }
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;
5763
5872
  }
5764
- return { ip, hostPort };
5873
+ return {
5874
+ ip,
5875
+ hostPort,
5876
+ ...Object.keys(hostPortByContainerPort).length ? { hostPortByContainerPort } : {}
5877
+ };
5765
5878
  }
5766
5879
  function parseLoadedImageRef(output) {
5767
5880
  const matches = [...(output || "").matchAll(/Loaded image(?: ID)?:\s*(\S+)/gi)];
@@ -5918,11 +6031,11 @@ var DockerRuntime = class _DockerRuntime {
5918
6031
  /** Labels applied to both build images and deploy containers. */
5919
6032
  labels(config) {
5920
6033
  const l = {
5921
- "openship.project": config.projectId
6034
+ [OPENSHIP_LABEL.project]: config.projectId
5922
6035
  };
5923
- if (config.deploymentId) l["openship.deployment"] = config.deploymentId;
5924
- if (config.sessionId) l["openship.build"] = config.sessionId;
5925
- 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;
5926
6039
  return l;
5927
6040
  }
5928
6041
  // ── Build lifecycle ────────────────────────────────────────────────────
@@ -5933,7 +6046,7 @@ var DockerRuntime = class _DockerRuntime {
5933
6046
  */
5934
6047
  async estimateContextSize(dir) {
5935
6048
  const { stat, readdir } = await import("fs/promises");
5936
- const { join: join2 } = await import("path");
6049
+ const { join: join3 } = await import("path");
5937
6050
  let total = 0;
5938
6051
  const stack = [dir];
5939
6052
  while (stack.length) {
@@ -5945,7 +6058,7 @@ var DockerRuntime = class _DockerRuntime {
5945
6058
  continue;
5946
6059
  }
5947
6060
  for (const entry of entries) {
5948
- const full = join2(current, entry.name);
6061
+ const full = join3(current, entry.name);
5949
6062
  if (entry.isDirectory()) {
5950
6063
  stack.push(full);
5951
6064
  } else if (entry.isFile()) {
@@ -5964,12 +6077,20 @@ var DockerRuntime = class _DockerRuntime {
5964
6077
  logger.log(`[${step}] ${label} ${message}`);
5965
6078
  logger.step(step, status, message);
5966
6079
  }
5967
- handleBuildEvent(event, logger) {
6080
+ handleBuildEvent(event, logger, trace) {
5968
6081
  const errorMessage = event.errorDetail?.message ?? event.error;
5969
6082
  if (errorMessage) {
5970
6083
  logger.log(errorMessage, "error");
5971
6084
  return errorMessage;
5972
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
+ }
5973
6094
  if (event.stream) {
5974
6095
  const line = event.stream.trim();
5975
6096
  if (!line) return null;
@@ -6026,6 +6147,12 @@ var DockerRuntime = class _DockerRuntime {
6026
6147
  if (/returned a non-zero code:\s*\d+/i.test(line)) {
6027
6148
  return line;
6028
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
+ }
6029
6156
  if (/\/workspace\/package\.json/i.test(line) && /ENOENT/i.test(line)) {
6030
6157
  return "Docker build ran from /workspace but package.json was not found there. The configured rootDirectory is likely empty or incorrect.";
6031
6158
  }
@@ -6077,12 +6204,35 @@ var DockerRuntime = class _DockerRuntime {
6077
6204
  { excludes: [] }
6078
6205
  );
6079
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
+ }
6080
6227
  /**
6081
6228
  * Run native `docker build` on the remote host against an already-transferred
6082
- * context dir. One image; `dockerfileName` selects which Dockerfile in the
6083
- * 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).
6084
6233
  */
6085
- async buildImageOnRemote(config, remoteContextDir, dockerfileName, tag, log, signal) {
6234
+ async buildImageOnRemote(config, remoteBuildDir, dockerfileName, tag, log, opts) {
6235
+ const signal = opts?.signal;
6086
6236
  const executor = this.connectionOptions?.executor;
6087
6237
  if (!executor) throw new Error("SSH build path requires an executor on connectionOptions");
6088
6238
  const buildArgs = Object.entries({
@@ -6093,7 +6243,22 @@ var DockerRuntime = class _DockerRuntime {
6093
6243
  this.labels({ projectId: config.projectId, sessionId: config.sessionId })
6094
6244
  ).map(([k, v]) => `--label ${sq(`${k}=${v}`)}`).join(" ");
6095
6245
  const dockerfileFlag = dockerfileName && dockerfileName !== "Dockerfile" ? ` -f ${sq(dockerfileName)}` : "";
6096
- 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} .`;
6097
6262
  log.log(`Running on remote: ${buildCmd}`);
6098
6263
  log.log("\u2500\u2500\u2500 docker build output \u2500\u2500\u2500");
6099
6264
  this.emitDockerStep(log, "install", "running", "Running install inside container (docker build)");
@@ -6205,15 +6370,50 @@ var DockerRuntime = class _DockerRuntime {
6205
6370
  * (clone-on-server). Mirrors resolveServiceDockerfile but probes the remote
6206
6371
  * tree with `test -f` instead of the local FS: a repository Dockerfile
6207
6372
  * candidate is used when present; otherwise a Dockerfile is generated locally
6208
- * (pure fn) and written to the remote tree. Returns the dockerfile path
6209
- * 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.
6210
6379
  */
6211
- async resolveRemoteDockerfile(config, remoteContextDir, generatedName, requireRepositoryDockerfile) {
6380
+ async resolveRemoteDockerfile(config, remoteContextDir, generatedName, requireRepositoryDockerfile, log) {
6212
6381
  const executor = this.connectionOptions?.executor;
6213
6382
  if (!executor) throw new Error("Clone-on-server requires an SSH executor on connectionOptions");
6214
- for (const candidate of resolveDockerfileCandidates(config.rootDirectory, config.dockerfilePath)) {
6215
- const out = await executor.exec(`test -f ${sq(`${remoteContextDir}/${candidate}`)} && echo yes || true`).catch(() => "");
6216
- 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
+ );
6217
6417
  }
6218
6418
  if (requireRepositoryDockerfile) {
6219
6419
  const expected = config.dockerfilePath?.trim() || "Dockerfile";
@@ -6222,7 +6422,7 @@ var DockerRuntime = class _DockerRuntime {
6222
6422
  );
6223
6423
  }
6224
6424
  await executor.writeFile(`${remoteContextDir}/${generatedName}`, generateDockerfile(config));
6225
- return generatedName;
6425
+ return { remoteBuildDir, dockerfileName: generatedName };
6226
6426
  }
6227
6427
  async buildViaSshTarPipe(config, buildContext, tag, log, signal) {
6228
6428
  const remoteContextDir = `/tmp/openship-build-${config.sessionId}`;
@@ -6230,11 +6430,11 @@ var DockerRuntime = class _DockerRuntime {
6230
6430
  await this.transferBuildContext(buildContext.contextDir, remoteContextDir, log);
6231
6431
  await this.buildImageOnRemote(
6232
6432
  config,
6233
- remoteContextDir,
6433
+ buildContext.contextSubdir ? `${remoteContextDir}/${buildContext.contextSubdir}` : remoteContextDir,
6234
6434
  buildContext.dockerfileName,
6235
6435
  tag,
6236
6436
  log,
6237
- signal
6437
+ { signal, requiresBuildKit: buildContext.requiresBuildKit }
6238
6438
  );
6239
6439
  } finally {
6240
6440
  this.connectionOptions?.executor?.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
@@ -6242,6 +6442,41 @@ var DockerRuntime = class _DockerRuntime {
6242
6442
  await buildContext.cleanup();
6243
6443
  }
6244
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
+ }
6245
6480
  /**
6246
6481
  * Dockerode build path. Used for local socket and TCP transports, plus
6247
6482
  * SSH transports that didn't get an executor wired in (shouldn't happen
@@ -6254,10 +6489,12 @@ var DockerRuntime = class _DockerRuntime {
6254
6489
  async buildViaDockerode(config, buildContext, tag, log, cancelSignal) {
6255
6490
  log.log(`Streaming build context to Docker daemon - image tag: ${tag}`);
6256
6491
  const { body, abortSignal, takeError } = packBuildContext(
6257
- buildContext.contextDir,
6492
+ buildContext.buildContextDir,
6258
6493
  buildContext.contextEntries,
6259
- cancelSignal
6494
+ cancelSignal,
6495
+ buildContext.ignoreContextPath
6260
6496
  );
6497
+ const builder = this.selectDockerodeBuilder(config, buildContext.requiresBuildKit, log);
6261
6498
  try {
6262
6499
  const stream = await this.docker.buildImage(body, {
6263
6500
  t: tag,
@@ -6272,10 +6509,11 @@ var DockerRuntime = class _DockerRuntime {
6272
6509
  // needs several GB). Opt-in only; see dockerBuildResourceLimits.
6273
6510
  ...dockerBuildResourceLimits(config.resources),
6274
6511
  forcerm: true,
6512
+ ...builder.options,
6275
6513
  abortSignal
6276
6514
  });
6277
6515
  log.log("Connected to Docker daemon. Build output follows:");
6278
- await this.streamDockerodeBuild(stream, log);
6516
+ await this.streamDockerodeBuild(stream, log, builder.trace);
6279
6517
  log.log("Docker daemon finished streaming build output. Finalizing image...\n");
6280
6518
  } catch (err) {
6281
6519
  const contextErr = takeError();
@@ -6289,7 +6527,7 @@ var DockerRuntime = class _DockerRuntime {
6289
6527
  * idle timeout + keepalive, and throw on a fatal build event. Shared by the
6290
6528
  * single-image dockerode path and the local batch build (buildImages).
6291
6529
  */
6292
- async streamDockerodeBuild(stream, log) {
6530
+ async streamDockerodeBuild(stream, log, trace) {
6293
6531
  let fatalBuildError = null;
6294
6532
  await new Promise((resolve, reject) => {
6295
6533
  let settled = false;
@@ -6346,7 +6584,7 @@ var DockerRuntime = class _DockerRuntime {
6346
6584
  },
6347
6585
  (event) => {
6348
6586
  resetIdleTimer();
6349
- fatalBuildError ??= this.handleBuildEvent(event, log);
6587
+ fatalBuildError ??= this.handleBuildEvent(event, log, trace);
6350
6588
  }
6351
6589
  );
6352
6590
  });
@@ -6415,20 +6653,16 @@ var DockerRuntime = class _DockerRuntime {
6415
6653
  await this.cloneSourceOnRemote(config, remoteContextDir, log);
6416
6654
  if (abort.signal.aborted) throw new BuildCancelledError();
6417
6655
  this.emitDockerStep(log, "clone", "completed", "Source cloned on the server");
6418
- const dockerfileName = await this.resolveRemoteDockerfile(
6656
+ const { remoteBuildDir, dockerfileName } = await this.resolveRemoteDockerfile(
6419
6657
  config,
6420
6658
  remoteContextDir,
6421
6659
  "Dockerfile.openship",
6422
- config.stack === "docker"
6423
- );
6424
- await this.buildImageOnRemote(
6425
- config,
6426
- remoteContextDir,
6427
- dockerfileName,
6428
- tag,
6429
- log,
6430
- abort.signal
6660
+ config.stack === "docker",
6661
+ log
6431
6662
  );
6663
+ await this.buildImageOnRemote(config, remoteBuildDir, dockerfileName, tag, log, {
6664
+ signal: abort.signal
6665
+ });
6432
6666
  } finally {
6433
6667
  sshExecutor.exec(`rm -rf ${sq(remoteContextDir)}`).catch(() => {
6434
6668
  });
@@ -6458,7 +6692,9 @@ var DockerRuntime = class _DockerRuntime {
6458
6692
  } catch {
6459
6693
  this.emitDockerStep(log, "clone", "completed", "Docker build context ready");
6460
6694
  }
6461
- if (buildContext.rootDirectory) {
6695
+ if (buildContext.contextSubdir) {
6696
+ log.log(`Using Docker build context: ${buildContext.contextSubdir}`);
6697
+ } else if (buildContext.rootDirectory) {
6462
6698
  log.log(`Using Docker build root: ${buildContext.rootDirectory}`);
6463
6699
  }
6464
6700
  if (buildContext.usesRepositoryDockerfile) {
@@ -6527,6 +6763,7 @@ var DockerRuntime = class _DockerRuntime {
6527
6763
  await this.extractDocRootViaDaemon(tag, docRoot, hostOutDir);
6528
6764
  }
6529
6765
  }
6766
+ await this.pruneBuildFilesFromDocRoot(config, hostOutDir, sshExecutor ?? null, log);
6530
6767
  log.log(`Static files ready at ${hostOutDir}
6531
6768
  `);
6532
6769
  } finally {
@@ -6611,6 +6848,74 @@ var DockerRuntime = class _DockerRuntime {
6611
6848
  * expensive one: every extractor can succeed having copied nothing, which deploys
6612
6849
  * green and then 404s the entire site.
6613
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
+ }
6614
6919
  assertDocRootFilled(isEmpty, docRoot) {
6615
6920
  if (!isEmpty) return;
6616
6921
  throw new Error(
@@ -6663,12 +6968,12 @@ var DockerRuntime = class _DockerRuntime {
6663
6968
  */
6664
6969
  async extractDocRootViaBindMount(tag, docRoot, hostOutDir) {
6665
6970
  const { mkdir, readdir, writeFile, rm } = await import("fs/promises");
6666
- const { join: join2 } = await import("path");
6971
+ const { join: join3 } = await import("path");
6667
6972
  const { randomBytes } = await import("crypto");
6668
6973
  await mkdir(hostOutDir, { recursive: true });
6669
6974
  const OUT = "/__openship_out";
6670
6975
  const probe = `.openship-extract-probe-${randomBytes(6).toString("hex")}`;
6671
- await writeFile(join2(hostOutDir, probe), "probe");
6976
+ await writeFile(join3(hostOutDir, probe), "probe");
6672
6977
  const container = await this.docker.createContainer({
6673
6978
  Image: tag,
6674
6979
  // Override the ENTRYPOINT: builder base images (node, bun) ship a
@@ -6708,7 +7013,7 @@ var DockerRuntime = class _DockerRuntime {
6708
7013
  } finally {
6709
7014
  await container.remove({ force: true }).catch(() => {
6710
7015
  });
6711
- await rm(join2(hostOutDir, probe), { force: true }).catch(() => {
7016
+ await rm(join3(hostOutDir, probe), { force: true }).catch(() => {
6712
7017
  });
6713
7018
  }
6714
7019
  }
@@ -6797,7 +7102,25 @@ var DockerRuntime = class _DockerRuntime {
6797
7102
  prepareLogger.step("clone", "completed", "Source cloned on the server");
6798
7103
  } else {
6799
7104
  prepareLogger.step("clone", "running", "Preparing shared build context...");
6800
- 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
+ });
6801
7124
  }
6802
7125
  const resolvedList = await Promise.all(
6803
7126
  specs.map(async (spec) => {
@@ -6805,34 +7128,54 @@ var DockerRuntime = class _DockerRuntime {
6805
7128
  const requireRepo = spec.requireRepositoryDockerfile ?? spec.config.stack === "docker";
6806
7129
  try {
6807
7130
  if (cloneOnServer) {
6808
- const dockerfileName = await this.resolveRemoteDockerfile(
7131
+ const { remoteBuildDir, dockerfileName } = await this.resolveRemoteDockerfile(
6809
7132
  spec.config,
6810
7133
  remoteContextDir,
6811
7134
  generatedName,
6812
- requireRepo
7135
+ requireRepo,
7136
+ spec.logger
6813
7137
  );
6814
7138
  return {
6815
7139
  spec,
6816
7140
  dockerfileName,
7141
+ remoteBuildSubdir: dockerBuildContextDirectory(spec.config),
7142
+ remoteBuildDir,
7143
+ buildContextDir: null,
6817
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,
6818
7149
  error: null
6819
7150
  };
6820
7151
  }
6821
7152
  const resolved = await resolveServiceDockerfile(tree.contextDir, spec.config, {
6822
7153
  requireRepositoryDockerfile: requireRepo,
6823
- generatedName
7154
+ generatedName,
7155
+ dockerignorePruned: tree.dockerignorePruned,
7156
+ onLog: spec.logger.callback
6824
7157
  });
6825
7158
  return {
6826
7159
  spec,
6827
7160
  dockerfileName: resolved.dockerfileName,
7161
+ remoteBuildSubdir: resolved.contextSubdir,
7162
+ remoteBuildDir: resolved.contextSubdir ? `${remoteContextDir}/${resolved.contextSubdir}` : remoteContextDir,
7163
+ buildContextDir: resolved.buildContextDir,
6828
7164
  contextEntries: resolved.contextEntries,
7165
+ ignoreContextPath: resolved.ignoreContextPath,
7166
+ requiresBuildKit: resolved.requiresBuildKit,
6829
7167
  error: null
6830
7168
  };
6831
7169
  } catch (err) {
6832
7170
  return {
6833
7171
  spec,
6834
7172
  dockerfileName: null,
7173
+ remoteBuildSubdir: "",
7174
+ remoteBuildDir: remoteContextDir,
7175
+ buildContextDir: null,
6835
7176
  contextEntries: null,
7177
+ ignoreContextPath: void 0,
7178
+ requiresBuildKit: false,
6836
7179
  error: safeErrorMessage(err)
6837
7180
  };
6838
7181
  }
@@ -6854,7 +7197,17 @@ var DockerRuntime = class _DockerRuntime {
6854
7197
  }
6855
7198
  }
6856
7199
  const results = [];
6857
- 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) {
6858
7211
  const startedAt = Date.now();
6859
7212
  const tag = this.imageTag(spec.config.slug, spec.config.sessionId);
6860
7213
  const signal = abortControllers.get(spec.config.sessionId)?.signal;
@@ -6877,20 +7230,30 @@ var DockerRuntime = class _DockerRuntime {
6877
7230
  }
6878
7231
  spec.onStart?.();
6879
7232
  try {
7233
+ if (remoteBuildSubdir) {
7234
+ spec.logger.log(`Using Docker build context: ${remoteBuildSubdir}
7235
+ `);
7236
+ }
6880
7237
  if (isSsh) {
6881
7238
  await this.buildImageOnRemote(
6882
7239
  spec.config,
6883
- remoteContextDir,
7240
+ remoteBuildDir,
6884
7241
  dockerfileName,
6885
7242
  tag,
6886
7243
  spec.logger,
6887
- signal
7244
+ { signal, requiresBuildKit }
6888
7245
  );
6889
7246
  } else {
6890
7247
  const { body, abortSignal, takeError } = packBuildContext(
6891
- tree.contextDir,
7248
+ buildContextDir ?? tree.contextDir,
6892
7249
  contextEntries ?? [],
6893
- signal
7250
+ signal,
7251
+ ignoreContextPath
7252
+ );
7253
+ const builder = this.selectDockerodeBuilder(
7254
+ spec.config,
7255
+ requiresBuildKit,
7256
+ spec.logger
6894
7257
  );
6895
7258
  try {
6896
7259
  const stream = await this.docker.buildImage(body, {
@@ -6903,9 +7266,10 @@ var DockerRuntime = class _DockerRuntime {
6903
7266
  buildargs: { ...spec.config.envVars, NODE_ENV: "production" },
6904
7267
  ...dockerBuildResourceLimits(spec.config.resources),
6905
7268
  forcerm: true,
7269
+ ...builder.options,
6906
7270
  abortSignal
6907
7271
  });
6908
- await this.streamDockerodeBuild(stream, spec.logger);
7272
+ await this.streamDockerodeBuild(stream, spec.logger, builder.trace);
6909
7273
  } catch (err) {
6910
7274
  const contextErr = takeError();
6911
7275
  throw contextErr ? new Error(`Failed to read Docker build context: ${safeErrorMessage(contextErr)}`, { cause: contextErr }) : err;
@@ -6991,10 +7355,10 @@ var DockerRuntime = class _DockerRuntime {
6991
7355
  }
6992
7356
  const containers = await this.docker.listContainers({
6993
7357
  all: true,
6994
- filters: { label: ["openship.build"] }
7358
+ filters: { label: [OPENSHIP_LABEL.build] }
6995
7359
  });
6996
7360
  for (const c of containers) {
6997
- const buildId = c.Labels?.["openship.build"];
7361
+ const buildId = c.Labels?.[OPENSHIP_LABEL.build];
6998
7362
  if (!buildId || !cancelCovers(sessionId, buildId)) continue;
6999
7363
  try {
7000
7364
  await this.docker.getContainer(c.Id).remove({ force: true });
@@ -7015,10 +7379,18 @@ var DockerRuntime = class _DockerRuntime {
7015
7379
  throw new Error("Docker deploy requires an imageRef (built image tag)");
7016
7380
  }
7017
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
+ }
7018
7390
  const env = [
7019
7391
  ...config.portless ? [] : [`PORT=${config.port}`],
7020
7392
  `NODE_ENV=${config.environment === "production" ? "production" : "development"}`,
7021
- ...Object.entries(config.envVars).map(([k, v]) => `${k}=${v}`)
7393
+ ...projectEnv.entries.map(([k, v]) => `${k}=${v}`)
7022
7394
  ];
7023
7395
  const cmd = config.startCommand ? ["sh", "-c", config.startCommand] : void 0;
7024
7396
  const restartPolicy = resolveRestartPolicy(config.restartPolicy);
@@ -7136,6 +7508,11 @@ var DockerRuntime = class _DockerRuntime {
7136
7508
  await container.restart();
7137
7509
  }
7138
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
+ }
7139
7516
  const image = this.docker.getImage(imageRef);
7140
7517
  try {
7141
7518
  await image.remove({ force: true });
@@ -7144,16 +7521,18 @@ var DockerRuntime = class _DockerRuntime {
7144
7521
  }
7145
7522
  }
7146
7523
  async destroy(containerId) {
7147
- if (containerId.startsWith("/")) {
7148
- const executor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
7149
- if (executor) {
7150
- await executor.exec(`rm -rf ${sq(containerId)}`).catch(() => {
7151
- });
7152
- } else {
7153
- const { rm } = await import("fs/promises");
7154
- await rm(containerId, { recursive: true, force: true }).catch(() => {
7155
- });
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;
7156
7534
  }
7535
+ await removeManagedArtifact(null, containerId);
7157
7536
  return;
7158
7537
  }
7159
7538
  const container = this.docker.getContainer(containerId);
@@ -7193,8 +7572,8 @@ var DockerRuntime = class _DockerRuntime {
7193
7572
  return images.map((img) => ({
7194
7573
  id: img.Id,
7195
7574
  repoTags: (img.RepoTags ?? []).filter((t) => t && t !== "<none>:<none>"),
7196
- buildId: img.Labels?.["openship.build"],
7197
- deploymentId: img.Labels?.["openship.deployment"],
7575
+ buildId: img.Labels?.[OPENSHIP_LABEL.build],
7576
+ deploymentId: img.Labels?.[OPENSHIP_LABEL.deployment],
7198
7577
  size: img.Size ?? 0
7199
7578
  }));
7200
7579
  }
@@ -7239,7 +7618,7 @@ var DockerRuntime = class _DockerRuntime {
7239
7618
  return containers.map((c) => ({
7240
7619
  containerId: c.Id,
7241
7620
  status: stateMap[(c.State ?? "").toLowerCase().trim()] ?? "stopped",
7242
- serviceName: c.Labels?.["openship.service"]
7621
+ serviceName: c.Labels?.[OPENSHIP_LABEL.service]
7243
7622
  }));
7244
7623
  }
7245
7624
  // ── Rollback primitives (retention half only) ────────────────────────
@@ -7268,17 +7647,18 @@ var DockerRuntime = class _DockerRuntime {
7268
7647
  }
7269
7648
  }
7270
7649
  async purge(deployment) {
7650
+ const failures = [];
7271
7651
  if (deployment.containerId) {
7272
- try {
7273
- await this.destroy(deployment.containerId);
7274
- } catch {
7275
- }
7652
+ await this.destroy(deployment.containerId).catch((err) => failures.push(err));
7276
7653
  }
7277
7654
  if (deployment.imageRef && ownsBuiltImage(deployment.imageRef)) {
7278
- try {
7279
- await this.removeImage(deployment.imageRef);
7280
- } catch {
7281
- }
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
+ );
7282
7662
  }
7283
7663
  }
7284
7664
  /**
@@ -7425,13 +7805,39 @@ var DockerRuntime = class _DockerRuntime {
7425
7805
  }
7426
7806
  const executor = this.connectionOptions?.executor;
7427
7807
  if (executor) {
7428
- await executor.exec(`docker pull ${sq(ref)}`, { timeout: 10 * 6e4 });
7808
+ const timeout = 10 * 6e4;
7809
+ const auth = await this.connectionOptions?.resolveRegistryAuth?.(ref);
7810
+ const config = auth ? dockerConfigJsonFor(auth) : null;
7811
+ if (!config) {
7812
+ await executor.exec(`docker pull ${sq(ref)}`, { timeout });
7813
+ return;
7814
+ }
7815
+ const dir = `/tmp/openship-pull-${crypto.randomUUID()}`;
7816
+ try {
7817
+ await executor.exec(`mkdir -m 700 ${sq(dir)}`);
7818
+ await executor.writeFile(`${dir}/config.json`, config);
7819
+ await executor.exec(`chmod 600 ${sq(dir)}/config.json`);
7820
+ await executor.exec(
7821
+ `DOCKER_CONFIG=${sq(dir)} docker pull ${sq(ref)}; rc=$?; rm -rf ${sq(dir)}; exit $rc`,
7822
+ { timeout }
7823
+ );
7824
+ } finally {
7825
+ await executor.exec(`rm -rf ${sq(dir)}`).catch(() => {
7826
+ });
7827
+ }
7429
7828
  return;
7430
7829
  }
7431
- const stream = await this.docker.pull(ref);
7432
- await new Promise((resolve, reject) => {
7433
- this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
7434
- });
7830
+ const authconfig = this.connectionOptions?.resolveRegistryAuth ? await this.connectionOptions.resolveRegistryAuth(ref) : await resolveDockerAuth(ref);
7831
+ try {
7832
+ const stream = await this.docker.pull(ref, authconfig ? { authconfig } : {});
7833
+ await new Promise((resolve, reject) => {
7834
+ this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
7835
+ });
7836
+ } catch (err) {
7837
+ throw new Error(
7838
+ `Failed to pull ${ref} from ${registryForImage(ref)}: ${safeErrorMessage(err)}`
7839
+ );
7840
+ }
7435
7841
  }
7436
7842
  /** Is this image tag present on THIS daemon? Distinguishes a locally-built
7437
7843
  * image (must be transferred cross-server) from a registry tag (the target
@@ -7570,7 +7976,7 @@ var DockerRuntime = class _DockerRuntime {
7570
7976
  };
7571
7977
  const startedAt = data.State.StartedAt;
7572
7978
  const uptimeSeconds = startedAt && data.State.Running ? Math.floor((Date.now() - new Date(startedAt).getTime()) / 1e3) : void 0;
7573
- const { ip, hostPort } = extractNetworkInfo(data);
7979
+ const { ip, hostPort, hostPortByContainerPort } = extractNetworkInfo(data);
7574
7980
  let status;
7575
7981
  if (data.State.Running) {
7576
7982
  status = "running";
@@ -7585,6 +7991,7 @@ var DockerRuntime = class _DockerRuntime {
7585
7991
  status,
7586
7992
  ip,
7587
7993
  hostPort,
7994
+ ...hostPortByContainerPort ? { hostPortByContainerPort } : {},
7588
7995
  uptimeSeconds: uptimeSeconds && uptimeSeconds > 0 ? uptimeSeconds : void 0
7589
7996
  };
7590
7997
  }
@@ -8070,7 +8477,7 @@ var DockerRuntime = class _DockerRuntime {
8070
8477
  const ownNames = /* @__PURE__ */ new Set();
8071
8478
  const foreign = /* @__PURE__ */ new Map();
8072
8479
  for (const c of containers) {
8073
- const owner = c.Labels?.["openship.project"];
8480
+ const owner = c.Labels?.[OPENSHIP_LABEL.project];
8074
8481
  if (!owner) continue;
8075
8482
  for (const m of c.Mounts ?? []) {
8076
8483
  if (m.Type !== "volume" || !m.Name || !named.has(m.Name)) continue;
@@ -8124,7 +8531,7 @@ var DockerRuntime = class _DockerRuntime {
8124
8531
  );
8125
8532
  if (onNetwork) continue;
8126
8533
  if (this.cannotJoinNetworks(c)) continue;
8127
- const service = c.Labels?.["openship.service"];
8534
+ const service = c.Labels?.[OPENSHIP_LABEL.service];
8128
8535
  try {
8129
8536
  await network.connect({
8130
8537
  Container: c.Id,
@@ -8148,7 +8555,7 @@ var DockerRuntime = class _DockerRuntime {
8148
8555
  * doesn't exist (source not deployed) is skipped and nothing here ever throws —
8149
8556
  * a link networking failure must never fail the consumer's deploy.
8150
8557
  */
8151
- async attachToExternalNetworks(projectId, networkNames) {
8558
+ async attachToExternalNetworks(projectId, networkNames, extraContainerIds = []) {
8152
8559
  if (networkNames.length === 0) return;
8153
8560
  let containers;
8154
8561
  try {
@@ -8159,6 +8566,22 @@ var DockerRuntime = class _DockerRuntime {
8159
8566
  } catch {
8160
8567
  return;
8161
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
+ }
8162
8585
  for (const name of networkNames) {
8163
8586
  const network = this.docker.getNetwork(name);
8164
8587
  let netId;
@@ -8212,11 +8635,20 @@ var DockerRuntime = class _DockerRuntime {
8212
8635
  await existing.remove({ force: true });
8213
8636
  } catch {
8214
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
+ }
8215
8646
  const env = [
8216
8647
  ...config.publicPort && config.environment.PORT === void 0 ? [`PORT=${config.publicPort}`] : [],
8217
- ...Object.entries(config.environment).map(([k, v]) => `${k}=${v}`)
8648
+ ...serviceEnv.entries.map(([k, v]) => `${k}=${v}`)
8218
8649
  ];
8219
8650
  const cmd = resolveComposeCmd(config);
8651
+ const entrypoint = resolveComposeEntrypoint(config.advanced);
8220
8652
  const sharedNetwork = config.namespaces?.network;
8221
8653
  const sharesNetns = !!sharedNetwork && sharedNetwork !== "none";
8222
8654
  const ownsProjectEndpoint = ownsNetworkEndpoint(sharedNetwork);
@@ -8282,6 +8714,12 @@ var DockerRuntime = class _DockerRuntime {
8282
8714
  name: containerName,
8283
8715
  Image: config.image,
8284
8716
  Cmd: cmd,
8717
+ // Entrypoint (#575). SPREAD rather than assigned, because `undefined` and `[]`
8718
+ // must reach Docker differently: absent leaves the image's own ENTRYPOINT
8719
+ // alone, while `Entrypoint: []` clears it. Assigning `undefined` would send the
8720
+ // key, and the same conditional-spread shape is why `toStopConfig` returns an
8721
+ // object instead of nullable fields.
8722
+ ...entrypoint === void 0 ? {} : { Entrypoint: entrypoint },
8285
8723
  Env: env,
8286
8724
  // Docker rejects an explicit hostname when the network namespace is another
8287
8725
  // container's — that container owns the UTS identity too.
@@ -8291,7 +8729,7 @@ var DockerRuntime = class _DockerRuntime {
8291
8729
  deploymentId: config.deploymentId,
8292
8730
  projectId: config.projectId
8293
8731
  }),
8294
- "openship.service": config.serviceName
8732
+ [OPENSHIP_LABEL.service]: config.serviceName
8295
8733
  },
8296
8734
  ...healthcheck && { Healthcheck: healthcheck },
8297
8735
  ...stopConfig,
@@ -8330,7 +8768,7 @@ var DockerRuntime = class _DockerRuntime {
8330
8768
  throw startErr;
8331
8769
  }
8332
8770
  const data = await container.inspect();
8333
- const { ip, hostPort } = extractNetworkInfo(data);
8771
+ const { ip, hostPort, hostPortByContainerPort } = extractNetworkInfo(data);
8334
8772
  const imageDigest = await this.resolveImageDigest(config.image).catch(() => void 0);
8335
8773
  log({
8336
8774
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8343,6 +8781,7 @@ var DockerRuntime = class _DockerRuntime {
8343
8781
  status: "running",
8344
8782
  ip,
8345
8783
  hostPort,
8784
+ ...hostPortByContainerPort ? { hostPortByContainerPort } : {},
8346
8785
  imageDigest
8347
8786
  };
8348
8787
  }
@@ -8353,6 +8792,16 @@ var DockerRuntime = class _DockerRuntime {
8353
8792
  * RepoDigests) or any inspect error. Works local + SSH (plain inspect, no
8354
8793
  * followProgress). Never throws.
8355
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
+ */
8356
8805
  async resolveImageDigest(ref) {
8357
8806
  const info = await this.docker.getImage(ref).inspect();
8358
8807
  const repoDigests = info?.RepoDigests ?? [];
@@ -8369,7 +8818,7 @@ var DockerRuntime = class _DockerRuntime {
8369
8818
  };
8370
8819
 
8371
8820
  export {
8372
- scopedVolumeName,
8821
+ ensureScopedVolumeName,
8373
8822
  isHostPathSource,
8374
8823
  startExecStream,
8375
8824
  startAttachStream,