Files
ss-tools/backend/tests/integration/test_backend_container.py
busya 1e61f098bd fix(docker): add missing certs.sh COPY to backend/all-in-one Dockerfile
Backend entrypoint sources certs.sh for CA certificate installation,
but the file was never copied into the Docker image — causing
container crash loop on startup (regression in 8fd23f7e).

Changes:
- docker/backend.Dockerfile, docker/all-in-one.Dockerfile: COPY certs.sh
- docker/backend.entrypoint.sh: remove dead install_llm_ca_certs,
  install_certificates (replaced by docker/certs.sh); update @INVARIANT
- backend/src/core/ssl.py: fix describe_context() to report actual
  system cert count (SSLContext.capath attr does not exist in Python 3)
- __tests__: rewrite stale tests asserting LLM_SSL_VERIFY=false →
  verify=False; behaviour is permanently removed — always CERT_REQUIRED
- backend/tests/integration/test_backend_container.py [new]: 5 tests
  verifying certs.sh presence, sourceability, and full-stack smoke
  (testcontainers PG → entrypoint → migrations → health)
- conftest.py: restore health-wait loop and fixtures in superset_container
- ADR-0009, README.md, scripts, .env: align docs with centralized SSL
2026-07-07 00:58:41 +03:00

313 lines
14 KiB
Python

# #region TestBackendContainer [C:3] [TYPE Module] [SEMANTICS test,integration,backend,container,docker,entrypoint,certs]
# @BRIEF Integration tests for the superset-tools-backend Docker image:
# verifies entrypoint + certs.sh work correctly inside the container.
# @RELATION BINDS_TO -> [docker/backend.Dockerfile]
# @RELATION BINDS_TO -> [docker/certs.sh]
# @RELATION BINDS_TO -> [docker/backend.entrypoint.sh]
# @RELATION CALLS -> [TestSupersetTlsCustomCA]
# @RATIONALE In 0.5.0 release, backend entrypoint crashed with
# "/app/entrypoint.sh: line 264: /app/certs.sh: No such file or directory"
# because docker/backend.Dockerfile was missing COPY for certs.sh.
# Existing testcontainers tests only exercise apache/superset containers,
# not the superset-tools backend container. These tests close that gap.
# @TEST_CONTRACT BackendImageCertPaths ->
# /app/certs.sh exists and is readable
# source /app/certs.sh defines install_all_certs, install_ca_to_nss, normalize_cert
# /app/entrypoint.sh exists and is executable
# @TEST_CONTRACT BackendContainerSmoke ->
# docker compose up backend starts successfully
# /api/health/summary responds 200
# Container logs do NOT contain "certs.sh: No such file or directory"
# Container logs do NOT contain "Traceback" or unrecoverable startup error
# @REQUIRES Docker daemon running. --run-integration flag.
"""Integration tests for superset-tools-backend Docker image.
Two test suites:
1. **TestBackendImageCertsSh** — fast (no DB, no compose).
Builds the backend Docker image, runs a container with --entrypoint bash,
and verifies certs.sh is present, sourceable, and all certificate
installation functions are defined.
2. **TestBackendContainerSmoke** — full stack (needs PostgreSQL).
Starts PostgreSQL via testcontainers, deploys backend via docker compose,
waits for the health endpoint, and checks container logs for startup errors.
"""
import os
import re
import subprocess
import time
from pathlib import Path
import pytest
# ── Project paths ────────────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).resolve().parents[3]
BACKEND_IMAGE = "superset-tools-backend:test-integration"
# ── Fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def _backend_image():
"""Build superset-tools-backend:test-integration once per module."""
# Only rebuild if the Dockerfile changed (simple mtime check)
dockerfile = PROJECT_ROOT / "docker" / "backend.Dockerfile"
image_marker = PROJECT_ROOT / "backend" / ".image-built"
if not image_marker.exists() or image_marker.stat().st_mtime < dockerfile.stat().st_mtime:
subprocess.run(
["docker", "build", "-f", str(dockerfile), "-t", BACKEND_IMAGE, str(PROJECT_ROOT)],
check=True,
capture_output=True,
text=True,
timeout=300,
)
image_marker.touch()
return BACKEND_IMAGE
# ══════════════════════════════════════════════════════════════════════════
# TEST 1: Fast — verify certs.sh presence and basic functionality
# ══════════════════════════════════════════════════════════════════════════
class TestBackendImageCertPaths:
"""Fast checks: certs.sh exists, is sourceable, defines all functions.
These tests run in ~10s without PostgreSQL or docker compose.
They catch the 'certs.sh: No such file or directory' failure mode.
"""
@pytest.fixture(autouse=True)
def _image(self, _backend_image):
pass
# ── helpers ─────────────────────────────────────────────────────
@staticmethod
def _run_bash(command: str) -> subprocess.CompletedProcess:
return subprocess.run(
["docker", "run", "--rm", "--entrypoint", "bash", BACKEND_IMAGE, "-c", command],
capture_output=True,
text=True,
timeout=30,
)
# ── tests ───────────────────────────────────────────────────────
# #region test_certs_sh_exists [C:1] [TYPE Function] [SEMANTICS test,integration,certs]
# @BRIEF /app/certs.sh must exist and be readable.
def test_certs_sh_exists(self):
r = self._run_bash("test -f /app/certs.sh && test -r /app/certs.sh")
assert r.returncode == 0, f"File exists: {r.returncode == 0}, stderr: {r.stderr[:200]}"
# #endregion test_certs_sh_exists
# #region test_entrypoint_sh_exists [C:1] [TYPE Function] [SEMANTICS test,integration,entrypoint]
# @BRIEF /app/entrypoint.sh must exist and be executable.
def test_entrypoint_sh_exists(self):
r = self._run_bash("test -f /app/entrypoint.sh && test -x /app/entrypoint.sh")
assert r.returncode == 0, f"File: /app/entrypoint.sh, stderr: {r.stderr[:200]}"
# #endregion test_entrypoint_sh_exists
# #region test_certs_sh_source_defines_functions [C:2] [TYPE Function] [SEMANTICS test,integration,certs]
# @BRIEF sourcing /app/certs.sh defines install_all_certs, install_ca_to_nss, normalize_cert.
def test_certs_sh_source_defines_functions(self):
r = self._run_bash("source /app/certs.sh && declare -F install_all_certs >/dev/null 2>&1 && echo -n 'install_all_certs '; declare -F install_ca_to_nss >/dev/null 2>&1 && echo -n 'install_ca_to_nss '; declare -F normalize_cert >/dev/null 2>&1 && echo -n 'normalize_cert'")
assert "install_all_certs" in r.stdout, f"install_all_certs not defined:\n{r.stderr}"
assert "install_ca_to_nss" in r.stdout, f"install_ca_to_nss not defined:\n{r.stderr}"
assert "normalize_cert" in r.stdout, f"normalize_cert not defined:\n{r.stderr}"
# #endregion test_certs_sh_source_defines_functions
# #region test_entrypoint_sources_certs_sh_ok [C:2] [TYPE Function] [SEMANTICS test,integration,entrypoint]
# @BRIEF Running the real entrypoint script (without DB) must not crash on source certs.sh.
# We pass a short DB wait timeout so it fails fast instead of retrying 30 times.
# The certs.sh sourcing happens BEFORE the DB wait loop.
def test_entrypoint_sources_certs_sh_ok(self):
r = subprocess.run(
[
"docker",
"run",
"--rm",
"-e",
"POSTGRES_HOST=does-not-exist",
"-e",
"POSTGRES_USER=test",
"-e",
"POSTGRES_PASSWORD=test",
"-e",
"POSTGRES_DB=test",
# Reduce wait time — if certs.sh source succeeds, the error
# will be about DB connection, not certs.sh
"--entrypoint",
"bash",
BACKEND_IMAGE,
"-c",
"timeout 5 /app/entrypoint.sh 2>&1 || true",
],
capture_output=True,
text=True,
timeout=15,
)
# The certs.sh source comes BEFORE DB wait.
# If certs.sh is missing, the output MUST contain "No such file or directory".
# If certs.sh loads OK, the output should show DB connection error OR cert install.
assert re.search(r"No such file or directory.*certs\.sh|certs\.sh.*No such file or directory", r.stdout + r.stderr) is None, f"certs.sh not found! Entrypoint tried to source missing file:\nSTDOUT:\n{r.stdout[:2000]}\nSTDERR:\n{r.stderr[:2000]}"
# #endregion test_entrypoint_sources_certs_sh_ok
# ══════════════════════════════════════════════════════════════════════════
# TEST 2: Full stack — docker compose up backend + health check
# ══════════════════════════════════════════════════════════════════════════
@pytest.mark.skipif(
not os.environ.get("E2E_BACKEND_CONTAINER"),
reason="Skipped by default — set E2E_BACKEND_CONTAINER=1 to run full-stack test",
)
@pytest.mark.usefixtures("_backend_image")
class TestBackendContainerSmoke:
"""Full-stack smoke test: deploy backend container via docker compose
with PostgreSQL, wait for health endpoint, verify container logs.
Requires: set E2E_BACKEND_CONTAINER=1 (takes 30-60s, needs PostgreSQL).
"""
# #region test_backend_health [C:3] [TYPE Function] [SEMANTICS test,integration,smoke]
# @BRIEF docker run (host network) backend with testcontainers PostgreSQL,
# wait for health, check container logs for startup errors.
# @PRE Docker daemon running. Port 8000 must be free on the host.
# @POST Backend health endpoint responds 200. Container logs show no certs.sh errors.
# @SIDE_EFFECT Starts/stops PostgreSQL and backend Docker containers.
def test_backend_health(self):
import time as time_module
import urllib.request
import urllib.error
# ── Use host network mode so backend can reach PG on localhost ──
from testcontainers.postgres import PostgresContainer
pg = PostgresContainer(
"postgres:16-alpine",
dbname="ss_tools",
username="postgres",
password="postgres",
)
pg.start()
pg_port = pg.get_exposed_port(5432)
# ── Start backend container (host network) ──
container_name = f"ss-tools-integration-backend-{os.urandom(4).hex()}"
backend_port = "8000"
db_url = f"postgresql://postgres:postgres@127.0.0.1:{pg_port}/ss_tools"
run_cmd = [
"docker",
"run",
"-d",
"--name",
container_name,
"--network",
"host",
"-e",
f"DATABASE_URL={db_url}",
"-e",
"INITIAL_ADMIN_CREATE=true",
"-e",
"INITIAL_ADMIN_USERNAME=admin",
"-e",
"INITIAL_ADMIN_PASSWORD=admin123",
"-e",
"INITIAL_ADMIN_EMAIL=admin@example.com",
"-e",
"ENABLE_BELIEF_STATE_LOGGING=true",
"-e",
"TASK_LOG_LEVEL=ERROR",
"-e",
"CERTS_PATH=/opt/certs",
BACKEND_IMAGE,
]
r = subprocess.run(run_cmd, capture_output=True, text=True, timeout=30)
if r.returncode != 0:
pg.stop()
pytest.fail(f"docker run failed:\nSTDERR: {r.stderr[:2000]}")
container_id = r.stdout.strip()
container_removed = False
def _cleanup(rm_container=True):
nonlocal container_removed
if rm_container and not container_removed:
subprocess.run(["docker", "rm", "-f", container_id], capture_output=True, timeout=10)
container_removed = True
try:
pg.stop()
except Exception:
pass
try:
# ── Wait for health ──
health_url = f"http://127.0.0.1:{backend_port}/api/health/summary"
deadline = time_module.time() + 60
last_error = ""
while time_module.time() < deadline:
try:
hr = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--connect-timeout", "2", "--max-time", "4", health_url],
capture_output=True,
text=True,
timeout=10,
)
http_code = hr.stdout.strip()
if http_code in ("200", "401", "403"):
break
last_error = f"HTTP {http_code}"
except subprocess.TimeoutExpired:
last_error = "curl timeout"
except Exception as e:
last_error = str(e)
time_module.sleep(2)
else:
# Timeout — dump container logs
logs_r = subprocess.run(
["docker", "logs", container_id, "--tail", "60"],
capture_output=True,
text=True,
timeout=10,
)
full_log = logs_r.stdout[:5000] + logs_r.stderr[:2000]
_cleanup(rm_container=True)
pytest.fail(f"Backend health check failed after 60s.\nLast error: {last_error}\nContainer logs:\n{full_log}")
# ── Check container logs for startup errors ──
logs_r = subprocess.run(
["docker", "logs", container_id],
capture_output=True,
text=True,
timeout=10,
)
combined_logs = logs_r.stdout + logs_r.stderr
if re.search(r"No such file or directory.*certs\.sh|certs\.sh.*No such file", combined_logs):
_cleanup(rm_container=True)
pytest.fail(f"certs.sh not found! Entrypoint crashed:\n{combined_logs[:3000]}")
if re.search(r"Traceback|Error.*certs\.sh", combined_logs):
_cleanup(rm_container=True)
pytest.fail(f"Entrypoint crash detected:\n{combined_logs[:3000]}")
finally:
_cleanup(rm_container=True)
# #endregion test_backend_health
# #endregion TestBackendContainer