Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""
Root conftest.py - cleans up Docker test containers.

Handles two scenarios:
1. Session start: removes orphaned containers from previous interrupted runs.
2. Keyboard interrupt (Ctrl+C / VS Code stop): immediately kills containers before the process exits.
"""

from __future__ import annotations

import subprocess


def _remove_test_containers() -> None:
"""Find and forcefully remove all test_* Docker containers."""
try:
result = subprocess.run(
["docker", "ps", "-a", "--filter", "name=test_", "--format", "{{.Names}}"],
check=False,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
containers = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()]
if containers:
subprocess.run(["docker", "rm", "-f", *containers], check=False, capture_output=True, timeout=10)
except Exception:
pass


def pytest_sessionstart(session): # noqa: ANN001, ANN201
"""Remove any orphaned test Docker containers from previous runs."""
_remove_test_containers()


def pytest_keyboard_interrupt(excinfo): # noqa: ANN001, ANN201
"""Called on Ctrl+C / VS Code stop — immediately kill all test containers."""
_remove_test_containers()
10 changes: 5 additions & 5 deletions src/eduid/graphdb/testing.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import logging
import random
import unittest
from collections.abc import Sequence
from os import environ
Expand All @@ -10,6 +9,7 @@

from eduid.graphdb.db import Neo4jDB
from eduid.userdb.testing import EduidTemporaryInstance
from eduid.userdb.testing.temp_instance import _random_available_port

__author__ = "lundberg"

Expand Down Expand Up @@ -39,9 +39,9 @@ class Neo4jTemporaryInstance(EduidTemporaryInstance):
DEFAULT_PASSWORD = "testingtesting"

def __init__(self, max_retry_seconds: int = 60, neo4j_version: str = NEO4J_VERSION) -> None:
self._http_port = random.randint(40000, 43000)
self._https_port = random.randint(44000, 46000)
self._bolt_port = random.randint(47000, 50000)
self._http_port = _random_available_port(40000, 43000)
self._https_port = _random_available_port(44000, 46000)
self._bolt_port = _random_available_port(47000, 50000)
self._docker_name = f"test_neo4j_{self.bolt_port}"
self._neo4j_version = neo4j_version
self._host = "localhost"
Expand All @@ -53,7 +53,7 @@ def command(self) -> Sequence[str]:
return [
"docker",
"run",
"--rm",
"--restart=always",
"--name",
f"{self._docker_name}",
"-p",
Expand Down
4 changes: 2 additions & 2 deletions src/eduid/queue/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def command(self) -> Sequence[str]:
return [
"docker",
"run",
"--rm",
"--restart=always",
"-p",
f"{self.port}:{self.port}",
"-e",
Expand Down Expand Up @@ -90,7 +90,7 @@ def command(self) -> Sequence[str]:
return [
"docker",
"run",
"--rm",
"--restart=always",
"-p",
f"{self.port}:8025",
"--name",
Expand Down
2 changes: 1 addition & 1 deletion src/eduid/userdb/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def command(self) -> Sequence[str]:
return [
"docker",
"run",
"--rm",
"--restart=always",
"-p",
f"{self.port}:27017",
"--name",
Expand Down
36 changes: 30 additions & 6 deletions src/eduid/userdb/testing/temp_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import random
import shutil
import socket
import subprocess
import tempfile
import time
Expand All @@ -15,6 +16,29 @@

logger = logging.getLogger(__name__)

# Track all ports reserved by EduidTemporaryInstance subclasses to avoid collisions
_reserved_ports: set[int] = set()


def _is_port_free(port: int) -> bool:
"""Check if a port is actually available for binding."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return True
except OSError:
return False


def _random_available_port(low: int = 40000, high: int = 65535) -> int:
"""Pick a random port that hasn't been reserved by another temporary instance and is free on the system."""
for _ in range(100):
port = random.randint(low, high)
if port not in _reserved_ports and _is_port_free(port):
_reserved_ports.add(port)
return port
raise RuntimeError(f"Could not find an available port in range {low}-{high} after 100 attempts")


class EduidTemporaryInstance(ABC):
"""Singleton to manage a temporary instance of something needed when testing.
Expand All @@ -28,7 +52,7 @@ class EduidTemporaryInstance(ABC):
def __init__(self, max_retry_seconds: int) -> None:
self._conn: Any | None = None # self._conn should be initialised by subclasses in `setup_conn'
self._tmpdir = tempfile.mkdtemp()
self._port = random.randint(40000, 65535)
self._port = _random_available_port()
self._logfile = open(f"/tmp/{self.__class__.__name__}-{self.port}.log", "w") # noqa: SIM115

start_time = utc_now()
Expand Down Expand Up @@ -118,15 +142,15 @@ def shutdown(self) -> None:
break

if container_name:
# Stop the container - docker stop handles graceful shutdown with SIGTERM
subprocess.run(["docker", "stop", container_name], check=False, capture_output=True)
# Force-remove the container immediately (don't wait for graceful shutdown)
subprocess.run(["docker", "rm", "-f", container_name], check=False, capture_output=True, timeout=5)

# Wait for the docker run process to exit (it should exit when container stops)
# Wait for the docker run process to exit
try:
self._process.wait(timeout=10)
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
logger.warning("Docker run process didn't exit, terminating it")
self._process.terminate()
self._process.kill()
self._process.wait()

# Flush the logfile but don't close it - closing it causes "ValueError: I/O operation on closed file"
Expand Down
2 changes: 1 addition & 1 deletion src/eduid/webapp/common/session/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def command(self) -> Sequence[str]:
return [
"docker",
"run",
"--rm",
"--restart=always",
"-p",
f"{self.port!s}:6379",
"--name",
Expand Down
Loading