From 597287fce54784e87800c2070e35ac40e1e682be Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 19 Oct 2026 19:00:07 +0200 Subject: [PATCH] fix(compose): keep the CMS opt-in so it cannot claim the CI port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a CI-managed host scripts/ci-deploy.sh owns port 3002 through the blue/green slots, so a compose replica of the cms service could only ever crash-loop on EADDRINUSE — it never serves traffic, because nginx points at a slot container. Gate the service behind the `cms` profile so a bare `docker compose up -d` starts only byparr. Naming the service explicitly (`docker compose up -d cms`, which is what the installer and updater already do) still enables the profile automatically, so the compose-only install path keeps working. Keep the one port that is actually served (3002) and make the 2 GB ceiling hard by capping swap at 2g as well. Drop the green compose service (3003): the green slot only ever exists as a docker run container created by CI, so compose no longer defines it and the two paths cannot drift apart. The loopback override contract stays covered: proxy-config.test.mjs now renders the configuration with --profile cms, and a new build-contract test pins the profile and the memory limits so neither can be dropped by accident. --- README.md | 19 +++++++++----- docker-compose.yml | 35 ++++++++++++++++---------- docs/operations/docker-installation.md | 2 +- scripts/proxy-config.test.mjs | 7 ++++-- src/lib/docker-build-contract.test.ts | 11 ++++++++ 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 704911c4..e9c00959 100644 --- a/README.md +++ b/README.md @@ -965,11 +965,13 @@ there are two fixed slots, and each release lands in the idle one: | A | 3002 | `epicnext-cms-app` | | B | 3003 | `epicnext-cms-green` | -`docker-compose.yml` defines both services (`cms`, and `cms-green` behind the -`green` profile) from one shared YAML anchor so they cannot drift apart. Note -that the CI deploy path deliberately uses `docker run` rather than compose, so -the resource limits are declared in **both** places — limits that only existed -in compose would never apply to a real release. +`docker-compose.yml` defines a single `cms` service (the blue slot on 3002) +behind the `cms` profile, so a bare `docker compose up -d` never starts it. The +green slot (`epicnext-cms-green`, port 3003) only ever exists as a `docker run` +container created by the CI deploy; compose does not define it, so the two paths +cannot drift apart. Note that the CI deploy path deliberately uses `docker run` +rather than compose, so the resource limits are declared in **both** places — +limits that only existed in compose would never apply to a real release. ### Compose on a CI host @@ -977,9 +979,14 @@ Compose and CI both want port 3002, so only one of them can own a host. A stray compose replica (`docker compose up`, or the daily `scripts/docker-update.sh` cron) parked an `epicnext-cms` container on the blue slot while nginx served the green slot, and every later release stopped on "Port 3002 is already in use" — -after the build, the migrations and the browser gate. Two guards now prevent +after the build, the migrations and the browser gate. Three guards now prevent that: +- The `cms` service sits behind the `cms` profile, so a bare `docker compose up + -d` starts only byparr and can never claim 3002. The service starts only when + it is named explicitly (`docker compose up -d cms`), which is what the + installer/updater already do; Compose enables the profile automatically for an + explicitly named service. - `scripts/docker-update.sh` refuses to run on a CI host. It used to test only `epicnext-cms-app`, but after a cutover to the green slot that container is stopped and deleted, so the guard stopped firing while the host stayed diff --git a/docker-compose.yml b/docker-compose.yml index 14cca26e..6d121155 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,20 @@ # ───────────────────────────────────────────────────────────────────────────── -# Next.js CMS — blue/green +# Next.js CMS — single slot on port 3002 +# +# ── Eigenaar van poort 3002 ───────────────────────────────────────────────── +# Deze repo kent twee deploy-paden die allebei op poort 3002 willen: +# * `scripts/ci-deploy.sh` (blue/green, docker run) — de eigenaar op een +# CI-host. Elke push naar main bouwt daar een nieuwe image, draait de +# migrations en zet nginx pas om na health + e2e + imaging. +# * deze compose-`cms`-service — bedoeld voor een LOSSE host zonder CI. +# +# Ze kunnen niet naast elkaar draaien: wie op 3002 zit, blokkeert de ander met +# `EADDRINUSE`. Daarom staat `cms` achter een profile. Een bloot +# `docker compose up -d` start dus alleen byparr en kan nooit de live CI-release +# van de site drukken. De service start alleen als je hem expliciet noemt +# (`docker compose up -d cms`, `docker compose build cms`, ...): Compose schakelt +# het profile van een expliciet genoemde service vanzelf in. Op een CI-host wil +# je dat dus juist NOOIT doen. # ───────────────────────────────────────────────────────────────────────────── x-cms: &cms image: epicnext-cms:${CMS_RELEASE:-local} @@ -14,8 +29,8 @@ x-cms: &cms dockerfile: Dockerfile args: NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown} - # Runtime host networking IS required: blue/green needs per-release host ports - # (3002/3003) and nginx reaches the slot over 127.0.0.1. + # Runtime host networking IS required: nginx reaches the CMS over 127.0.0.1 + # on the single port that is actually served (3002). network_mode: host stop_grace_period: 15s restart: unless-stopped @@ -28,8 +43,8 @@ x-cms: &cms - /var/www/Gamedata:/var/www/Gamedata # ── Resource limits ── - mem_limit: 6g - memswap_limit: 7g + mem_limit: 2g + memswap_limit: 2g cpus: 2.0 pids_limit: 512 @@ -43,19 +58,13 @@ x-cms: &cms services: cms: <<: *cms + # Alleen starten als je deze host bewust compose-only beheert. + profiles: ["cms"] container_name: epicnext-cms environment: - HOSTNAME=0.0.0.0 - PORT=3002 - cms-green: - <<: *cms - container_name: epicnext-cms-green - profiles: ["green"] - environment: - - HOSTNAME=0.0.0.0 - - PORT=3003 - byparr: image: ghcr.io/thephaseless/byparr:latest container_name: byparr diff --git a/docs/operations/docker-installation.md b/docs/operations/docker-installation.md index 9a57763d..4c05d1ef 100644 --- a/docs/operations/docker-installation.md +++ b/docs/operations/docker-installation.md @@ -81,7 +81,7 @@ The direct template intentionally records the CDN/edge socket address when place ### Local verification and deployment boundary -`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test. +`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test. `pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall. diff --git a/scripts/proxy-config.test.mjs b/scripts/proxy-config.test.mjs index 99493778..1775f865 100644 --- a/scripts/proxy-config.test.mjs +++ b/scripts/proxy-config.test.mjs @@ -18,7 +18,7 @@ const hasCompose = }).status === 0; it.skipIf(!hasCompose)( - "loads the loopback profile from .env for bare Compose commands without changing mounts or host networking", + "loads the loopback override from .env for the cms service without changing mounts or host networking", () => { const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-")); try { @@ -50,9 +50,12 @@ it.skipIf(!hasCompose)( ["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key) ) delete environment[key]; + // `cms` is behind the `cms` profile so a bare `docker compose up -d` + // can never squat port 3002 on a CI-managed host. Selecting the + // profile here keeps the loopback/port-override contract under test. const result = spawnSync( "docker", - ["compose", "config", "--format", "json"], + ["compose", "--profile", "cms", "config", "--format", "json"], { cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 }, ); expect(result.status, result.stderr).toBe(0); diff --git a/src/lib/docker-build-contract.test.ts b/src/lib/docker-build-contract.test.ts index 04b0abdb..ac90de31 100644 --- a/src/lib/docker-build-contract.test.ts +++ b/src/lib/docker-build-contract.test.ts @@ -47,6 +47,17 @@ it("passes a compiled release to both the application build and final image", () expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}"); }); +it("keeps the compose CMS opt-in and inside the 2 GB limit", () => { + const compose = readFileSync("docker-compose.yml", "utf8"); + // A bare `docker compose up -d` starts only byparr. On a CI-managed host the + // CMS owns port 3002 through ci-deploy.sh; letting compose claim the same + // port again produces an endless EADDRINUSE crash loop. + expect(compose).toContain('profiles: ["cms"]'); + expect(compose).not.toContain("cms-green"); + expect(compose).toContain("mem_limit: 2g"); + expect(compose).toContain("memswap_limit: 2g"); +}); + it("builds with fixtures and excludes installation secrets from every stage", () => { const ignored = readFileSync(".dockerignore", "utf8"); expect(ignored).toMatch(/^\.env$/m);