diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c90732d..b9f2184 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -129,9 +129,33 @@ jobs: export PATH="$HOME/.local/bin:$PATH" uv run pytest tests_e2e/test_proxy_headers.py -sv --tb=short + Tests-E2E-Swarm: + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [Test] + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Install dependencies + run: | + export PATH="$HOME/.local/bin:$PATH" + uv sync --group dev + + - name: Run Swarm E2E tests + run: | + export PATH="$HOME/.local/bin:$PATH" + uv run pytest tests_e2e/test_swarm.py -sv --tb=short + Build: runs-on: ubuntu-latest - needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes, Tests-E2E-Additional] + needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes, Tests-E2E-Additional, Tests-E2E-Swarm] permissions: contents: read packages: write @@ -231,7 +255,7 @@ jobs: Publish-PyPI: runs-on: ubuntu-latest - needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes, Tests-E2E-Additional] + needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes, Tests-E2E-Additional, Tests-E2E-Swarm] if: startsWith(github.ref, 'refs/tags/') permissions: contents: read diff --git a/pyproject.toml b/pyproject.toml index 7735c6a..2985d06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ markers = [ "static: marks tests for static configuration mode", "acme: marks tests for certbot/acme", "proxy_headers: marks tests for proxy headers", + "swarm: marks tests for Docker Swarm mode", ] [tool.ruff] diff --git a/tests_e2e/swarm/cloudflare.yml b/tests_e2e/swarm/cloudflare.yml index 30b9d17..0864663 100644 --- a/tests_e2e/swarm/cloudflare.yml +++ b/tests_e2e/swarm/cloudflare.yml @@ -70,7 +70,7 @@ version: "3.7" services: haproxy: - image: byjg/easy-haproxy:5.0.0 + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock configs: diff --git a/tests_e2e/swarm/easyhaproxy.yml b/tests_e2e/swarm/easyhaproxy.yml index 362fc5e..552bd2a 100644 --- a/tests_e2e/swarm/easyhaproxy.yml +++ b/tests_e2e/swarm/easyhaproxy.yml @@ -54,7 +54,7 @@ services: haproxy: - image: byjg/easy-haproxy:5.0.0 + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock - ./certs:/certs/haproxy diff --git a/tests_e2e/swarm/ip-whitelist.yml b/tests_e2e/swarm/ip-whitelist.yml index 053fa6e..c14a926 100644 --- a/tests_e2e/swarm/ip-whitelist.yml +++ b/tests_e2e/swarm/ip-whitelist.yml @@ -65,7 +65,7 @@ version: "3.7" services: haproxy: - image: byjg/easy-haproxy:5.0.0 + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock deploy: diff --git a/tests_e2e/swarm/jwt-validator.yml b/tests_e2e/swarm/jwt-validator.yml index 1885dbe..d58db64 100644 --- a/tests_e2e/swarm/jwt-validator.yml +++ b/tests_e2e/swarm/jwt-validator.yml @@ -78,7 +78,7 @@ version: "3.7" services: haproxy: - image: byjg/easy-haproxy:5.0.0 + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock configs: diff --git a/tests_e2e/swarm/plugins-combined.yml b/tests_e2e/swarm/plugins-combined.yml index f38b4cd..28c3f4e 100644 --- a/tests_e2e/swarm/plugins-combined.yml +++ b/tests_e2e/swarm/plugins-combined.yml @@ -94,7 +94,7 @@ version: "3.7" services: haproxy: - image: byjg/easy-haproxy:5.0.0 + image: byjg/easy-haproxy:local volumes: - /var/run/docker.sock:/var/run/docker.sock configs: diff --git a/tests_e2e/test_docker_compose.py b/tests_e2e/test_docker_compose.py index 1c850b7..83db51c 100644 --- a/tests_e2e/test_docker_compose.py +++ b/tests_e2e/test_docker_compose.py @@ -121,7 +121,7 @@ def create_cloudflare_ips_file(): _cloudflare_ips_created = True -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_basic_ssl() -> Generator[None, None, None]: """Fixture for docker-compose.yml (Basic SSL)""" fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose.yml")) @@ -130,7 +130,7 @@ def docker_compose_basic_ssl() -> Generator[None, None, None]: fixture.down() -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_jwt_validator() -> Generator[None, None, None]: """Fixture for docker-compose-jwt-validator.yml""" fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-jwt-validator.yml")) @@ -139,7 +139,7 @@ def docker_compose_jwt_validator() -> Generator[None, None, None]: fixture.down() -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_multi_containers() -> Generator[None, None, None]: """Fixture for docker-compose-multi-containers.yml""" fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-multi-containers.yml")) @@ -148,7 +148,7 @@ def docker_compose_multi_containers() -> Generator[None, None, None]: fixture.down() -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_php_fpm() -> Generator[None, None, None]: """Fixture for docker-compose-php-fpm.yml""" fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-php-fpm.yml")) @@ -157,7 +157,7 @@ def docker_compose_php_fpm() -> Generator[None, None, None]: fixture.down() -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_plugins_combined() -> Generator[None, None, None]: """Fixture for docker-compose-plugins-combined.yml""" # Create cloudflare_ips.lst (required by this compose file) @@ -169,7 +169,7 @@ def docker_compose_plugins_combined() -> Generator[None, None, None]: fixture.down() -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_ip_whitelist() -> Generator[None, None, None]: """Fixture for docker-compose-ip-whitelist.yml""" fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-ip-whitelist.yml")) @@ -178,7 +178,7 @@ def docker_compose_ip_whitelist() -> Generator[None, None, None]: fixture.down() -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_cloudflare() -> Generator[None, None, None]: """Fixture for docker-compose-cloudflare.yml""" # Create cloudflare_ips.lst (required by this compose file) @@ -751,7 +751,7 @@ class TestCloudflare: # Test: docker-compose-changed-label.yml - Custom Label Prefix # ============================================================================= -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_changed_label() -> Generator[None, None, None]: """Fixture for docker-compose-changed-label.yml""" fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-changed-label.yml")) @@ -843,7 +843,7 @@ class TestChangedLabel: # Test: docker-compose-acme-e2e.yml - ACME/Certbot with Pebble # ============================================================================= -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_acme() -> Generator[None, None, None]: """Fixture for docker-compose-acme-e2e.yml - ACME/Certbot E2E test""" volume_name = "docker_certbot-certs" diff --git a/tests_e2e/test_kubernetes.py b/tests_e2e/test_kubernetes.py index 0a4f8b6..663ce70 100644 --- a/tests_e2e/test_kubernetes.py +++ b/tests_e2e/test_kubernetes.py @@ -476,7 +476,7 @@ class KubernetesFixture: ) -@pytest.fixture +@pytest.fixture(scope="class") def k8s_service(kind_cluster) -> Generator[str, None, None]: """Fixture for service.yml""" kubectl_cmd = kind_cluster["kubectl"] @@ -486,7 +486,7 @@ def k8s_service(kind_cluster) -> Generator[str, None, None]: fixture.delete() -@pytest.fixture +@pytest.fixture(scope="class") def k8s_ip_whitelist(kind_cluster) -> Generator[str, None, None]: """Fixture for ip-whitelist.yml""" kubectl_cmd = kind_cluster["kubectl"] @@ -496,7 +496,7 @@ def k8s_ip_whitelist(kind_cluster) -> Generator[str, None, None]: fixture.delete() -@pytest.fixture +@pytest.fixture(scope="class") def k8s_service_tls(kind_cluster) -> Generator[str, None, None]: """Fixture for service_tls.yml with generated certificates""" kubectl_cmd = kind_cluster["kubectl"] @@ -573,7 +573,7 @@ def k8s_service_tls(kind_cluster) -> Generator[str, None, None]: ) -@pytest.fixture +@pytest.fixture(scope="class") def k8s_jwt_validator_secret(kind_cluster) -> Generator[dict, None, None]: """Fixture for jwt-validator-secret-example.yml with generated JWT keys""" if not JWT_AVAILABLE: @@ -665,7 +665,7 @@ def k8s_jwt_validator_secret(kind_cluster) -> Generator[dict, None, None]: ) -@pytest.fixture +@pytest.fixture(scope="class") def k8s_cloudflare(kind_cluster, kind_cmd) -> Generator[str, None, None]: """Fixture for cloudflare.yml with base64-encoded IP list""" kubectl_cmd = kind_cluster["kubectl"] diff --git a/tests_e2e/test_proxy_headers.py b/tests_e2e/test_proxy_headers.py index faa7fe5..d5f3ac3 100644 --- a/tests_e2e/test_proxy_headers.py +++ b/tests_e2e/test_proxy_headers.py @@ -36,7 +36,7 @@ BASE_DIR = Path(__file__).parent.absolute() DOCKER_DIR = BASE_DIR / "docker" -@pytest.fixture +@pytest.fixture(scope="class") def docker_compose_proxy_headers() -> Generator[None, None, None]: """Fixture for testing proxy headers with header-echo server""" fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-proxy-headers.yml")) diff --git a/tests_e2e/test_swarm.py b/tests_e2e/test_swarm.py new file mode 100644 index 0000000..4556be1 --- /dev/null +++ b/tests_e2e/test_swarm.py @@ -0,0 +1,635 @@ +""" +Pytest test suite for EasyHAProxy Docker Swarm examples + +These tests verify the functionality of Docker Swarm stack configurations. +Tests require Docker with Swarm support. + +Test scenarios: + 1. TestSwarmBasicServices: easyhaproxy.yml + services.yml + - HTTPS for host1.local and host2.local (embedded SSL cert) + - HTTP to HTTPS redirect + - HAProxy stats interface + - HAProxy config verification + + 2. TestSwarmPluginsCombined: plugins-combined.yml + - Public website (Cloudflare + deny_pages) + - Protected API (JWT validator + deny_pages) + - Admin panel (IP whitelist) + - HAProxy stats interface + +Requirements: +- Docker with Swarm support +- pytest, requests, PyJWT, cryptography + +Usage: + # Run all swarm tests + pytest tests_e2e/test_swarm.py -v + + # Run specific test class + pytest tests_e2e/test_swarm.py::TestSwarmBasicServices -v + + # Run with markers + pytest tests_e2e/test_swarm.py -m swarm -v +""" + +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Generator + +import pytest +import requests + +BASE_DIR = Path(__file__).parent.absolute() +SWARM_DIR = BASE_DIR / "swarm" + +# Session-level init state (prevent redundant work within a session; +# swarm and network are left running after the session so subsequent runs skip setup) +_swarm_image_built = False +_swarm_initialized = False +_swarm_network_created = False +# Session-level cloudflare config state +_cloudflare_config_created = False + + +# ============================================================================= +# Image and Swarm Infrastructure Helpers +# ============================================================================= + +def ensure_haproxy_image(): + """Build byjg/easy-haproxy:local from source at the start of the test session. + + Always builds on the first call so tests run against the current codebase, + never a stale image left over from a previous session or docker compose run. + Subsequent calls within the same session are no-ops (image already built). + """ + global _swarm_image_built + if _swarm_image_built: + return + + project_root = BASE_DIR.parent + print("\n → Building byjg/easy-haproxy:local from source...") + subprocess.run( + [ + "docker", "build", + "-t", "byjg/easy-haproxy:local", + "-f", str(project_root / "deploy/docker/Dockerfile"), + str(project_root), + ], + check=True, + ) + print(" ✓ Image built as byjg/easy-haproxy:local") + _swarm_image_built = True + + +def init_swarm() -> None: + """Initialize Docker Swarm if not already done this session. + + Uses a session-level flag (like ensure_haproxy_image) so the check runs at most + once per pytest session regardless of how many fixtures call it. + Swarm is left running after the session so subsequent runs skip initialization. + """ + global _swarm_initialized + if _swarm_initialized: + return + + result = subprocess.run( + ["docker", "info", "--format", "{{.Swarm.LocalNodeState}}"], + capture_output=True, text=True, check=True + ) + if result.stdout.strip() != "active": + print("\n → Initializing Docker Swarm...") + subprocess.run(["docker", "swarm", "init"], check=True, capture_output=True) + print(" ✓ Docker Swarm initialized") + else: + print("\n ✓ Docker Swarm already active") + + _swarm_initialized = True + + +def create_overlay_network(network_name: str = "easyhaproxy") -> None: + """Create an attachable overlay network if not already done this session. + + Uses a session-level flag (like ensure_haproxy_image / init_swarm) so the check + runs at most once per pytest session. The network is left running after the + session so subsequent runs skip creation. + """ + global _swarm_network_created + if _swarm_network_created: + return + + result = subprocess.run( + ["docker", "network", "ls", "--filter", f"name={network_name}", "--format", "{{.Name}}"], + capture_output=True, text=True, check=True + ) + existing = [n.strip() for n in result.stdout.strip().split("\n") if n.strip()] + if network_name not in existing: + print(f"\n → Creating overlay network '{network_name}'...") + subprocess.run( + ["docker", "network", "create", "--driver", "overlay", "--attachable", network_name], + check=True, capture_output=True + ) + print(f" ✓ Overlay network '{network_name}' created") + else: + print(f"\n ✓ Overlay network '{network_name}' already exists") + + _swarm_network_created = True + + +def wait_for_swarm_services(stack_name: str, timeout: int = 120) -> bool: + """Poll until all services in a stack have reached their target replica count.""" + start_time = time.time() + print(f"\n → Waiting for stack '{stack_name}' services (timeout: {timeout}s)...") + while time.time() - start_time < timeout: + result = subprocess.run( + ["docker", "stack", "services", stack_name, "--format", "{{.Replicas}}"], + capture_output=True, text=True + ) + if result.returncode != 0 or not result.stdout.strip(): + time.sleep(2) + continue + + replicas = [r.strip() for r in result.stdout.strip().split("\n") if "/" in r] + if not replicas: + time.sleep(2) + continue + + all_ready = all(r.split("/")[0] == r.split("/")[1] for r in replicas) + if all_ready: + elapsed = time.time() - start_time + print(f" ✓ All {len(replicas)} service(s) ready ({elapsed:.1f}s)") + return True + + time.sleep(2) + + return False + + +def wait_for_http( + url: str, + headers: dict = None, + expected_status: list = None, + timeout: int = 120, + verify_ssl: bool = False, +) -> bool: + """Poll an HTTP endpoint until it responds with an expected status code.""" + if expected_status is None: + expected_status = [200, 301, 302, 403, 401, 404] + + start_time = time.time() + while time.time() - start_time < timeout: + try: + resp = requests.get( + url, + headers=headers or {}, + verify=verify_ssl, + allow_redirects=False, + timeout=5, + ) + if resp.status_code in expected_status: + return True + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + pass + + time.sleep(3) + + return False + + +def get_haproxy_container_id(stack_name: str, service_name: str = "haproxy") -> str: + """Return the container ID for a swarm service (e.g., 'easyhaproxy_haproxy').""" + result = subprocess.run( + ["docker", "ps", "--filter", f"name={stack_name}_{service_name}", "--format", "{{.ID}}"], + capture_output=True, text=True, check=True + ) + container_ids = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()] + if not container_ids: + raise RuntimeError(f"No container found for {stack_name}_{service_name}") + return container_ids[0] + + +def create_swarm_config(config_name: str, file_path: Path) -> None: + """Create a Docker swarm config from a file, removing any existing one first.""" + subprocess.run(["docker", "config", "rm", config_name], capture_output=True) + subprocess.run( + ["docker", "config", "create", config_name, str(file_path)], + check=True, capture_output=True + ) + print(f" ✓ Docker config '{config_name}' created") + + +def remove_swarm_config(config_name: str) -> None: + """Remove a Docker swarm config, ignoring errors if it doesn't exist.""" + subprocess.run(["docker", "config", "rm", config_name], capture_output=True) + + +def create_cloudflare_config(): + """Download Cloudflare IP ranges and create a Docker swarm config. + + Adds the 10.0.0.0/8 range so that requests routed through Docker Swarm's + ingress network (typically 10.x.x.x) are treated as Cloudflare IPs in tests. + """ + global _cloudflare_config_created + if _cloudflare_config_created: + return + + print("\n → Creating 'cloudflare_ips' Docker config...") + with tempfile.NamedTemporaryFile(mode="w", suffix=".lst", delete=False) as f: + temp_path = Path(f.name) + + try: + subprocess.run( + ["curl", "-s", "https://www.cloudflare.com/ips-v4"], + stdout=open(temp_path, "w"), check=True + ) + with open(temp_path, "a") as f: + f.write("\n") + subprocess.run( + ["curl", "-s", "https://www.cloudflare.com/ips-v6"], + stdout=open(temp_path, "a"), check=True + ) + # Add Docker ingress range so test requests appear to come from Cloudflare + with open(temp_path, "a") as f: + f.write("\n10.0.0.0/8\n") + + create_swarm_config("cloudflare_ips", temp_path) + _cloudflare_config_created = True + finally: + temp_path.unlink(missing_ok=True) + + +# ============================================================================= +# SwarmFixture: manages docker stack lifecycle +# ============================================================================= + +class SwarmFixture: + """Helper class to manage Docker Swarm stack lifecycle.""" + + def __init__(self, stack_files, stack_name: str, timeout: int = 120): + self.stack_files = stack_files if isinstance(stack_files, list) else [stack_files] + self.stack_name = stack_name + self.timeout = timeout + + def up(self): + """Deploy the stack and wait for all services to be running.""" + for stack_file in self.stack_files: + name = Path(stack_file).name + print(f"\n → Deploying stack '{self.stack_name}' from {name}...") + result = subprocess.run( + [ + "docker", "stack", "deploy", + "--resolve-image", "never", # use local image, never pull from registry + "-c", stack_file, self.stack_name, + ], + capture_output=True, text=True + ) + if result.returncode != 0: + print(f" ✗ Deploy failed:\n stdout: {result.stdout}\n stderr: {result.stderr}") + raise subprocess.CalledProcessError( + result.returncode, result.args, result.stdout, result.stderr + ) + + if not wait_for_swarm_services(self.stack_name, self.timeout): + raise TimeoutError( + f"Stack '{self.stack_name}' services did not become ready within {self.timeout}s" + ) + + def down(self): + """Force-kill all stack containers, then remove the stack definition.""" + print(f"\n → Removing stack '{self.stack_name}'...") + + # Force-kill running containers immediately (no graceful shutdown period) + result = subprocess.run( + ["docker", "ps", "-q", "--filter", f"name={self.stack_name}_"], + capture_output=True, text=True + ) + container_ids = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()] + if container_ids: + subprocess.run(["docker", "kill"] + container_ids, capture_output=True) + + # Remove the stack definition (services, configs, secrets) + subprocess.run( + ["docker", "stack", "rm", self.stack_name], + capture_output=True, text=True + ) + + # Poll until no containers from this stack remain, so ports are free + start = time.time() + while time.time() - start < 60: + result = subprocess.run( + ["docker", "ps", "-q", "--filter", f"name={self.stack_name}_"], + capture_output=True, text=True + ) + if not result.stdout.strip(): + break + time.sleep(2) + + print(f" ✓ Stack '{self.stack_name}' removed") + + +# ============================================================================= +# Session-level setup: swarm mode + overlay network + image build +# ============================================================================= + +@pytest.fixture(scope="session", autouse=True) +def swarm_setup(): + """Session fixture: build image, initialize Docker Swarm, and create overlay network. + + Each step runs at most once per session (guarded by module-level flags). + Swarm mode and the overlay network are left running after the session so that + a subsequent test run can skip setup — the same pattern as the image build. + Only deployed stacks (SwarmFixture) are torn down between test classes. + """ + ensure_haproxy_image() + init_swarm() + create_overlay_network() + + yield + # No teardown of swarm/network: they persist for subsequent runs (faster second run) + + +# ============================================================================= +# Per-test fixtures +# ============================================================================= + +@pytest.fixture(scope="class") +def swarm_basic_services(generate_ssl_certificates) -> Generator[None, None, None]: + """Fixture for easyhaproxy.yml + services.yml. + + Deploys two stacks: + - easyhaproxy: HAProxy in swarm discovery mode + - services: Two backends (host1.local, host2.local) with embedded SSL + + EasyHAProxy auto-attaches services to the easyhaproxy overlay network on + each refresh cycle (default: every 10 seconds), then regenerates HAProxy + config to include the discovered backends. + """ + easyhaproxy = SwarmFixture(str(SWARM_DIR / "easyhaproxy.yml"), "easyhaproxy", timeout=120) + services = SwarmFixture(str(SWARM_DIR / "services.yml"), "services", timeout=60) + + easyhaproxy.up() + services.up() + + # Wait for EasyHAProxy to auto-attach services, regenerate config, and + # for HAProxy to start serving traffic (up to 2 refresh cycles = ~20s). + print("\n → Waiting for HAProxy to discover and configure swarm services...") + ready = wait_for_http( + "https://127.0.0.1/", + headers={"Host": "host1.local"}, + expected_status=[200, 301, 302, 503], + timeout=120, + ) + if not ready: + print(" ⚠ Warning: Services may not be fully configured yet") + else: + print(" ✓ Services are reachable through HAProxy") + + yield + services.down() + easyhaproxy.down() + + +@pytest.fixture(scope="class") +def swarm_plugins_combined(generate_ssl_certificates) -> Generator[None, None, None]: + """Fixture for plugins-combined.yml. + + Creates required Docker swarm configs (jwt_api_pubkey, cloudflare_ips) and + deploys a self-contained stack containing: + - HAProxy with plugin support + - Public website (Cloudflare + deny_pages) + - Protected API (JWT validator + deny_pages) + - Admin panel (IP whitelist: 203.0.113.0/24, 10.0.0.0/8) + """ + certs = generate_ssl_certificates + + print("\n → Setting up Docker configs for plugins-combined stack...") + create_swarm_config("jwt_api_pubkey", certs["jwt_pubkey"]) + create_cloudflare_config() + + stack = SwarmFixture(str(SWARM_DIR / "plugins-combined.yml"), "production", timeout=120) + stack.up() + + # Wait for HAProxy to discover services and apply plugin configurations + print("\n → Waiting for HAProxy to configure plugin backends...") + ready = wait_for_http( + "http://127.0.0.1/", + headers={"Host": "website.example.com"}, + expected_status=[200, 404, 403, 401, 503], + timeout=120, + ) + if not ready: + print(" ⚠ Warning: Services may not be fully configured yet") + else: + print(" ✓ Services are reachable through HAProxy") + + yield + stack.down() + + print("\n → Cleaning up Docker configs...") + remove_swarm_config("jwt_api_pubkey") + remove_swarm_config("cloudflare_ips") + global _cloudflare_config_created + _cloudflare_config_created = False + + +# ============================================================================= +# Tests: easyhaproxy.yml + services.yml - Basic Services with SSL in Swarm +# ============================================================================= + +@pytest.mark.swarm +@pytest.mark.ssl +class TestSwarmBasicServices: + """Tests for Swarm mode with basic SSL services. + + Uses easyhaproxy.yml (HAProxy) + services.yml (two SSL backends). + EasyHAProxy discovers services via Swarm API and auto-attaches them + to the shared overlay network. + """ + + def test_services_running(self, swarm_basic_services): + """Verify all expected swarm services are running.""" + result = subprocess.run( + ["docker", "service", "ls", "--format", "{{.Name}}"], + capture_output=True, text=True, check=True + ) + service_names = result.stdout + assert "easyhaproxy_haproxy" in service_names, "easyhaproxy_haproxy service not found" + assert "services_container" in service_names, "services_container service not found" + assert "services_container2" in service_names, "services_container2 service not found" + + def test_haproxy_config(self, swarm_basic_services): + """Verify HAProxy configuration contains backends for both swarm services.""" + from utils import extract_backend_block + container_id = get_haproxy_container_id("easyhaproxy") + result = subprocess.run( + ["docker", "exec", container_id, "cat", "/etc/easyhaproxy/haproxy/haproxy.cfg"], + capture_output=True, text=True, check=True + ) + config = result.stdout + + # Both HTTPS backends must be present + assert "backend srv_host1_local_443" in config, "HTTPS backend for host1.local not found" + assert "backend srv_host2_local_443" in config, "HTTPS backend for host2.local not found" + + # HTTP to HTTPS redirect must be configured + assert "redirect scheme https" in config or "redirect prefix https://" in config, \ + "HTTP to HTTPS redirect not found in HAProxy config" + + def test_https_host1(self, swarm_basic_services): + """Test HTTPS access to host1.local through Swarm HAProxy.""" + response = requests.get( + "https://127.0.0.1/", + headers={"Host": "host1.local"}, + verify=False, + timeout=10, + ) + assert response.status_code == 200 + + def test_https_host2(self, swarm_basic_services): + """Test HTTPS access to host2.local through Swarm HAProxy.""" + response = requests.get( + "https://127.0.0.1/", + headers={"Host": "host2.local"}, + verify=False, + timeout=10, + ) + assert response.status_code == 200 + + def test_http_redirect_host1(self, swarm_basic_services): + """Test HTTP to HTTPS permanent redirect for host1.local.""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "host1.local"}, + allow_redirects=False, + timeout=10, + ) + assert response.status_code == 301 + assert response.headers.get("location") == "https://host1.local/" + + def test_http_redirect_host2(self, swarm_basic_services): + """Test HTTP to HTTPS permanent redirect for host2.local.""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "host2.local"}, + allow_redirects=False, + timeout=10, + ) + assert response.status_code == 301 + assert response.headers.get("location") == "https://host2.local/" + + def test_haproxy_stats(self, swarm_basic_services): + """Test HAProxy stats interface is accessible.""" + from conftest import verify_haproxy_stats + verify_haproxy_stats() + + +# ============================================================================= +# Tests: plugins-combined.yml - Multiple Security Plugins in Swarm +# ============================================================================= + +@pytest.mark.swarm +@pytest.mark.plugins +class TestSwarmPluginsCombined: + """Tests for multiple combined security plugins in Swarm mode. + + Uses plugins-combined.yml which is a self-contained stack: + - HAProxy with Cloudflare + JWT + IP-whitelist plugins + - website.example.com: Cloudflare IP restoration + deny_pages + - api.example.com: JWT validator + deny_pages + - admin.example.com: IP whitelist (203.0.113.0/24, 10.0.0.0/8) + """ + + def test_services_running(self, swarm_plugins_combined): + """Verify all four services are running in the production stack.""" + result = subprocess.run( + ["docker", "service", "ls", "--format", "{{.Name}}"], + capture_output=True, text=True, check=True + ) + service_names = result.stdout + assert "production_haproxy" in service_names, "production_haproxy service not found" + assert "production_website" in service_names, "production_website service not found" + assert "production_api" in service_names, "production_api service not found" + assert "production_admin" in service_names, "production_admin service not found" + + def test_website_normal_access(self, swarm_plugins_combined): + """Test normal GET request reaches the public website (Cloudflare + deny_pages).""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "website.example.com"}, + timeout=10, + ) + assert response.status_code == 200 + + def test_website_blocked_paths(self, swarm_plugins_combined): + """Test deny_pages plugin blocks sensitive paths with HTTP 404.""" + blocked_paths = ["/admin", "/wp-admin", "/.env", "/config"] + for path in blocked_paths: + response = requests.get( + f"http://127.0.0.1{path}", + headers={"Host": "website.example.com"}, + timeout=10, + ) + assert response.status_code == 404, \ + f"Expected 404 for blocked path '{path}', got {response.status_code}" + + def test_api_without_token(self, swarm_plugins_combined): + """Test JWT validator rejects requests that have no Authorization header.""" + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "api.example.com"}, + timeout=10, + ) + assert response.status_code in (401, 403), \ + f"Expected 401/403 without JWT, got {response.status_code}" + assert "Missing Authorization HTTP header" in response.text + + def test_api_with_valid_token(self, swarm_plugins_combined, jwt_token): + """Test JWT validator allows requests with a valid RS256 JWT token.""" + response = requests.get( + "http://127.0.0.1/", + headers={ + "Host": "api.example.com", + "Authorization": f"Bearer {jwt_token}", + }, + timeout=10, + ) + assert response.status_code == 200 + + def test_api_blocked_paths_with_token(self, swarm_plugins_combined, jwt_token): + """Test deny_pages blocks internal API paths even with a valid JWT token.""" + blocked_paths = ["/internal", "/debug", "/metrics"] + for path in blocked_paths: + response = requests.get( + f"http://127.0.0.1{path}", + headers={ + "Host": "api.example.com", + "Authorization": f"Bearer {jwt_token}", + }, + timeout=10, + ) + assert response.status_code == 403, \ + f"Expected 403 for blocked API path '{path}', got {response.status_code}" + + def test_admin_panel_ip_whitelist(self, swarm_plugins_combined): + """Test IP whitelist plugin on admin panel. + + The whitelist is '203.0.113.0/24,10.0.0.0/8'. + In Docker Swarm ingress mode, requests from the host arrive at HAProxy + with the Docker ingress router IP (typically in 10.0.0.0/8), so the + admin panel should be accessible. + """ + response = requests.get( + "http://127.0.0.1/", + headers={"Host": "admin.example.com"}, + timeout=10, + ) + # Docker Swarm ingress IPs (10.x.x.x) are in the 10.0.0.0/8 whitelist + assert response.status_code == 200, \ + (f"Expected admin access from Docker ingress IP (in 10.0.0.0/8), " + f"got {response.status_code}") + + def test_haproxy_stats(self, swarm_plugins_combined): + """Test HAProxy stats interface is accessible.""" + from conftest import verify_haproxy_stats + verify_haproxy_stats() \ No newline at end of file