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
313 lines
14 KiB
Python
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
|