Files
lmxopcua/scripts/ci/integration-fixtures.sh
T
Joseph Doherty b114779ba5 fix(ci): #860 integration fixtures survive a busy host port and concurrent runs
Hosted v2-integration run 1224 got past the old fixture-host timeout and
failed one step earlier: "Bind for :::19000 failed: port is already
allocated" — the runner's docker daemon is shared. Fixtures now retry on the
next host port when one is taken (opc-plc keeps host == listen port, as its
advertised endpoint requires) and export the ports actually used; container
names carry the run id so a concurrent run's `down` can't remove them, and a
new `logs` subcommand replaces the workflow's hard-coded name list.

Verified locally: with 19000 squatted, QuestDB moved to 18813/19001 and every
fixture reached ready; logs/down found the suffixed containers.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 16:53:14 -05:00

189 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# integration-fixtures.sh — throwaway, self-contained fixtures for the v2-integration workflow (#757).
#
# up start SQL Server, PostgreSQL (seeded), QuestDB, opc-plc and a port-NAT opc-plc as containers named
# otopcua-ci-*, wait until each is usable, and print the env exports the
# *.IntegrationTests fixtures read (also appended to $GITHUB_ENV when set).
# down remove those containers (idempotent; touches nothing else).
# logs print the tail of each fixture container's log (the workflow's on-failure step).
#
# Deliberately NOT the shared 10.100.0.35 host: CI must not depend on that box, and these
# containers are created and destroyed inside the job. Host ports are non-default so a
# developer's own fixtures are never clobbered when the script is run locally.
#
# Reaching the containers: on the host (or a host-network job) that is 127.0.0.1; when the job
# itself runs inside a container (/.dockerenv) the published ports live on the docker host,
# i.e. the container's default gateway. Override with FIXTURE_HOST.
set -euo pipefail
SQL_PORT="${CI_SQL_PORT:-14340}"
PG_PORT="${CI_PG_PORT:-15440}"
QDB_PORT="${CI_QDB_PORT:-18812}"
QDB_HTTP_PORT="${CI_QDB_HTTP_PORT:-19000}"
OPC_PORT="${CI_OPC_PORT:-50100}"
# #813 NAT opc-plc: published on OPC_NAT_PORT, listening on 50000 inside, advertising a private `.invalid`
# host — so its GetEndpoints URLs are unreachable on BOTH host and port (NatPublishedEndpointBrowseTests).
OPC_NAT_PORT="${CI_OPC_NAT_PORT:-50110}"
# Throwaway-container-only credentials (never used against anything else).
SA_PASSWORD="${CI_SA_PASSWORD:-Ci_throwaway_Pw1}"
PG_PASSWORD="${CI_PG_PASSWORD:-Ci_throwaway_pg1}"
# Per-run container names: the runner's docker daemon is shared, so fixed names let a concurrent run's `down` remove
# this run's containers. GITHUB_RUN_ID is set in every step of a run, so `up`, `logs` and `down` agree.
SUFFIX="${CI_FIXTURE_SUFFIX-${GITHUB_RUN_ID:+-$GITHUB_RUN_ID}}"
C_MSSQL="otopcua-ci-mssql$SUFFIX"
C_PG="otopcua-ci-pg$SUFFIX"
C_QDB="otopcua-ci-questdb$SUFFIX"
C_OPC="otopcua-ci-opcplc$SUFFIX"
C_NAT="otopcua-ci-opcplc-nat$SUFFIX"
NAMES=("$C_MSSQL" "$C_PG" "$C_QDB" "$C_OPC" "$C_NAT")
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# #841: CI keeps its OWN throwaway QuestDB (hermetic — never the shared /opt/otopcua-questdb fixture), but runs the
# SAME image the shared fixture's compose file pins, read from that file so the two cannot drift. CI_QDB_IMAGE
# overrides it only to trial an upgrade.
QDB_COMPOSE="$ROOT/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml"
questdb_image() {
if [ -n "${CI_QDB_IMAGE:-}" ]; then echo "$CI_QDB_IMAGE"; return; fi
local img
img="$(sed -n 's/^[[:space:]]*image:[[:space:]]*\(questdb\/questdb:[^[:space:]]*\).*/\1/p' "$QDB_COMPOSE" | head -n 1)"
if [ -z "$img" ]; then echo "no questdb/questdb:<tag> image line in $QDB_COMPOSE" >&2; return 1; fi
echo "$img"
}
# The job container's default gateway, read from /proc/net/route (#860): the act_runner job image
# (catthehacker/ubuntu:act-latest) ships no `ip`, so the old `ip route` probe silently fell back to 127.0.0.1
# inside the container and every published-port fixture (QuestDB first) timed out.
default_gateway() {
if command -v ip >/dev/null 2>&1; then
ip route | awk '/^default/{print $3; exit}'; return
fi
# Columns: Iface Destination Gateway ...; the default route has Destination 00000000. Gateway is little-endian hex.
# Plain bash (no gawk strtonum: the image's awk may be mawk).
local iface dest gw _
[ -r /proc/net/route ] || return 0
while read -r iface dest gw _; do
if [ "$dest" = "00000000" ] && [ "$gw" != "00000000" ] && [ ${#gw} -eq 8 ]; then
echo "$((16#${gw:6:2})).$((16#${gw:4:2})).$((16#${gw:2:2})).$((16#${gw:0:2}))"; return 0
fi
done < /proc/net/route
}
fixture_host() {
if [ -n "${FIXTURE_HOST:-}" ]; then echo "$FIXTURE_HOST"; return; fi
if [ -f /.dockerenv ]; then
local gw; gw="$(default_gateway)"
if [ -n "$gw" ]; then echo "$gw"; return; fi
echo "WARNING: in a container but no default gateway found; falling back to 127.0.0.1" >&2
fi
echo 127.0.0.1
}
down() { docker rm -f "${NAMES[@]}" >/dev/null 2>&1 || true; }
logs() { for c in "${NAMES[@]}"; do echo "=== $c ==="; docker logs --tail 40 "$c" 2>&1 || true; done; }
# start_on_free_port <container> <VAR[,VAR2]> <docker run args, with @P0@ / @P1@ standing for the ports>
# The runner's docker host is shared, so a default host port can already be taken (#860 follow-up: run 1224 failed
# "Bind for :::19000 failed: port is already allocated"). On that error, retry with every port bumped by one; the
# chosen values are written back to the named variables, which the waits and the exports read afterwards.
start_on_free_port() {
local name="$1" vars="$2"; shift 2
local -a names; IFS=, read -r -a names <<< "$vars"
local -a ports=(); local v; for v in "${names[@]}"; do ports+=("${!v}"); done
local attempt out i
for ((attempt = 0; attempt < 20; attempt++)); do
local -a args=("$@")
for i in "${!ports[@]}"; do args=("${args[@]//@P${i}@/${ports[$i]}}"); done
if out="$(docker run -d --name "$name" "${args[@]}" 2>&1)"; then
for i in "${!names[@]}"; do printf -v "${names[$i]}" '%s' "${ports[$i]}"; done
return 0
fi
docker rm -f "$name" >/dev/null 2>&1 || true
if [[ "$out" == *"port is already allocated"* || "$out" == *"address already in use"* ]]; then
echo "host port(s) ${ports[*]} busy for $name; retrying one higher" >&2
for i in "${!ports[@]}"; do ports[$i]=$((ports[$i] + 1)); done
continue
fi
echo "$out" >&2; return 1
done
echo "no free host port for $name after 20 attempts" >&2; return 1
}
wait_for() { # <description> <timeout-seconds> <command...>
local what="$1" t="$2"; shift 2
local end=$((SECONDS + t))
until "$@" >/dev/null 2>&1; do
if [ "$SECONDS" -ge "$end" ]; then echo "TIMEOUT waiting for $what" >&2; return 1; fi
sleep 3
done
echo "ready: $what"
}
up() {
command -v docker >/dev/null || { echo "docker not available" >&2; exit 1; }
local qdb_image; qdb_image="$(questdb_image)"
down
start_on_free_port "$C_MSSQL" SQL_PORT -e ACCEPT_EULA=Y -e MSSQL_PID=Developer \
-e "MSSQL_SA_PASSWORD=$SA_PASSWORD" -p "@P0@:1433" mcr.microsoft.com/mssql/server:2022-latest
start_on_free_port "$C_PG" PG_PORT -e "POSTGRES_PASSWORD=$PG_PASSWORD" -e POSTGRES_DB=sqlpollfixture \
-p "@P0@:5432" postgres:17-alpine
start_on_free_port "$C_QDB" QDB_PORT,QDB_HTTP_PORT -p "@P0@:8812" -p "@P1@:9000" "$qdb_image"
# opc-plc advertises the port it listens on, so it must be the SAME inside and out (--pn follows the host port).
start_on_free_port "$C_OPC" OPC_PORT -p "@P0@:@P0@" \
mcr.microsoft.com/iotedge/opc-plc:2.14.10 --pn="@P0@" --ut --aa --alm
# Mirrors tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/nat/docker-compose.yml.
start_on_free_port "$C_NAT" OPC_NAT_PORT --hostname otopcua-ci-opcplc-nat-private -p "@P0@:50000" \
mcr.microsoft.com/iotedge/opc-plc:2.14.10 --pn=50000 --ph=otopcua-ci-opcplc-nat-private.invalid --ut --aa
echo "fixture ports: sql=$SQL_PORT pg=$PG_PORT questdb=$QDB_PORT/$QDB_HTTP_PORT opc=$OPC_PORT opc-nat=$OPC_NAT_PORT (containers *$SUFFIX)"
wait_for "postgres" 120 docker exec "$C_PG" pg_isready -U postgres -d sqlpollfixture
# pg_isready can answer during initdb's temp server; require a real query on the final server.
wait_for "postgres query" 60 docker exec -e "PGPASSWORD=$PG_PASSWORD" "$C_PG" \
psql -h 127.0.0.1 -U postgres -d sqlpollfixture -c 'select 1'
docker exec -i -e "PGPASSWORD=$PG_PASSWORD" "$C_PG" \
psql -v ON_ERROR_STOP=1 -v "app_password=$PG_PASSWORD" -h 127.0.0.1 -U postgres -d sqlpollfixture \
< "$ROOT/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/seed-pg.sql" >/dev/null
echo "seeded: postgres"
echo "fixture host: $(fixture_host) (in container: $([ -f /.dockerenv ] && echo yes || echo no))"
local sqlcmd=/opt/mssql-tools18/bin/sqlcmd
wait_for "sql server" 300 docker exec "$C_MSSQL" "$sqlcmd" -C -S localhost -U sa -P "$SA_PASSWORD" -Q 'SELECT 1'
wait_for "questdb" 120 curl -fsS "http://$(fixture_host):$QDB_HTTP_PORT/exec?query=select%201"
wait_for "opc-plc" 120 bash -c "exec 3<>/dev/tcp/$(fixture_host)/$OPC_PORT"
wait_for "opc-plc (NAT)" 120 bash -c "exec 3<>/dev/tcp/$(fixture_host)/$OPC_NAT_PORT"
# #818: hand the NAT server's PUBLIC certificate to NatPublishedEndpointBrowseTests (OPCUA_NAT_SERVER_CERT) so the
# positive secure / host-name cases run here too. Copied out of the container — the trust channel is docker itself,
# not the wire. The throwaway container makes a fresh certificate per run, so the committed shared-host copy cannot
# be used; the private key never leaves the container.
local nat_cert="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/otopcua-ci-fixtures/opc-plc-nat-server.der"
mkdir -p "$(dirname "$nat_cert")"
wait_for "opc-plc (NAT) certificate" 60 bash -c \
"docker exec '$C_NAT' bash -c 'cat /app/pki/own/certs/*.der' > '$nat_cert' && [ -s '$nat_cert' ]"
local h; h="$(fixture_host)"
local -a exports=(
"SQL_TEST_ENDPOINT=$h,$SQL_PORT"
"SQL_TEST_USER=sa"
"SQL_TEST_PASSWORD=$SA_PASSWORD"
"SQL_PG_ENDPOINT=$h:$PG_PORT"
"SQL_PG_PASSWORD=$PG_PASSWORD"
"HISTORIAN_SQL_TEST_CONNECTION=Server=$h,$SQL_PORT;User Id=sa;Password=$SA_PASSWORD;TrustServerCertificate=True;Encrypt=False"
"HISTORIAN_QUESTDB_TEST_CONNECTION=Host=$h;Port=$QDB_PORT;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading"
"OPCUA_SIM_ENDPOINT=opc.tcp://$h:$OPC_PORT"
"OPCUA_NAT_ENDPOINT=opc.tcp://$h:$OPC_NAT_PORT"
"OPCUA_NAT_SERVER_CERT=$nat_cert"
)
for e in "${exports[@]}"; do
echo "export \"$e\""
[ -n "${GITHUB_ENV:-}" ] && echo "$e" >> "$GITHUB_ENV"
done
}
case "${1:-}" in
up) up ;;
down) down ;;
logs) logs ;;
*) echo "usage: $0 up|down|logs" >&2; exit 2 ;;
esac