From 8ee144745aece1eac203df3b06fd9873c5a0df48 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 3 Oct 2026 18:30:00 +0200 Subject: [PATCH] fix(deploy): stub ss in the deploy harness and cover the port-conflict path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port-conflict guard added in the previous commit made the six existing blue/green deployment simulation tests fail. assert_port_free() shells out to ss, and the simulation harness stubs git, curl, docker, nginx, pnpm and node — but not ss. Because the runner is self-hosted and the containers use --net=host, the simulation saw the production CMS containers holding 3002 and 3003 and refused to start its own candidate. The harness now stubs ss. It reports no listener for every scenario except 'port-taken', which reserves whichever port the script asks about, so the simulation stays independent of the host it runs on. Also switched the ss probe from `command -v ss` to `type ss`. The stub is a shell function delivered through BASH_ENV; `command -v` happens to find it, but `type` is the reliable test for "is this resolvable", and the two differ across shells. Added a regression test for the guard itself: with the candidate port already occupied, the deploy must fail, must not have run `docker run`, and must leave the nginx upstream untouched on the old port — no half-finished cutover. Verified it fails when the assert_port_free call is removed. Deploy simulation: 26 passed. Full unit suite: 3316 passed, 12 skipped. --- scripts/ci-deploy.sh | 7 ++++++- src/lib/ci-deploy.test.ts | 17 +++++++++++++++++ src/test/ci-deploy-harness.sh | 24 +++++++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh index 6eccb73d..5ce7ceee 100644 --- a/scripts/ci-deploy.sh +++ b/scripts/ci-deploy.sh @@ -151,7 +151,12 @@ read_active_port() { assert_port_free() { local port="$1" name="$2" local holders="" - if command -v ss >/dev/null 2>&1; then + # `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als + # shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de + # functie is niet geëxporteerd naar de subshell van start_candidate. Met `type` + # blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten + # hoeft te zien. + if type ss >/dev/null 2>&1; then # `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op # LISTEN, anders zou elke vrije poort als bezet gemeld worden. holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)" diff --git a/src/lib/ci-deploy.test.ts b/src/lib/ci-deploy.test.ts index 8aa1b582..d1b010b8 100644 --- a/src/lib/ci-deploy.test.ts +++ b/src/lib/ci-deploy.test.ts @@ -299,6 +299,23 @@ describe("blue/green cutover", () => { expect(r.upstream).not.toContain("127.0.0.1:3003"); }); + // Regressie: een poort die al in gebruik is liet `docker run` stilletjes op + // EADDRINUSE sterven. De health-probe beantwoordde daarna vanaf de + // reeds draaiende container op diezelfde poort, waardoor de + // release-vergelijking 30 keer op een verkeerde release faalde in plaats + // van op de echte oorzaak te wijzen. + it("refuses to start the candidate on a port that is already in use", () => { + const r = simulateBlueGreen("port-taken"); + expect(r.status, r.output).not.toBe(0); + expect(r.output).toContain("already in use"); + // Er is geen kandidaat gestart, dus er is ook niets om te verwijderen. + expect(r.calls).not.toContain("docker run"); + // De live release draait ongestoord door en nginx wijst nog steeds + // naar de oude poort: geen halve cutover. + expect(r.upstream).toContain("127.0.0.1:3002"); + expect(r.upstream).not.toContain("127.0.0.1:3003"); + }); + it.each([ "run-failure", "health-failure", diff --git a/src/test/ci-deploy-harness.sh b/src/test/ci-deploy-harness.sh index e7ccb682..5cba57a9 100644 --- a/src/test/ci-deploy-harness.sh +++ b/src/test/ci-deploy-harness.sh @@ -38,6 +38,28 @@ curl() { echo '{"database":true}' } sleep() { :; } +# De poortconflict-check (assert_port_free in scripts/ci-deploy.sh) leest de +# echte luisterende sockets. Zonder deze stub zou de simulatie de containers van +# de productiehost zien — de test draait immers op dezelfde machine — en elke +# blauwe/groene scenario laten falen op een poort die de simulatie zelf net +# virtueel heeft toegewezen. Standaard is geen enkele poort bezet; het scenario +# 'port-taken' reserveert expliciet de kandidaatpoort. +ss() { + local requested="" arg + for arg in "$@"; do + case "$arg" in + sport=*) requested="${arg#sport=}" ;; + 'sport'|'='|':') ;; + *:*) [ -z "$requested" ] && requested="${arg##*:}" ;; + esac + done + if [ "$SCENARIO" = port-taken ] && [ -n "$requested" ]; then + printf 'State Recv-Q Send-Q Local Address:Port Peer Address:Port\n' + printf 'LISTEN 0 511 0.0.0.0:%s 0.0.0.0:*\n' "$requested" + return 0 + fi + printf 'State Recv-Q Send-Q Local Address:Port Peer Address:Port\n' +} docker() { echo "docker $*" >> "$TEST_DIR/calls" local name="${@: -1}" @@ -70,7 +92,7 @@ docker() { *) return 0 ;; esac } -export -f git flock pnpm curl sleep docker nginx candidate_is_new +export -f git flock pnpm curl sleep ss docker nginx candidate_is_new node() { echo "node $*" >> "$TEST_DIR/calls"