openship 0.4.7 → 0.5.0

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 (42) hide show
  1. package/README.md +1 -0
  2. package/dist/{bare-2HYDXQEH.js → bare-VRGE5MXH.js} +7 -7
  3. package/dist/{chunk-TGIINPVM.js → chunk-6LKHXB6T.js} +22 -7
  4. package/dist/{chunk-R5BHFFAV.js → chunk-6PIYRG5I.js} +56 -17
  5. package/dist/{chunk-IMBD5PZ5.js → chunk-BS2AXYZX.js} +430 -86
  6. package/dist/{chunk-SJIFLLX4.js → chunk-CUJXGK7R.js} +321 -85
  7. package/dist/{chunk-VFINNTUL.js → chunk-EAAGSQXN.js} +23 -8
  8. package/dist/{chunk-4Z5QO7N6.js → chunk-I6PSTQIR.js} +2 -2
  9. package/dist/{chunk-XFCDYZUQ.js → chunk-JLKKYUZA.js} +31 -49
  10. package/dist/{chunk-4TJC4JZQ.js → chunk-K2RPVGPN.js} +23 -4
  11. package/dist/{chunk-MZ2WEHHY.js → chunk-KNTJ7PSF.js} +1 -1
  12. package/dist/{chunk-L2VOGB2X.js → chunk-KZOBYJUW.js} +1 -1
  13. package/dist/{chunk-IPYPMI3S.js → chunk-NNPEG6HM.js} +1 -1
  14. package/dist/{chunk-UXWAVM4C.js → chunk-NXFIRK2Z.js} +398 -112
  15. package/dist/{chunk-FXTBHTWY.js → chunk-RB35YN3J.js} +271 -35
  16. package/dist/{chunk-6APULWSU.js → chunk-UMQP7SYT.js} +429 -66
  17. package/dist/{chunk-V6AN6XLZ.js → chunk-V7BZMGOT.js} +123 -45
  18. package/dist/{chunk-VDMQJHPI.js → chunk-YWMODKWL.js} +67 -4
  19. package/dist/{chunk-OXWDY22G.js → chunk-ZCOQJCZO.js} +1 -1
  20. package/dist/{cloud-2KXREXMO.js → cloud-3M2WZ624.js} +12 -12
  21. package/dist/{detect-2XSCFFTF.js → detect-BAJMY6KM.js} +7 -5
  22. package/dist/{docker-LODK5Y2S.js → docker-2NGBRJFJ.js} +9 -7
  23. package/dist/{docker-edge-executor-VHW3JPJS.js → docker-edge-executor-5I4M5QT7.js} +1 -1
  24. package/dist/{edge-import-ZQ4Y5HEA.js → edge-import-RIWNEIXQ.js} +21 -19
  25. package/dist/{ensure-container-edge-ED25XL3Z.js → ensure-container-edge-3NSYKHRX.js} +6 -6
  26. package/dist/{executor-YPT6RH6O.js → executor-LOYMPU67.js} +3 -3
  27. package/dist/index.js +309 -338
  28. package/dist/{nginx-XWJAOGS4.js → nginx-QE4DOM5J.js} +4 -4
  29. package/dist/{openresty-lua-4WLGDSER.js → openresty-lua-BTAKECCF.js} +2 -2
  30. package/dist/server/index.js +51705 -45369
  31. package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
  32. package/dist/server/migrations/0074_project_compose_path.sql +1 -0
  33. package/dist/server/migrations/0075_service_command_argv.sql +1 -0
  34. package/dist/server/migrations/0076_rollback_retention.sql +17 -0
  35. package/dist/server/migrations/0077_domain_redirect.sql +13 -0
  36. package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
  37. package/dist/server/migrations/0079_project_readiness.sql +18 -0
  38. package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
  39. package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
  40. package/dist/server/migrations/meta/_journal.json +63 -0
  41. package/dist/{setup-2DMFIA74.js → setup-KELALK7H.js} +7 -7
  42. package/package.json +1 -1
@@ -9,7 +9,7 @@ import {
9
9
  resolveDockerfileCandidates,
10
10
  resolveServiceDockerfile,
11
11
  staticBuilderOutputPath
12
- } from "./chunk-V6AN6XLZ.js";
12
+ } from "./chunk-V7BZMGOT.js";
13
13
  import {
14
14
  BuildLogger,
15
15
  assembleGitClone,
@@ -18,20 +18,24 @@ import {
18
18
  shellGitSshWriter,
19
19
  sq,
20
20
  transferLocalDirectory
21
- } from "./chunk-4Z5QO7N6.js";
21
+ } from "./chunk-I6PSTQIR.js";
22
22
  import {
23
23
  connectSshClient,
24
24
  execSshCommand,
25
25
  openSshExecChannel,
26
26
  openSshUnixSocket
27
- } from "./chunk-OXWDY22G.js";
27
+ } from "./chunk-ZCOQJCZO.js";
28
28
  import {
29
29
  isRuntimeNotFoundError
30
30
  } from "./chunk-RPHRPFEH.js";
31
31
  import {
32
+ formatCpuCores,
33
+ formatMemoryMb,
34
+ hasCpuLimit,
35
+ hasMemoryLimit,
32
36
  safeErrorMessage,
33
37
  withTimeout
34
- } from "./chunk-6APULWSU.js";
38
+ } from "./chunk-UMQP7SYT.js";
35
39
 
36
40
  // ../../packages/adapters/src/runtime/docker.ts
37
41
  import Dockerode from "dockerode";
@@ -201,6 +205,59 @@ function daemonConnectionFrom(docker) {
201
205
  };
202
206
  }
203
207
 
208
+ // ../../packages/adapters/src/runtime/compose-cmd.ts
209
+ function resolveComposeCmd(config) {
210
+ if (config.commandArgv != null) return config.commandArgv;
211
+ return config.command ? ["sh", "-c", config.command] : void 0;
212
+ }
213
+
214
+ // ../../packages/adapters/src/runtime/resource-limits.ts
215
+ function dockerResourceLimits(resources) {
216
+ const limits = {};
217
+ if (hasMemoryLimit(resources)) {
218
+ limits.Memory = Math.round(resources.memoryMb * 1024 * 1024);
219
+ }
220
+ if (hasCpuLimit(resources)) {
221
+ limits.NanoCpus = Math.max(1e6, Math.round(resources.cpuCores * 1e9));
222
+ }
223
+ return limits;
224
+ }
225
+ var CPU_PERIOD_US = 1e5;
226
+ function dockerBuildResourceLimits(resources) {
227
+ const limits = {};
228
+ if (hasMemoryLimit(resources)) {
229
+ limits.memory = Math.round(resources.memoryMb * 1024 * 1024);
230
+ }
231
+ if (hasCpuLimit(resources)) {
232
+ limits.cpuperiod = CPU_PERIOD_US;
233
+ limits.cpuquota = Math.max(1e3, Math.round(resources.cpuCores * CPU_PERIOD_US));
234
+ }
235
+ return limits;
236
+ }
237
+ function inspectResourceLimits(hostConfig) {
238
+ if (!hostConfig) return void 0;
239
+ const memoryMb = typeof hostConfig.Memory === "number" && hostConfig.Memory > 0 ? Math.round(hostConfig.Memory / (1024 * 1024)) : void 0;
240
+ let cpuCores;
241
+ if (typeof hostConfig.NanoCpus === "number" && hostConfig.NanoCpus > 0) {
242
+ cpuCores = hostConfig.NanoCpus / 1e9;
243
+ } else if (typeof hostConfig.CpuQuota === "number" && hostConfig.CpuQuota > 0 && typeof hostConfig.CpuPeriod === "number" && hostConfig.CpuPeriod > 0) {
244
+ cpuCores = hostConfig.CpuQuota / hostConfig.CpuPeriod;
245
+ }
246
+ if (cpuCores !== void 0) cpuCores = Math.round(cpuCores * 100) / 100;
247
+ if (memoryMb === void 0 && cpuCores === void 0) return void 0;
248
+ return {
249
+ ...cpuCores !== void 0 && { cpuCores },
250
+ ...memoryMb !== void 0 && { memoryMb }
251
+ };
252
+ }
253
+ function describeResourceLimits(resources) {
254
+ const parts = [
255
+ hasCpuLimit(resources) ? formatCpuCores(resources.cpuCores) : null,
256
+ hasMemoryLimit(resources) ? formatMemoryMb(resources.memoryMb) : null
257
+ ].filter(Boolean);
258
+ return parts.length ? parts.join(" \xB7 ") : "no limits (machine capacity)";
259
+ }
260
+
204
261
  // ../../packages/adapters/src/runtime/docker-ssh-agent.ts
205
262
  import net2 from "net";
206
263
  var DEFAULT_REMOTE_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
@@ -551,13 +608,29 @@ function createDockerSshBridge(opts) {
551
608
  }
552
609
 
553
610
  // ../../packages/adapters/src/runtime/docker-transport.ts
611
+ var DEFAULT_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
612
+ function resolveLocalDockerSocketPath(opts, env = process.env) {
613
+ const explicit = opts?.dockerSocketPath?.trim();
614
+ if (explicit) return explicit;
615
+ const host = env.DOCKER_HOST?.trim();
616
+ if (host) {
617
+ if (host.startsWith("unix://")) {
618
+ const path = host.slice("unix://".length).trim();
619
+ if (path.startsWith("/")) return path;
620
+ } else if (host.startsWith("/")) {
621
+ return host;
622
+ }
623
+ }
624
+ return DEFAULT_DOCKER_SOCKET_PATH;
625
+ }
554
626
  function resolveDockerTransport(opts) {
555
627
  if (!opts || !opts.transport || opts.transport === "socket") {
628
+ const socketPath = resolveLocalDockerSocketPath(opts);
556
629
  return {
557
630
  kind: "socket",
558
- description: "local Docker daemon via socket",
559
- unreachableHint: "Check that the local Docker daemon is running.",
560
- establish: async () => ({ socketPath: "/var/run/docker.sock" }),
631
+ description: `local Docker daemon via socket (${socketPath})`,
632
+ unreachableHint: socketPath === DEFAULT_DOCKER_SOCKET_PATH ? "Check that the local Docker daemon is running. If it isn't Docker Desktop (Colima, Rancher, Podman, rootless), set DOCKER_HOST to its socket." : `Check that the local Docker daemon is running and listening on ${socketPath} (from DOCKER_HOST / dockerSocketPath).`,
633
+ establish: async () => ({ socketPath }),
561
634
  close: async () => {
562
635
  },
563
636
  preflight: async () => {
@@ -622,6 +695,9 @@ function resolveDockerTransport(opts) {
622
695
 
623
696
  // ../../packages/adapters/src/runtime/docker.ts
624
697
  var isDockerNotFoundError = isRuntimeNotFoundError;
698
+ function ownsBuiltImage(imageRef) {
699
+ return imageRef.startsWith("openship/");
700
+ }
625
701
  function clampShellWindow(value, fallback, min, max) {
626
702
  const n = Number.isFinite(value) ? Number(value) : fallback;
627
703
  if (n < min) return min;
@@ -848,7 +924,8 @@ var DockerRuntime = class _DockerRuntime {
848
924
  "serviceShell",
849
925
  "projectContainerSweep",
850
926
  "deploymentContainerQuery",
851
- "hostContainerQuery"
927
+ "hostContainerQuery",
928
+ "stabilityProbe"
852
929
  ]);
853
930
  /** Docker honors every extended compose key we currently support. */
854
931
  unsupportedComposeKeys = /* @__PURE__ */ new Set();
@@ -1265,6 +1342,10 @@ var DockerRuntime = class _DockerRuntime {
1265
1342
  ...config.envVars,
1266
1343
  NODE_ENV: "production"
1267
1344
  },
1345
+ // Omitted entirely unless the project set a build cap — a self-hosted
1346
+ // build should be free to use the machine (a production build often
1347
+ // needs several GB). Opt-in only; see dockerBuildResourceLimits.
1348
+ ...dockerBuildResourceLimits(config.resources),
1268
1349
  forcerm: true
1269
1350
  }
1270
1351
  );
@@ -1483,12 +1564,16 @@ var DockerRuntime = class _DockerRuntime {
1483
1564
  async moveStaticBuildToHost(tag, config, hostOutDir, log) {
1484
1565
  const docRoot = staticBuilderOutputPath(config);
1485
1566
  const sshExecutor = this.transport.kind === "ssh" ? this.connectionOptions?.executor : null;
1486
- log.log("Moving built files out of the build container...\n");
1567
+ log.log(`Moving built files out of the build container (${docRoot})...
1568
+ `);
1487
1569
  try {
1488
1570
  if (sshExecutor) {
1489
1571
  await this.extractDocRootOverSsh(sshExecutor, tag, docRoot, hostOutDir);
1490
1572
  } else {
1491
- await this.extractDocRootViaDaemon(tag, docRoot, hostOutDir);
1573
+ const copiedInPlace = this.transport.kind === "socket" && await this.extractDocRootViaBindMount(tag, docRoot, hostOutDir);
1574
+ if (!copiedInPlace) {
1575
+ await this.extractDocRootViaDaemon(tag, docRoot, hostOutDir);
1576
+ }
1492
1577
  }
1493
1578
  log.log(`Static files ready at ${hostOutDir}
1494
1579
  `);
@@ -1510,8 +1595,9 @@ var DockerRuntime = class _DockerRuntime {
1510
1595
  * a second web server was never going to run.
1511
1596
  * Returns the host dir as `imageRef`, matching BareRuntime.build's host-dir
1512
1597
  * contract so the existing file-backed serve path (deployStatic /
1513
- * resolveStaticRoot) consumes it unchanged. Never leaves a long-lived
1514
- * container: the extraction container is created (not started) and removed.
1598
+ * resolveStaticRoot) consumes it unchanged. Never leaves a long-lived container:
1599
+ * the extraction container runs one `cp` (or isn't started at all, when the tar is
1600
+ * streamed instead) and is removed either way.
1515
1601
  */
1516
1602
  async buildStaticToHost(config, hostOutDir, logger) {
1517
1603
  const log = logger ?? new BuildLogger();
@@ -1544,18 +1630,31 @@ var DockerRuntime = class _DockerRuntime {
1544
1630
  }
1545
1631
  }
1546
1632
  /**
1547
- * THE CONTRACT both extractors satisfy: after either one returns, `hostOutDir`
1548
- * holds the doc-root's CONTENTS directly — `index.html` at the top, no wrapping
1549
- * `html/` directory. OpenResty's `root` points straight at it, so a stray extra
1550
- * level is a silent 404 for the whole site.
1633
+ * THE CONTRACT all three extractors satisfy: once one returns, `hostOutDir` holds
1634
+ * the doc-root's CONTENTS directly — `index.html` at the top, no wrapping
1635
+ * directory. OpenResty's `root` points straight at it, so one stray level is a
1636
+ * silent 404 for the whole site.
1637
+ *
1638
+ * They reach that shape differently, which is why the rule is stated here once
1639
+ * instead of in each branch:
1640
+ * - `docker cp <cid>:<root>/.` and `cp -a <root>/. <out>/` — the trailing `/.`
1641
+ * means "contents of", so there is nothing to strip.
1642
+ * - `getArchive({path: <root>})` — the tar is rooted at the doc-root's own
1643
+ * basename (`dist/`, `build/`, …), so exactly one leading component is
1644
+ * stripped.
1551
1645
  *
1552
- * The two paths reach that same shape differently, which is why the rule is
1553
- * stated here once instead of in each branch:
1554
- * - `docker cp <cid>:<root>/.` the trailing `/.` means "contents of", so
1555
- * there is nothing to strip.
1556
- * - `getArchive({path: <root>})` the tar IS rooted at `html/`, so exactly
1557
- * one leading component must be stripped.
1646
+ * The other half of the contract is that hostOutDir is non-EMPTY, which is what
1647
+ * this enforces. It has to be checked per-path (three different filesystems) but
1648
+ * the rule and its wording live here, because the failure it prevents is the
1649
+ * expensive one: every extractor can succeed having copied nothing, which deploys
1650
+ * green and then 404s the entire site.
1558
1651
  */
1652
+ assertDocRootFilled(isEmpty, docRoot) {
1653
+ if (!isEmpty) return;
1654
+ throw new Error(
1655
+ `static extract produced no files from ${docRoot} in the build container \u2014 check the project's output directory setting.`
1656
+ );
1657
+ }
1559
1658
  /** Remote host: disk-to-disk via the host's own docker CLI — never stream a
1560
1659
  * large tar over the SSH dockerode bridge (same rationale as saveImage/pullImage). */
1561
1660
  async extractDocRootOverSsh(sshExecutor, tag, docRoot, hostOutDir) {
@@ -1563,34 +1662,126 @@ var DockerRuntime = class _DockerRuntime {
1563
1662
  try {
1564
1663
  await sshExecutor.exec(`mkdir -p ${sq(hostOutDir)}`);
1565
1664
  await sshExecutor.exec(`docker cp ${sq(`${cid}:${docRoot}/.`)} ${sq(hostOutDir)}`);
1665
+ const listing = (await sshExecutor.exec(`ls -A ${sq(hostOutDir)} | head -1`).catch(() => "")).trim();
1666
+ this.assertDocRootFilled(!listing, docRoot);
1566
1667
  } finally {
1567
1668
  await sshExecutor.exec(`docker rm ${sq(cid)}`).catch(() => {
1568
1669
  });
1569
1670
  }
1570
1671
  }
1571
- /** Local socket / remote TCP: pull the tar through dockerode (portable to a TCP
1572
- * daemon where a local `docker` CLI wouldn't apply) and extract onto THIS
1573
- * process's filesystem in docker-edge mode that's /opt/openship/static,
1574
- * bind-mounted into both the api and the edge at the same path. */
1672
+ /**
1673
+ * LOCAL DAEMON: let the daemon do the copy on its own disk. The builder image is
1674
+ * run once with `hostOutDir` bind-mounted, and it `cp -a`s the doc-root across.
1675
+ * No archive crosses this process at all.
1676
+ *
1677
+ * This exists because streaming the doc-root out via `getArchive` is undebuggable
1678
+ * when it goes wrong. Once the daemon has sent 200 + headers for an archive it has
1679
+ * NO way to report a mid-archive failure — an unreadable file, a socket/fifo, a
1680
+ * path it can't stat — so it just closes the connection. The client sees a
1681
+ * truncated body and the only symptom anywhere is `tar: Unexpected EOF in
1682
+ * archive`, with the actual cause visible solely in the daemon's own log. That is
1683
+ * the failure this method removes: `cp` runs in the container, so a real error
1684
+ * arrives as real stderr and a real exit code.
1685
+ *
1686
+ * Requires that the daemon's filesystem IS this process's filesystem, which is
1687
+ * true for DooD (`/opt/openship/static` is a host bind mount at the same path
1688
+ * inside and out — see docker/docker-compose.yml) and false for a VM-backed
1689
+ * daemon (Docker Desktop, Colima), where `hostOutDir` resolves inside the VM and
1690
+ * the copy would land somewhere this process can't see.
1691
+ *
1692
+ * So it PROVES the assumption instead of trusting the transport: a sentinel file
1693
+ * is written here and the container refuses to copy unless it can see it. A
1694
+ * daemon that can't reach our disk exits 97 and the caller streams instead. That
1695
+ * check is folded into the same container run — no extra round trip — because
1696
+ * getting it wrong means an empty doc-root, i.e. a site that deploys green and
1697
+ * serves 404s.
1698
+ *
1699
+ * Returns false when the bind isn't shared; throws on a real copy failure.
1700
+ */
1701
+ async extractDocRootViaBindMount(tag, docRoot, hostOutDir) {
1702
+ const { mkdir, readdir, writeFile, rm } = await import("fs/promises");
1703
+ const { join } = await import("path");
1704
+ const { randomBytes } = await import("crypto");
1705
+ await mkdir(hostOutDir, { recursive: true });
1706
+ const OUT = "/__openship_out";
1707
+ const probe = `.openship-extract-probe-${randomBytes(6).toString("hex")}`;
1708
+ await writeFile(join(hostOutDir, probe), "probe");
1709
+ const container = await this.docker.createContainer({
1710
+ Image: tag,
1711
+ // Override the ENTRYPOINT: builder base images (node, bun) ship a
1712
+ // docker-entrypoint.sh that would swallow this Cmd.
1713
+ Entrypoint: ["/bin/sh", "-c"],
1714
+ Cmd: [
1715
+ `set -e; test -f ${sq(`${OUT}/${probe}`)} || exit 97; cp -a ${sq(docRoot)}/. ${OUT}/; rm -f ${sq(`${OUT}/${probe}`)}`
1716
+ ],
1717
+ // Root, explicitly. A builder image that sets `USER node`/`USER bun` would
1718
+ // otherwise run this copy unprivileged and fail writing into a root-owned
1719
+ // output dir — the two paths this replaces both run with daemon privileges,
1720
+ // so anything less would be a NEW failure this change introduced.
1721
+ User: "0:0",
1722
+ HostConfig: {
1723
+ // `:z` matches how compose mounts this dir — required on SELinux hosts for
1724
+ // the container to write, and ignored where SELinux is off.
1725
+ Binds: [`${hostOutDir}:${OUT}:z`]
1726
+ }
1727
+ });
1728
+ try {
1729
+ try {
1730
+ await container.start();
1731
+ } catch {
1732
+ return false;
1733
+ }
1734
+ const status = await container.wait();
1735
+ if (status.StatusCode === 97) return false;
1736
+ if (status.StatusCode !== 0) {
1737
+ const logs = await container.logs({ stdout: true, stderr: true, tail: 40 }).then((b) => Buffer.from(b).toString("utf8")).then((s) => s.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, " ").trim()).catch(() => "");
1738
+ throw new Error(
1739
+ `static extract failed copying ${docRoot} out of the build container (exit ${status.StatusCode})${logs ? `: ${logs.slice(-500)}` : ""}`
1740
+ );
1741
+ }
1742
+ const entries = (await readdir(hostOutDir).catch(() => [])).filter((e) => e !== probe);
1743
+ this.assertDocRootFilled(entries.length === 0, docRoot);
1744
+ return true;
1745
+ } finally {
1746
+ await container.remove({ force: true }).catch(() => {
1747
+ });
1748
+ await rm(join(hostOutDir, probe), { force: true }).catch(() => {
1749
+ });
1750
+ }
1751
+ }
1752
+ /** Remote TCP daemon: no shared filesystem and no local `docker` CLI, so the tar
1753
+ * has to come through dockerode and be extracted onto THIS process's disk. */
1575
1754
  async extractDocRootViaDaemon(tag, docRoot, hostOutDir) {
1576
- const { mkdir } = await import("fs/promises");
1755
+ const { mkdir, readdir } = await import("fs/promises");
1577
1756
  const { spawn } = await import("child_process");
1757
+ const { pipeline } = await import("stream/promises");
1578
1758
  await mkdir(hostOutDir, { recursive: true });
1579
1759
  const container = await this.docker.createContainer({ Image: tag });
1580
1760
  try {
1581
1761
  const tarStream = await container.getArchive({ path: docRoot });
1582
- await new Promise((resolve, reject) => {
1583
- const extract = spawn("tar", ["-x", "--strip-components=1", "-C", hostOutDir]);
1584
- let errBuf = "";
1585
- extract.stderr.on("data", (d) => errBuf += d.toString());
1586
- extract.on("error", reject);
1587
- tarStream.on("error", reject);
1588
- tarStream.pipe(extract.stdin);
1589
- extract.on(
1590
- "close",
1591
- (code) => code === 0 ? resolve() : reject(new Error(`static extract failed (tar ${code}): ${errBuf.trim().slice(-500)}`))
1592
- );
1762
+ const extract = spawn("tar", ["-x", "--strip-components=1", "-C", hostOutDir]);
1763
+ let errBuf = "";
1764
+ extract.stderr.on("data", (d) => errBuf += d.toString());
1765
+ const exited = new Promise((resolve) => {
1766
+ extract.on("close", (code2) => resolve(code2 ?? 0));
1767
+ extract.on("error", () => resolve(-1));
1593
1768
  });
1769
+ let streamError;
1770
+ await pipeline(tarStream, extract.stdin).catch((err) => {
1771
+ streamError = err;
1772
+ });
1773
+ const code = await exited;
1774
+ const tarErr = errBuf.trim();
1775
+ if (streamError && !(code !== 0 && tarErr)) {
1776
+ throw new Error(
1777
+ `static extract failed reading ${docRoot} from the build container: ${safeErrorMessage(streamError)}${tarErr ? ` (tar: ${tarErr.slice(-200)})` : ""}`
1778
+ );
1779
+ }
1780
+ if (code !== 0) {
1781
+ throw new Error(`static extract failed (tar ${code}): ${tarErr.slice(-500)}`);
1782
+ }
1783
+ const entries = await readdir(hostOutDir).catch(() => []);
1784
+ this.assertDocRootFilled(entries.length === 0, docRoot);
1594
1785
  } finally {
1595
1786
  await container.remove({ force: true }).catch(() => {
1596
1787
  });
@@ -1717,6 +1908,7 @@ var DockerRuntime = class _DockerRuntime {
1717
1908
  sessionId: spec.config.sessionId
1718
1909
  }),
1719
1910
  buildargs: { ...spec.config.envVars, NODE_ENV: "production" },
1911
+ ...dockerBuildResourceLimits(spec.config.resources),
1720
1912
  forcerm: true
1721
1913
  }
1722
1914
  );
@@ -1805,9 +1997,29 @@ var DockerRuntime = class _DockerRuntime {
1805
1997
  ];
1806
1998
  const cmd = config.startCommand ? ["sh", "-c", config.startCommand] : void 0;
1807
1999
  const restartPolicy = resolveRestartPolicy(config.restartPolicy);
2000
+ const scopedBinds = scopeVolumeBinds(
2001
+ config.slug || config.runtimeName || config.projectId,
2002
+ config.volumes ?? [],
2003
+ true
2004
+ );
2005
+ const binds = scopedBinds.length > 0 ? scopedBinds : void 0;
1808
2006
  log({
1809
2007
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1810
2008
  message: `Creating container ${containerName} from ${imageRef}...
2009
+ `,
2010
+ level: "info"
2011
+ });
2012
+ if (binds) {
2013
+ log({
2014
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2015
+ message: `Persistent storage: ${binds.join(", ")}
2016
+ `,
2017
+ level: "info"
2018
+ });
2019
+ }
2020
+ log({
2021
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2022
+ message: `Resource limits: ${describeResourceLimits(config.resources)}
1811
2023
  `,
1812
2024
  level: "info"
1813
2025
  });
@@ -1823,8 +2035,10 @@ var DockerRuntime = class _DockerRuntime {
1823
2035
  ExposedPorts: { [`${config.port}/tcp`]: {} },
1824
2036
  HostConfig: {
1825
2037
  RestartPolicy: restartPolicy,
1826
- Memory: config.resources.memoryMb * 1024 * 1024,
1827
- CpuShares: Math.round(config.resources.cpuCores * 1024),
2038
+ Binds: binds,
2039
+ // Omitted entirely when the project has no cap (self-hosted default)
2040
+ // see dockerResourceLimits. Never substitute a tier here.
2041
+ ...dockerResourceLimits(config.resources),
1828
2042
  // Publish on the LOOPBACK interface only — the edge (host process, or a
1829
2043
  // host-net OpenResty container) reaches it at 127.0.0.1:<hostPort>, and
1830
2044
  // it never faces the network. Binding 0.0.0.0 here would expose every
@@ -1969,44 +2183,24 @@ var DockerRuntime = class _DockerRuntime {
1969
2183
  serviceName: c.Labels?.["openship.service"]
1970
2184
  }));
1971
2185
  }
1972
- // ── Rollback primitives ──────────────────────────────────────────────
2186
+ // ── Rollback primitives (retention half only) ────────────────────────
1973
2187
  //
1974
- // Docker semantics:
1975
- // makeActive prefer `start` of the retained container (fast,
1976
- // preserves PID/state). If the container was GC'd but the image
1977
- // is still tagged, `run` from imageRef to provision a fresh
1978
- // container. Stop the previous active as part of the swap.
1979
- // archive — `docker stop`. Image stays tagged. Container kept
1980
- // for fast restart on later makeActive.
1981
- // purge — `docker rm` (force) + `docker rmi`. Past this point
1982
- // rollback to this deployment is impossible.
1983
- async makeActive(input) {
1984
- if (input.from?.containerId) {
1985
- try {
1986
- await this.stop(input.from.containerId);
1987
- } catch {
1988
- }
1989
- }
1990
- if (input.to.containerId) {
1991
- try {
1992
- await this.start(input.to.containerId);
1993
- return { containerId: input.to.containerId };
1994
- } catch {
1995
- }
1996
- }
1997
- if (!input.to.imageRef) {
1998
- throw new Error(
1999
- `Cannot make deployment ${input.to.id} active: container is gone and no imageRef is stored. Artifact has been purged.`
2000
- );
2001
- }
2002
- const container = await this.docker.createContainer({
2003
- Image: input.to.imageRef,
2004
- name: `dep-${input.to.id}`,
2005
- HostConfig: { RestartPolicy: { Name: "unless-stopped" } }
2006
- });
2007
- await container.start();
2008
- return { containerId: container.id };
2009
- }
2188
+ // Docker deliberately does NOT implement `makeActive` — see the
2189
+ // "unitRestore" capability. A redeploy REMOVES the previous container
2190
+ // (loopback-port routing can't overlap two containers on one host
2191
+ // port), so there is no unit left to restart; the durable artifact is
2192
+ // the IMAGE. Restore therefore re-materializes the container through
2193
+ // the normal deploy step from the target's frozen snapshot + retained
2194
+ // image (modules/deployments/rollback/restore-plan.ts), which is the
2195
+ // only way env, published port, volumes, labels, network and routing
2196
+ // all come back correctly.
2197
+ //
2198
+ // archive — `docker stop` (usually a no-op: the container is gone).
2199
+ // The image stays tagged, retained by the rollback-window
2200
+ // keep-set in modules/deployments/image-gc.
2201
+ // purge — `docker rm` (force) + `docker rmi`. Past this point an
2202
+ // instant restore of this deployment is impossible and
2203
+ // rollback degrades to a rebuild from its commit.
2010
2204
  async archive(deployment) {
2011
2205
  if (!deployment.containerId) return;
2012
2206
  try {
@@ -2021,7 +2215,7 @@ var DockerRuntime = class _DockerRuntime {
2021
2215
  } catch {
2022
2216
  }
2023
2217
  }
2024
- if (deployment.imageRef) {
2218
+ if (deployment.imageRef && ownsBuiltImage(deployment.imageRef)) {
2025
2219
  try {
2026
2220
  await this.removeImage(deployment.imageRef);
2027
2221
  } catch {
@@ -2121,6 +2315,7 @@ var DockerRuntime = class _DockerRuntime {
2121
2315
  retries: hc.Retries,
2122
2316
  startPeriod: hc.StartPeriod
2123
2317
  } : void 0,
2318
+ resources: inspectResourceLimits(data.HostConfig),
2124
2319
  composeProject: labels["com.docker.compose.project"] || void 0,
2125
2320
  composeService: labels["com.docker.compose.service"] || void 0,
2126
2321
  composeConfigFiles: configFiles ? configFiles.split(",").map((s) => s.trim()).filter(Boolean) : void 0,
@@ -2334,6 +2529,45 @@ var DockerRuntime = class _DockerRuntime {
2334
2529
  uptimeSeconds: uptimeSeconds && uptimeSeconds > 0 ? uptimeSeconds : void 0
2335
2530
  };
2336
2531
  }
2532
+ /**
2533
+ * One stabilization reading for the post-deploy watch. Unlike
2534
+ * `getContainerInfo` this keeps `restarting` distinct and carries
2535
+ * `RestartCount` / `ExitCode` / health — the only fields that separate a
2536
+ * container that is UP from one that is bouncing. A vanished container is a
2537
+ * `missing` sample (not a throw): that is a verdict, not a transport error.
2538
+ */
2539
+ async sampleStability(containerId) {
2540
+ let data;
2541
+ try {
2542
+ data = await this.docker.getContainer(containerId).inspect();
2543
+ } catch (err) {
2544
+ if (isDockerNotFoundError(err)) {
2545
+ return {
2546
+ state: "missing",
2547
+ exitCode: null,
2548
+ restartCount: 0,
2549
+ health: null,
2550
+ errorLine: null,
2551
+ oomKilled: false,
2552
+ restartPolicy: null
2553
+ };
2554
+ }
2555
+ throw err;
2556
+ }
2557
+ const rawState = (data.State?.Status ?? "").toLowerCase().trim();
2558
+ const state = ["created", "running", "restarting", "paused", "exited", "dead", "removing"].includes(rawState) ? rawState : "unknown";
2559
+ const rawHealth = (data.State?.Health?.Status ?? "").toLowerCase().trim();
2560
+ const health = rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "starting" ? rawHealth : null;
2561
+ return {
2562
+ state,
2563
+ exitCode: typeof data.State?.ExitCode === "number" ? data.State.ExitCode : null,
2564
+ restartCount: typeof data.RestartCount === "number" ? data.RestartCount : 0,
2565
+ health,
2566
+ errorLine: data.State?.Error?.trim() ? data.State.Error.trim() : null,
2567
+ oomKilled: data.State?.OOMKilled === true,
2568
+ restartPolicy: data.HostConfig?.RestartPolicy?.Name ?? null
2569
+ };
2570
+ }
2337
2571
  async getRuntimeLogs(containerId, tail) {
2338
2572
  const container = this.docker.getContainer(containerId);
2339
2573
  const buffer = await container.logs({
@@ -2807,7 +3041,7 @@ var DockerRuntime = class _DockerRuntime {
2807
3041
  ...config.publicPort && config.environment.PORT === void 0 ? [`PORT=${config.publicPort}`] : [],
2808
3042
  ...Object.entries(config.environment).map(([k, v]) => `${k}=${v}`)
2809
3043
  ];
2810
- const cmd = config.command ? ["sh", "-c", config.command] : void 0;
3044
+ const cmd = resolveComposeCmd(config);
2811
3045
  const { exposedPorts, portBindings } = parsePortBindings(config.ports);
2812
3046
  if (!config.namespaceVolumes) {
2813
3047
  await this.assertNoForeignNamedVolumeCollision(config);
@@ -2819,6 +3053,12 @@ var DockerRuntime = class _DockerRuntime {
2819
3053
  log({
2820
3054
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2821
3055
  message: `Creating service container ${containerName} from ${config.image}...
3056
+ `,
3057
+ level: "info"
3058
+ });
3059
+ log({
3060
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3061
+ message: `Resource limits: ${describeResourceLimits(config.resources)}
2822
3062
  `,
2823
3063
  level: "info"
2824
3064
  });
@@ -2858,12 +3098,7 @@ var DockerRuntime = class _DockerRuntime {
2858
3098
  ExposedPorts: exposedPorts,
2859
3099
  HostConfig: {
2860
3100
  RestartPolicy: restartPolicy,
2861
- ...config.resources?.memoryMb && {
2862
- Memory: config.resources.memoryMb * 1024 * 1024
2863
- },
2864
- ...config.resources?.cpuCores && {
2865
- CpuShares: Math.round(config.resources.cpuCores * 1024)
2866
- },
3101
+ ...dockerResourceLimits(config.resources),
2867
3102
  PortBindings: portBindings,
2868
3103
  Binds: binds,
2869
3104
  NetworkMode: group.id
@@ -2927,5 +3162,6 @@ var DockerRuntime = class _DockerRuntime {
2927
3162
  export {
2928
3163
  scopedVolumeName,
2929
3164
  isHostPathSource,
3165
+ ownsBuiltImage,
2930
3166
  DockerRuntime
2931
3167
  };
@@ -4,7 +4,7 @@ import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
6
  safeErrorMessage
7
- } from "./chunk-6APULWSU.js";
7
+ } from "./chunk-UMQP7SYT.js";
8
8
 
9
9
  // ../../packages/adapters/src/infra/openresty-lua.ts
10
10
  import { createHash } from "crypto";
@@ -85,16 +85,26 @@ async function detectOpenRestyPaths(executor) {
85
85
  pidPath
86
86
  };
87
87
  }
88
- function buildReloadCommand(paths) {
88
+ function buildReloadCommand(paths, opts = {}) {
89
+ if (opts.containerEdge) {
90
+ return `${paths.bin} -t 2>&1 || exit 1
91
+ ${paths.bin} -s reload 2>&1 || exit 1`;
92
+ }
89
93
  return `${paths.bin} -t 2>&1 || exit 1
90
94
 
91
- if ${paths.bin} -s reload 2>/dev/null; then
92
- exit 0
95
+ reload_err=$(${paths.bin} -s reload 2>&1) && exit 0
96
+
97
+ if [ "$(cat /proc/1/comm 2>/dev/null)" = "openresty" ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "nginx" ]; then
98
+ echo "openresty reload failed and openresty is PID 1 (containerized edge) \u2014 not killing it; the container supervisor owns restarts." >&2
99
+ echo "$reload_err" >&2
100
+ exit 1
101
+ fi
102
+
103
+ if [ -f ${paths.pidPath} ] && kill -0 "$(cat ${paths.pidPath} 2>/dev/null)" 2>/dev/null; then
104
+ echo "openresty (pid $(cat ${paths.pidPath})) is running but refused -s reload" >&2
105
+ exit 1
93
106
  fi
94
107
 
95
- pkill -f '[o]penresty' >/dev/null 2>&1 || true
96
- pkill -f '[n]ginx' >/dev/null 2>&1 || true
97
- sleep 1
98
108
  rm -f ${paths.pidPath}
99
109
  ${paths.bin}`;
100
110
  }
@@ -138,13 +148,18 @@ http {
138
148
  default_type application/octet-stream;
139
149
  sendfile on;
140
150
  keepalive_timeout 65;
151
+ # See apps/edge/nginx.conf: the 64-byte default fails \`nginx -t\` outright
152
+ # ("could not build server_names_hash") for any server_name past ~63 chars,
153
+ # which refuses the reload and wedges every route on the box, not just the
154
+ # long one. Keep this in step with the containerized edge's value.
155
+ server_names_hash_bucket_size 128;
141
156
  include ${sitesDir}/*.conf;
142
157
  }
143
158
  `;
144
159
  }
145
160
  var GEOIP_DIR = "/usr/share/GeoIP";
146
161
  var GEOIP_DB_PATH = `${GEOIP_DIR}/GeoLite2-Country.mmdb`;
147
- var GEOIP_DB_URL = "https://github.com/P3TERX/GeoLite.mmdb/releases/download/2026.04.07/GeoLite2-Country.mmdb";
162
+ var GEOIP_DB_URL = "https://github.com/P3TERX/GeoLite.mmdb/releases/latest/download/GeoLite2-Country.mmdb";
148
163
  var LUA_SRC_DIR = process.env.OPENSHIP_LUA_DIR?.trim() || join(dirname(fileURLToPath(import.meta.url)), "lua");
149
164
  var VHOST_REFERENCED_LUA = ["rules_guard.lua", "rules_lib.lua", "site_logger.lua"];
150
165
  function luaSourceAvailable() {
@@ -5,12 +5,12 @@ const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
6
  getTarCreateEnv,
7
7
  prepareSourceTarArgs
8
- } from "./chunk-OXWDY22G.js";
8
+ } from "./chunk-ZCOQJCZO.js";
9
9
  import {
10
10
  TRANSFER_EXCLUDES,
11
11
  packageManagerEnsureCommand,
12
12
  safeErrorMessage
13
- } from "./chunk-6APULWSU.js";
13
+ } from "./chunk-UMQP7SYT.js";
14
14
 
15
15
  // ../../packages/adapters/src/runtime/git-clone.ts
16
16
  function sq(value) {