Bump to Postgres 18 in tests and prevent container leaks (#207)

Upgrade the test postgres container from 17-alpine to 18-alpine
to match what we use in the Docker image.

Also, use with_postgres.sh in change_processing.py, since we would
otherwise sometimes leak a Docker container when a test fails.

Assisted-by: Claude
diff --git a/tests/server/ui/change_processing.py b/tests/server/ui/change_processing.py
index 7213b70..d1fa555 100644
--- a/tests/server/ui/change_processing.py
+++ b/tests/server/ui/change_processing.py
@@ -1,13 +1,14 @@
 # Check that the LNT REST JSON API is working.
-# RUN: python %s %{utils}
+# RUN: %{utils}/with_postgres.sh %t.pg.log \
+# RUN:     python %s
 
 import datetime
 import logging
 import os
-import subprocess
-import sys
 import unittest
+import uuid
 
+import sqlalchemy
 from sqlalchemy import or_
 from sqlalchemy.orm import joinedload
 
@@ -20,10 +21,6 @@
 
 logging.basicConfig(level=logging.DEBUG)
 
-UTILS_DIR = sys.argv.pop(1)
-START_POSTGRES = os.path.join(UTILS_DIR, 'start_postgres.sh')
-STOP_POSTGRES = os.path.join(UTILS_DIR, 'stop_postgres.sh')
-
 
 def _mkorder(session, ts, rev):
     order = ts.Order()
@@ -35,13 +32,26 @@
 class ChangeProcessingTests(unittest.TestCase):
     """Test fieldchange and regression building."""
 
+    def _admin_execute(self, sql):
+        """Run a DDL statement (CREATE/DROP DATABASE) against the server."""
+        base_uri = os.environ['LNT_TEST_DB_URI']
+        engine = sqlalchemy.create_engine(base_uri + '/postgres', isolation_level='AUTOCOMMIT')
+        try:
+            with engine.connect() as conn:
+                conn.execute(sqlalchemy.text(sql))
+        finally:
+            engine.dispose()
+
     def setUp(self):
-        output = subprocess.check_output([START_POSTGRES, os.devnull], text=True)
-        env = dict(line.split('=', 1) for line in output.strip().splitlines())
-        self._container = env['LNT_PG_CONTAINER']
+        base_uri = os.environ['LNT_TEST_DB_URI']
+
+        # Create a fresh database for each test method so tests don't
+        # interfere with each other via leftover data.
+        self._db_name = 'lnt_test_' + uuid.uuid4().hex[:8]
+        self._admin_execute(f'CREATE DATABASE {self._db_name}')
 
         self.db = v4db.V4DB(
-            env['LNT_TEST_DB_URI'] + "/" + env['LNT_TEST_DB_NAME'],
+            base_uri + "/" + self._db_name,
             Config.dummy_instance())
         session = self.session = self.db.make_session()
 
@@ -129,8 +139,7 @@
     def tearDown(self):
         self.session.close()
         self.db.close()
-        subprocess.call([STOP_POSTGRES, self._container],
-                        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+        self._admin_execute(f'DROP DATABASE IF EXISTS {self._db_name}')
 
     def test_startup(self):
         pass
diff --git a/tests/utils/start_postgres.sh b/tests/utils/start_postgres.sh
deleted file mode 100755
index 7cd5a66..0000000
--- a/tests/utils/start_postgres.sh
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env bash
-#
-# start_postgres.sh <LOG_FILE>
-#
-# Start a fresh PostgreSQL container. Sets and outputs KEY=VALUE lines for:
-#
-#   LNT_PG_CONTAINER     Container name (needed to stop it later)
-#   LNT_TEST_DB_URI      Base connection URL (no database), e.g. postgresql://postgres@127.0.0.1:PORT
-#   LNT_TEST_DB_NAME     Name of the ready-to-use database ("lnt_test")
-#
-# Shell callers can capture and eval the output, e.g.:
-#   pg_output=$(start_postgres.sh /tmp/pg.log)
-#   eval "${pg_output}"
-# Python callers can execute it and parse the KEY=VALUE stdout lines.
-#
-# LOG_FILE receives the PostgreSQL server logs (pass /dev/null to discard).
-# The caller is responsible for stopping and removing the container.
-#
-set -euo pipefail
-
-if ! command -v docker > /dev/null 2>&1; then
-    echo 1>&2 "error: Could not find 'docker' -- Docker is required to run the tests"
-    exit 1
-fi
-
-if [ $# -lt 1 ]; then
-    echo 1>&2 "usage: start_postgres.sh <LOG_FILE>"
-    exit 1
-fi
-
-LOG_FILE="$1"
-
-# ---------------------------------------------------------------------------
-# Unique container name — allows parallel test runs without collision.
-# ---------------------------------------------------------------------------
-CONTAINER_NAME="lnt_pg_$(uuidgen | tr '[:upper:]' '[:lower:]' | tr -d '-')"
-
-# ---------------------------------------------------------------------------
-# Start the container in the background and bind a random port from the host
-# to the container's 5432 port.
-#
-# Key flags:
-#   POSTGRES_HOST_AUTH_METHOD=trust     no password needed
-#   --tmpfs /var/lib/postgresql/data    store data in RAM — faster, no cleanup needed
-# ---------------------------------------------------------------------------
-echo 1>&2 "Starting container ${CONTAINER_NAME} ..."
-docker run \
-    --detach \
-    --name "${CONTAINER_NAME}" \
-    --publish "127.0.0.1::5432" \
-    --env POSTGRES_HOST_AUTH_METHOD=trust \
-    --tmpfs /var/lib/postgresql/data \
-    postgres:17-alpine \
-    > /dev/null 2>&1
-
-echo 1>&2 "Streaming PostgreSQL server logs into ${LOG_FILE}"
-docker logs --follow "${CONTAINER_NAME}" >> "${LOG_FILE}" 2>&1 &
-
-# ---------------------------------------------------------------------------
-# Discover the host port that Docker assigned.
-# ---------------------------------------------------------------------------
-HOST_PORT=$(docker port "${CONTAINER_NAME}" 5432/tcp | head -1 | sed 's/.*://')
-if [ -z "${HOST_PORT}" ]; then
-    echo 1>&2 "error: could not determine host port for container ${CONTAINER_NAME}"
-    docker logs "${CONTAINER_NAME}" 1>&2
-    exit 1
-fi
-
-echo 1>&2 "PostgreSQL available at: postgresql://postgres@127.0.0.1:${HOST_PORT}"
-
-# ---------------------------------------------------------------------------
-# Wait for PostgreSQL to accept connections (up to 30 seconds).
-#
-# NOTE: We use '-h localhost' to check via TCP rather than the Unix socket.
-# The official postgres image runs a temporary server during initdb that
-# listens only on the Unix socket (listen_addresses=''). Without '-h localhost',
-# pg_isready can succeed against that temp server, then createdb fails when
-# the socket disappears during the transition to the real server.
-# ---------------------------------------------------------------------------
-MAX_TRIES=30
-for i in $(seq 1 "${MAX_TRIES}"); do
-    if docker exec "${CONTAINER_NAME}" pg_isready --quiet -h localhost; then
-        echo 1>&2 "PostgreSQL ready after ${i} attempt(s)."
-        break
-    fi
-    if [ "${i}" -eq "${MAX_TRIES}" ]; then
-        echo 1>&2 "error: PostgreSQL did not become ready after ${MAX_TRIES}s."
-        docker logs "${CONTAINER_NAME}" 1>&2
-        exit 1
-    fi
-    sleep 1
-done
-
-# ---------------------------------------------------------------------------
-# Create a default database for tests to use.
-# ---------------------------------------------------------------------------
-docker exec "${CONTAINER_NAME}" createdb --username=postgres lnt_test
-
-# ---------------------------------------------------------------------------
-# Output machine-readable results to stdout (for callers that capture output,
-# e.g. Python subprocess). Shell callers can source this script directly and
-# use the variables.
-# ---------------------------------------------------------------------------
-LNT_PG_CONTAINER="${CONTAINER_NAME}"
-LNT_TEST_DB_URI="postgresql://postgres@127.0.0.1:${HOST_PORT}"
-LNT_TEST_DB_NAME="lnt_test"
-
-echo "LNT_PG_CONTAINER=${LNT_PG_CONTAINER}"
-echo "LNT_TEST_DB_URI=${LNT_TEST_DB_URI}"
-echo "LNT_TEST_DB_NAME=${LNT_TEST_DB_NAME}"
diff --git a/tests/utils/stop_postgres.sh b/tests/utils/stop_postgres.sh
deleted file mode 100755
index 33ae9dd..0000000
--- a/tests/utils/stop_postgres.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/usr/bin/env bash
-#
-# stop_postgres.sh <CONTAINER_NAME>
-#
-# Stop and remove a PostgreSQL container by name. Silently succeeds if the
-# container does not exist or has already been removed.
-#
-set -euo pipefail
-
-if [ $# -lt 1 ]; then
-    echo 1>&2 "usage: $(basename "$0") <CONTAINER_NAME>"
-    exit 1
-fi
-
-docker stop  "$1" > /dev/null 2>&1 || true
-docker rm    "$1" > /dev/null 2>&1 || true
diff --git a/tests/utils/with_postgres.sh b/tests/utils/with_postgres.sh
index eadefc0..284b517 100755
--- a/tests/utils/with_postgres.sh
+++ b/tests/utils/with_postgres.sh
@@ -15,8 +15,13 @@
 set -euo pipefail
 
 # ---------------------------------------------------------------------------
-# Arguments
+# Prerequisites
 # ---------------------------------------------------------------------------
+if ! command -v docker > /dev/null 2>&1; then
+    echo 1>&2 "error: Could not find 'docker' -- Docker is required to run the tests"
+    exit 1
+fi
+
 if [ $# -lt 2 ]; then
     echo 1>&2 "usage: $(basename "$0") <LOG_FILE> <command> [args...]"
     exit 1
@@ -31,26 +36,89 @@
 fi
 
 # ---------------------------------------------------------------------------
-# Start the container via start_postgres.sh and capture its output (environment
-# variables) so we can get the DB URI and name.
+# Unique container name — allows parallel test runs without collision.
 # ---------------------------------------------------------------------------
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-pg_output=$("${SCRIPT_DIR}/start_postgres.sh" "${LOG_FILE}")
-eval "${pg_output}"
-export LNT_TEST_DB_URI LNT_TEST_DB_NAME
+CONTAINER_NAME="lnt_pg_$(uuidgen | tr '[:upper:]' '[:lower:]' | tr -d '-')"
+
+# ---------------------------------------------------------------------------
+# Start the container in the background and bind a random port from the host
+# to the container's 5432 port.
+#
+# Key flags:
+#   POSTGRES_HOST_AUTH_METHOD=trust     no password needed
+#   --tmpfs /var/lib/postgresql         store data in RAM — faster, no cleanup needed
+# ---------------------------------------------------------------------------
+echo 1>&2 "Starting container ${CONTAINER_NAME} ..."
+docker run \
+    --detach \
+    --name "${CONTAINER_NAME}" \
+    --publish "127.0.0.1::5432" \
+    --env POSTGRES_HOST_AUTH_METHOD=trust \
+    --tmpfs /var/lib/postgresql \
+    postgres:18-alpine \
+    > /dev/null 2>&1
 
 # ---------------------------------------------------------------------------
 # Cleanup — always stop and remove the container, success or failure.
+# Registered immediately after docker run so that failures during the
+# readiness wait or createdb don't leak the container.
 # ---------------------------------------------------------------------------
 cleanup() {
     local exit_code=$?
-    echo "Stopping container ${LNT_PG_CONTAINER} ..."
-    "${SCRIPT_DIR}/stop_postgres.sh" "${LNT_PG_CONTAINER}"
+    echo "Stopping container ${CONTAINER_NAME} ..."
+    docker stop "${CONTAINER_NAME}" > /dev/null 2>&1 || true
+    docker rm   "${CONTAINER_NAME}" > /dev/null 2>&1 || true
     exit "${exit_code}"
 }
 trap cleanup EXIT
 
+echo 1>&2 "Streaming PostgreSQL server logs into ${LOG_FILE}"
+docker logs --follow "${CONTAINER_NAME}" >> "${LOG_FILE}" 2>&1 &
+
 # ---------------------------------------------------------------------------
-# Run the wrapped command.
+# Discover the host port that Docker assigned.
 # ---------------------------------------------------------------------------
+HOST_PORT=$(docker port "${CONTAINER_NAME}" 5432/tcp | head -1 | sed 's/.*://')
+if [ -z "${HOST_PORT}" ]; then
+    echo 1>&2 "error: could not determine host port for container ${CONTAINER_NAME}"
+    docker logs "${CONTAINER_NAME}" 1>&2
+    exit 1
+fi
+
+echo 1>&2 "PostgreSQL available at: postgresql://postgres@127.0.0.1:${HOST_PORT}"
+
+# ---------------------------------------------------------------------------
+# Wait for PostgreSQL to accept connections (up to 30 seconds).
+#
+# NOTE: We use '-h localhost' to check via TCP rather than the Unix socket.
+# The official postgres image runs a temporary server during initdb that
+# listens only on the Unix socket (listen_addresses=''). Without '-h localhost',
+# pg_isready can succeed against that temp server, then createdb fails when
+# the socket disappears during the transition to the real server.
+# ---------------------------------------------------------------------------
+MAX_TRIES=30
+for i in $(seq 1 "${MAX_TRIES}"); do
+    if docker exec "${CONTAINER_NAME}" pg_isready --quiet -h localhost; then
+        echo 1>&2 "PostgreSQL ready after ${i} attempt(s)."
+        break
+    fi
+    if [ "${i}" -eq "${MAX_TRIES}" ]; then
+        echo 1>&2 "error: PostgreSQL did not become ready after ${MAX_TRIES}s."
+        docker logs "${CONTAINER_NAME}" 1>&2
+        exit 1
+    fi
+    sleep 1
+done
+
+# ---------------------------------------------------------------------------
+# Create a default database for tests to use.
+# ---------------------------------------------------------------------------
+docker exec "${CONTAINER_NAME}" createdb --username=postgres lnt_test
+
+# ---------------------------------------------------------------------------
+# Export the connection details and run the wrapped command.
+# ---------------------------------------------------------------------------
+export LNT_TEST_DB_URI="postgresql://postgres@127.0.0.1:${HOST_PORT}"
+export LNT_TEST_DB_NAME="lnt_test"
+
 "$@"