fix(deploy): stub ss in the deploy harness and cover the port-conflict path
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m47s
CI / tests-unit (push) Successful in 1m54s
CI / tests-ui (push) Successful in 2m40s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m41s

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.
This commit is contained in:
openhands committed 2026-10-03 18:30:00 +02:00
1 parent 64ad9baf39
commit 8ee144745a
3 files changed
+46 -2

No files matched your search

+6 -1
View File
@@ -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)"
+17
View File
@@ -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",
+23 -1
View File
@@ -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"