test: add hermetic e2e support services

This commit is contained in:
Estrella Pan
2026-07-10 12:26:46 +02:00
parent 582144b209
commit 8ad0762e04
26 changed files with 3548 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Statically reject non-hermetic or collision-prone E2E source patterns."""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence
from urllib.parse import urlsplit
_URL_RE = re.compile(r"https?://[^\s\"'<>]+")
_FIXED_PORT_RE = re.compile(r"127\.0\.0\.1:\d+:\d+")
_BLIND_SLEEP_RE = re.compile(r"\b(?:time\.)?sleep\(\s*\d+(?:\.\d+)?\s*\)")
_ALLOWED_HOSTS = {
"127.0.0.1",
"0.0.0.0",
"::1",
"localhost",
"mock-upstream",
"fake-qb",
"qbittorrent",
}
@dataclass(frozen=True)
class AuditFinding:
path: Path
line: int
message: str
def _source_files(root: Path) -> Iterable[Path]:
e2e_root = root / "e2e"
if e2e_root.exists():
for path in e2e_root.rglob("*"):
if path.is_file() and (
path.name == "Dockerfile"
or path.suffix in {".py", ".yml", ".yaml", ".ts"}
):
if path.name == "audit_sources.py":
continue
yield path
playwright = root / "webui/playwright.config.ts"
if playwright.is_file():
yield playwright
def _add(
findings: list[AuditFinding],
path: Path,
line: int,
message: str,
) -> None:
findings.append(AuditFinding(path=path, line=line, message=message))
def _audit_line(
root: Path,
path: Path,
line_number: int,
line: str,
findings: list[AuditFinding],
) -> None:
relative = path.relative_to(root)
if "container_name" in line:
_add(findings, relative, line_number, "container_name is forbidden")
if ":latest" in line:
_add(findings, relative, line_number, "floating image tag is forbidden")
if _FIXED_PORT_RE.search(line):
_add(findings, relative, line_number, "fixed host port is forbidden")
if path.name not in {"stack.py", "poll.py"} and _BLIND_SLEEP_RE.search(line):
_add(findings, relative, line_number, "blind sleep is forbidden")
if path.name == "playwright.config.ts":
retry = re.search(r"\bretries\s*:\s*(\d+)", line)
if retry and int(retry.group(1)) != 0:
_add(findings, relative, line_number, "Playwright retries must be zero")
for raw_url in _URL_RE.findall(line):
try:
hostname = urlsplit(raw_url.rstrip("),];")).hostname
except ValueError:
if "{" in raw_url: # A local f-string URL is checked after expansion.
continue
_add(findings, relative, line_number, "malformed URL in E2E source")
continue
if hostname and hostname not in _ALLOWED_HOSTS:
_add(
findings,
relative,
line_number,
f"public upstream host is forbidden: {hostname}",
)
def _audit_image_references(
root: Path,
path: Path,
lines: list[str],
findings: list[AuditFinding],
) -> None:
relative = path.relative_to(root)
if path.name == "Dockerfile":
for line_number, line in enumerate(lines, 1):
match = re.match(r"\s*FROM\s+(\S+)", line, re.I)
if match and "@sha256:" not in match.group(1):
_add(
findings,
relative,
line_number,
"external Dockerfile base image must use an immutable digest",
)
if path.suffix in {".yml", ".yaml"}:
for line_number, line in enumerate(lines, 1):
match = re.match(r"\s*image:\s*([^\s#]+)", line)
if not match:
continue
image = match.group(1).strip("\"'")
if image.startswith("${"):
continue
if "/" in image and "@sha256:" not in image:
_add(
findings,
relative,
line_number,
"external Compose image must use an immutable digest",
)
def audit_repository(root: Path) -> list[AuditFinding]:
root = root.resolve()
findings: list[AuditFinding] = []
for path in sorted(set(_source_files(root))):
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
lines = text.splitlines()
for line_number, line in enumerate(lines, 1):
_audit_line(root, path, line_number, line, findings)
_audit_image_references(root, path, lines, findings)
return findings
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"root",
nargs="?",
type=Path,
default=Path(__file__).resolve().parents[2],
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
findings = audit_repository(args.root)
for finding in findings:
print(f"{finding.path}:{finding.line}: {finding.message}")
return 1 if findings else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Build the native E2E image from an isolated temporary context."""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Iterable, Sequence
DEFAULT_VERSION = "3.3.999-e2e.1"
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$")
def repository_root() -> Path:
return Path(__file__).resolve().parents[2]
def validate_version(version: str) -> str:
if not _VERSION_RE.fullmatch(version):
raise ValueError(f"E2E image version must be SemVer-compatible: {version!r}")
return version
def tracked_backend_files(repo_root: Path) -> tuple[Path, ...]:
"""Return tracked build inputs while excluding unrelated untracked files."""
result = subprocess.run(
[
"git",
"ls-files",
"-z",
"--",
".dockerignore",
"Dockerfile",
"entrypoint.sh",
"backend",
],
cwd=repo_root,
check=True,
capture_output=True,
)
return tuple(
Path(value.decode("utf-8")) for value in result.stdout.split(b"\0") if value
)
def _safe_relative(path: Path) -> Path:
if path.is_absolute() or ".." in path.parts:
raise ValueError(f"Tracked path must stay inside the repository: {path}")
return path
def prepare_context(
repo_root: Path,
dist_dir: Path,
destination: Path,
*,
version: str = DEFAULT_VERSION,
tracked_files: Iterable[Path] | None = None,
) -> Path:
"""Copy tracked build inputs, WebUI dist, and version into *destination*."""
version = validate_version(version)
repo_root = repo_root.resolve()
dist_dir = dist_dir.resolve()
destination = destination.resolve()
if not (dist_dir / "index.html").is_file():
raise FileNotFoundError(f"Built WebUI is missing index.html: {dist_dir}")
destination.mkdir(parents=True, exist_ok=True)
selected = tracked_files or tracked_backend_files(repo_root)
for relative in selected:
relative = _safe_relative(Path(relative))
source = repo_root / relative
if not source.is_file():
raise FileNotFoundError(f"Tracked build input is missing: {source}")
target = destination / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
staged_dist = destination / "backend/src/dist"
if staged_dist.exists():
shutil.rmtree(staged_dist)
shutil.copytree(dist_dir, staged_dist)
version_module = destination / "backend/src/module/__version__.py"
version_module.parent.mkdir(parents=True, exist_ok=True)
version_module.write_text(f'VERSION = "{version}"\n', encoding="utf-8")
return destination
def build_command(context: Path, *, image: str, version: str) -> list[str]:
validate_version(version)
if not image or any(character.isspace() for character in image):
raise ValueError(f"Invalid Docker image name: {image!r}")
return [
"docker",
"build",
"--build-arg",
f"VERSION={version}",
"--tag",
image,
"--pull=false",
str(context.resolve()),
]
def verification_command(*, image: str, version: str) -> list[str]:
validate_version(version)
script = (
"import sys; "
"from pathlib import Path; "
"from module.__version__ import VERSION; "
"expected=sys.argv[1]; "
"image_version=Path('/app/IMAGE_VERSION').read_text().strip(); "
"assert VERSION == expected == image_version, "
"(VERSION, expected, image_version)"
)
return [
"docker",
"run",
"--rm",
"--entrypoint",
"python",
image,
"-c",
script,
version,
]
def build_image(
*,
repo_root: Path,
dist_dir: Path,
image: str,
version: str,
runner=subprocess.run,
) -> None:
with tempfile.TemporaryDirectory(prefix="ab-e2e-build-") as temporary:
context = prepare_context(
repo_root,
dist_dir,
Path(temporary) / "context",
version=version,
)
runner(build_command(context, image=image, version=version), check=True)
runner(verification_command(image=image, version=version), check=True)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", default=DEFAULT_VERSION)
parser.add_argument("--image", default="auto-bangumi:e2e")
parser.add_argument("--dist", type=Path)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
root = repository_root()
dist = args.dist or root / "webui/dist"
build_image(
repo_root=root,
dist_dir=dist,
image=args.image,
version=args.version,
)
print(f"Built and verified {args.image} at version {args.version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Collect and redact diagnostics from a running hermetic E2E stack."""
from __future__ import annotations
import argparse
import http.cookiejar
import json
import re
import shutil
import subprocess
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Iterable, Mapping, Sequence
REDACTED = "[REDACTED]"
_AUTH_RE = re.compile(r"(?i)\bAuthorization\b\s*[:=]\s*(?:Bearer\s+)?[^\s,;]+")
_SENSITIVE_RE = re.compile(
r"(?i)\b(password|passwd|secret|token|cookie|sid)\b"
r"(\s*[:=]\s*)(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)"
)
def redact_text(text: str, secrets: Iterable[str] = ()) -> str:
redacted = text
for secret in sorted({value for value in secrets if value}, key=len, reverse=True):
redacted = redacted.replace(secret, REDACTED)
redacted = _AUTH_RE.sub(f"Authorization: {REDACTED}", redacted)
redacted = _SENSITIVE_RE.sub(
lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}", redacted
)
return redacted
def _write_redacted(path: Path, text: str, secrets: Iterable[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(redact_text(text, secrets), encoding="utf-8")
def _capture(
command: Sequence[str],
path: Path,
*,
environment: Mapping[str, str] | None,
secrets: Iterable[str],
) -> None:
try:
result = subprocess.run(
list(command),
check=False,
capture_output=True,
text=True,
timeout=30,
env=dict(environment) if environment is not None else None,
)
output = (
f"$ {' '.join(command)}\nexit={result.returncode}\n"
f"{result.stdout}\n{result.stderr}"
)
except Exception as exc: # Diagnostics must never hide the test failure.
output = f"$ {' '.join(command)}\ncollection failed: {exc!r}\n"
_write_redacted(path, output, secrets)
def _fetch_json(url: str, *, opener=None) -> str:
client = opener or urllib.request
with client.urlopen(url, timeout=5) as response:
payload = response.read().decode("utf-8", errors="replace")
try:
return json.dumps(json.loads(payload), indent=2, ensure_ascii=False)
except json.JSONDecodeError:
return payload
def _collect_admin(
name: str,
base_url: str,
artifact_dir: Path,
secrets: Iterable[str],
) -> None:
for endpoint in ("requests", "state"):
try:
payload = _fetch_json(f"{base_url.rstrip('/')}/__admin/{endpoint}")
except Exception as exc:
payload = f"request failed: {exc!r}\n"
_write_redacted(
artifact_dir / f"{name}-{endpoint}.json",
payload,
secrets,
)
def _collect_qbittorrent(
base_url: str,
artifact_dir: Path,
secrets: Iterable[str],
*,
username: str = "admin",
password: str = "adminadmin",
) -> None:
cookies = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
login = urllib.request.Request(
f"{base_url.rstrip('/')}/api/v2/auth/login",
data=urllib.parse.urlencode(
{"username": username, "password": password}
).encode("utf-8"),
)
try:
with opener.open(login, timeout=5) as response:
response.read()
payload = _fetch_json(
f"{base_url.rstrip('/')}/api/v2/torrents/info", opener=opener
)
except Exception as exc:
payload = f"qBittorrent state request failed: {exc!r}\n"
_write_redacted(artifact_dir / "qbittorrent-state.json", payload, secrets)
def collect_artifacts(
*,
compose_command: Sequence[str],
artifact_dir: Path,
data_dir: Path,
log_file: Path,
admin_urls: Mapping[str, str] | None = None,
qb_url: str | None = None,
environment: Mapping[str, str] | None = None,
secrets: Iterable[str] = (),
) -> Path:
"""Best-effort collection; failures are written into the bundle."""
artifact_dir.mkdir(parents=True, exist_ok=True)
all_secrets = tuple(secrets) + ("adminadmin",)
_capture(
[*compose_command, "ps", "--all"],
artifact_dir / "compose-ps.txt",
environment=environment,
secrets=all_secrets,
)
_capture(
[*compose_command, "logs", "--no-color", "--timestamps"],
artifact_dir / "compose-logs.txt",
environment=environment,
secrets=all_secrets,
)
for name, base_url in (admin_urls or {}).items():
_collect_admin(name, base_url, artifact_dir, all_secrets)
if qb_url:
_collect_qbittorrent(qb_url, artifact_dir, all_secrets)
if log_file.is_file():
_write_redacted(
artifact_dir / "backend-log.txt",
log_file.read_text(encoding="utf-8", errors="replace"),
all_secrets,
)
for name in ("data.db", "data.db-wal", "data.db-shm"):
source = data_dir / name
if source.is_file():
shutil.copy2(source, artifact_dir / name)
return artifact_dir
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--artifact-dir", required=True, type=Path)
parser.add_argument("--data-dir", required=True, type=Path)
parser.add_argument("--log-file", required=True, type=Path)
parser.add_argument("compose", nargs=argparse.REMAINDER)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
if not args.compose:
raise SystemExit("compose command prefix is required after --")
command = args.compose[1:] if args.compose[0] == "--" else args.compose
collect_artifacts(
compose_command=command,
artifact_dir=args.artifact_dir,
data_dir=args.data_dir,
log_file=args.log_file,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Generate deterministic binary fixtures for the hermetic E2E stack."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
FIXTURE_ROOT = Path(__file__).resolve().parents[1] / "fixtures"
MEDIA_PATH = Path("files/tiny-media.mkv")
TORRENT_PATH = Path("torrents/tiny-media.torrent")
CHECKSUM_PATH = Path("checksums.json")
GENERATED_PATHS = (MEDIA_PATH, TORRENT_PATH)
MEDIA_NAME = "Tiny Show - 01 [E2E].mkv"
WEB_SEED = "http://mock-upstream:18888/files/tiny-media.mkv"
PIECE_LENGTH = 16 * 1024
def bencode(value: bytes | str | int | list | tuple | dict) -> bytes:
"""Encode the small bencode subset used by the single-file torrent."""
if isinstance(value, bytes):
return str(len(value)).encode() + b":" + value
if isinstance(value, str):
return bencode(value.encode())
if isinstance(value, int):
return b"i" + str(value).encode() + b"e"
if isinstance(value, (list, tuple)):
return b"l" + b"".join(bencode(item) for item in value) + b"e"
if isinstance(value, dict):
items: list[tuple[bytes, object]] = []
for key, item in value.items():
encoded_key = key if isinstance(key, bytes) else str(key).encode()
items.append((encoded_key, item))
return (
b"d"
+ b"".join(bencode(key) + bencode(item) for key, item in sorted(items))
+ b"e"
)
raise TypeError(f"Unsupported bencode value: {type(value)!r}")
def build_media() -> bytes:
"""Return a stable 64 KiB payload without random or timestamp input."""
return bytes(range(256)) * 256
def build_torrent(media: bytes) -> bytes:
"""Return trackerless single-file metainfo backed by the local web seed."""
pieces = b"".join(
hashlib.sha1(media[offset : offset + PIECE_LENGTH]).digest()
for offset in range(0, len(media), PIECE_LENGTH)
)
return bencode(
{
b"created by": b"AutoBangumi hermetic E2E",
b"creation date": 0,
b"info": {
b"length": len(media),
b"name": MEDIA_NAME,
b"piece length": PIECE_LENGTH,
b"pieces": pieces,
},
b"url-list": WEB_SEED,
}
)
def generated_content() -> dict[Path, bytes]:
media = build_media()
return {MEDIA_PATH: media, TORRENT_PATH: build_torrent(media)}
def _checksum_content(content: dict[Path, bytes]) -> bytes:
checksums = {
str(path): hashlib.sha256(data).hexdigest() for path, data in content.items()
}
return (json.dumps(checksums, indent=2, sort_keys=True) + "\n").encode()
def write_fixtures(root: Path = FIXTURE_ROOT) -> None:
content = generated_content()
for relative_path, data in content.items():
destination = root / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(data)
(root / CHECKSUM_PATH).write_bytes(_checksum_content(content))
def check_fixtures(root: Path = FIXTURE_ROOT) -> list[str]:
content = generated_content()
expected = {**content, CHECKSUM_PATH: _checksum_content(content)}
mismatches: list[str] = []
for relative_path, data in expected.items():
destination = root / relative_path
if not destination.exists():
mismatches.append(f"missing: {relative_path}")
elif destination.read_bytes() != data:
mismatches.append(f"out of date: {relative_path}")
return mismatches
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true")
parser.add_argument("--output", type=Path, default=FIXTURE_ROOT)
args = parser.parse_args()
if args.check:
mismatches = check_fixtures(args.output)
if mismatches:
print("\n".join(mismatches))
return 1
print("E2E binary fixtures are deterministic and up to date.")
return 0
write_fixtures(args.output)
print(f"Wrote deterministic E2E fixtures to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

509
e2e/scripts/stack.py Normal file
View File

@@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""Lifecycle controller for isolated AutoBangumi E2E Compose stacks."""
from __future__ import annotations
import argparse
import http.cookiejar
import importlib.util
import json
import os
import re
import secrets
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from contextlib import contextmanager
from pathlib import Path
from typing import Callable, Mapping, Sequence
PROFILES = {"browser", "downloader"}
DEFAULT_WAIT_SECONDS = 120
QB_EXPECTED_PREFERENCES = {
"dht": False,
"pex": False,
"lsd": False,
"upnp": False,
"save_path": "/downloads/Bangumi",
"rss_processing_enabled": False,
"rss_auto_downloading_enabled": False,
}
def repository_root() -> Path:
return Path(__file__).resolve().parents[2]
def unique_project_name(profile: str) -> str:
if profile not in PROFILES:
raise ValueError(f"Unknown E2E profile: {profile}")
raw = f"ab-e2e-{profile}-{os.getpid()}-{secrets.token_hex(4)}".lower()
normalized = re.sub(r"[^a-z0-9-]+", "-", raw).strip("-")
return normalized[:63].rstrip("-")
def parse_published_port(output: str) -> int:
match = re.search(r":(\d+)\s*$", output.strip())
if not match:
raise ValueError(f"Cannot parse Docker Compose published port: {output!r}")
port = int(match.group(1))
if not 1 <= port <= 65535:
raise ValueError(f"Published port is out of range: {port}")
return port
def validate_qb_preferences(preferences: Mapping[str, object]) -> None:
mismatches = {
key: {"expected": expected, "actual": preferences.get(key)}
for key, expected in QB_EXPECTED_PREFERENCES.items()
if preferences.get(key) != expected
}
if mismatches:
raise AssertionError(
"qBittorrent E2E preferences are unsafe or non-deterministic: "
f"{json.dumps(mismatches, sort_keys=True)}"
)
def verify_qb_runtime(
base_url: str,
*,
username: str = "admin",
password: str = "adminadmin",
) -> dict[str, object]:
cookies = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
request = urllib.request.Request(
f"{base_url.rstrip('/')}/api/v2/auth/login",
data=urllib.parse.urlencode(
{"username": username, "password": password}
).encode("utf-8"),
)
with opener.open(request, timeout=5) as response:
body = response.read().decode("utf-8", errors="replace").strip()
if response.status not in (200, 204) or (
response.status == 200 and body.lower() != "ok."
):
raise AssertionError(
f"qBittorrent rejected fixed E2E credentials: "
f"HTTP {response.status} {body!r}"
)
with opener.open(
f"{base_url.rstrip('/')}/api/v2/app/preferences", timeout=5
) as response:
preferences = json.loads(response.read().decode("utf-8"))
if not isinstance(preferences, dict):
raise AssertionError("qBittorrent preferences response is not an object")
validate_qb_preferences(preferences)
return preferences
def _read_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.is_file():
return values
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, raw_value = line.split("=", 1)
try:
value = json.loads(raw_value)
except json.JSONDecodeError:
value = raw_value
values[key] = str(value)
return values
def _write_env_file(path: Path, values: Mapping[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = []
for key, value in sorted(values.items()):
if "\n" in value or "\r" in value:
raise ValueError(f"Environment value for {key} contains a newline")
lines.append(f"{key}={json.dumps(value)}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _load_artifact_module(repo_root: Path):
path = repo_root / "e2e/scripts/collect_artifacts.py"
spec = importlib.util.spec_from_file_location("ab_e2e_collect_artifacts", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Cannot load artifact collector: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class Stack:
"""One Compose project with isolated bind mounts and dynamic host ports."""
def __init__(
self,
*,
profile: str,
repo_root: Path | None = None,
work_dir: Path | None = None,
project_name: str | None = None,
runner=subprocess.run,
) -> None:
if profile not in PROFILES:
raise ValueError(f"Unknown E2E profile: {profile}")
self.profile = profile
self.repo_root = (repo_root or repository_root()).resolve()
self.project_name = project_name or unique_project_name(profile)
self.work_dir = (
work_dir.resolve()
if work_dir is not None
else Path(tempfile.mkdtemp(prefix=f"{self.project_name}-")).resolve()
)
self.config_dir = self.work_dir / "config"
self.data_dir = self.work_dir / "data"
self.download_dir = self.work_dir / "downloads"
self.log_dir = self.work_dir / "logs"
self.log_file = self.log_dir / "log.txt"
self.qb_config_dir = self.work_dir / "qb-config"
self.artifact_dir = self.work_dir / "artifacts"
self.env_file = self.work_dir / "stack.env"
self._runner = runner
self._started = False
for directory in (
self.config_dir,
self.data_dir,
self.download_dir,
self.log_dir,
self.qb_config_dir,
self.artifact_dir,
):
directory.mkdir(parents=True, exist_ok=True)
self.log_file.touch(exist_ok=True)
self._install_qb_config()
existing = _read_env_file(self.env_file)
self.environment: dict[str, str] = {
"AB_E2E_PROFILE": self.profile,
"AB_E2E_PROJECT": self.project_name,
"AB_E2E_CONFIG_DIR": str(self.config_dir),
"AB_E2E_DATA_DIR": str(self.data_dir),
"AB_E2E_DOWNLOAD_DIR": str(self.download_dir),
"AB_E2E_LOG_FILE": str(self.log_file),
"AB_E2E_QB_CONFIG_DIR": str(self.qb_config_dir),
"AB_E2E_FIXTURES_DIR": str(self.repo_root / "e2e/fixtures"),
"AB_E2E_ARTIFACT_DIR": str(self.artifact_dir),
"AB_E2E_USERNAME": "testadmin",
"AB_E2E_PASSWORD": "testpassword123",
"AB_E2E_APP_IMAGE": os.environ.get("AB_E2E_APP_IMAGE", "auto-bangumi:e2e"),
"AB_E2E_MOCK_IMAGE": os.environ.get(
"AB_E2E_MOCK_IMAGE", f"{self.project_name}/mock-upstream:local"
),
"AB_E2E_FAKE_QB_IMAGE": os.environ.get(
"AB_E2E_FAKE_QB_IMAGE", f"{self.project_name}/fake-qb:local"
),
"AB_E2E_PUID": str(getattr(os, "getuid", lambda: 1000)()),
"AB_E2E_PGID": str(getattr(os, "getgid", lambda: 1000)()),
"AB_E2E_MOCK_INTERNAL_URL": "http://mock-upstream:18888",
"AB_E2E_FAKE_QB_INTERNAL_URL": "http://fake-qb:8080",
"AB_E2E_QB_INTERNAL_URL": "http://qbittorrent:8080",
**existing,
}
self.write_environment()
@property
def compose_files(self) -> tuple[Path, ...]:
files = (self.repo_root / "e2e/compose/browser.yml",)
if self.profile == "downloader":
files += (self.repo_root / "e2e/compose/downloader.yml",)
return files
@property
def process_environment(self) -> dict[str, str]:
return {**os.environ, **self.environment}
def _install_qb_config(self) -> None:
source = self.repo_root / "e2e/fixtures/qbittorrent/qBittorrent.conf"
destination = self.qb_config_dir / "qBittorrent/qBittorrent.conf"
destination.parent.mkdir(parents=True, exist_ok=True)
if not destination.exists():
shutil.copy2(source, destination)
def write_environment(self) -> Path:
_write_env_file(self.env_file, self.environment)
return self.env_file
def compose_command(self, *arguments: str) -> list[str]:
command = [
"docker",
"compose",
"--project-name",
self.project_name,
"--env-file",
str(self.env_file),
]
for compose_file in self.compose_files:
command.extend(("--file", str(compose_file)))
command.extend(arguments)
return command
def _run_compose(
self,
*arguments: str,
check: bool = True,
capture_output: bool = False,
):
return self._runner(
self.compose_command(*arguments),
cwd=self.repo_root,
env=self.process_environment,
check=check,
capture_output=capture_output,
text=capture_output,
)
def validate(self) -> None:
self._run_compose("config", capture_output=True)
def _published_port(self, service: str, container_port: int) -> int:
result = self._run_compose(
"port",
"--index",
"1",
service,
str(container_port),
capture_output=True,
)
return parse_published_port(result.stdout)
def ports(self) -> dict[str, int]:
ports = {
"app": self._published_port("app", 7892),
"mock-upstream": self._published_port("mock-upstream", 18888),
"fake-qb": self._published_port("fake-qb", 8080),
}
if self.profile == "downloader":
ports["qbittorrent"] = self._published_port("qbittorrent", 8080)
return ports
def _publish_urls(self, ports: Mapping[str, int]) -> None:
self.environment.update(
{
"AB_E2E_BASE_URL": f"http://127.0.0.1:{ports['app']}",
"AB_E2E_MOCK_URL": (f"http://127.0.0.1:{ports['mock-upstream']}"),
"AB_E2E_FAKE_QB_URL": f"http://127.0.0.1:{ports['fake-qb']}",
}
)
if "qbittorrent" in ports:
real_qb_url = f"http://127.0.0.1:{ports['qbittorrent']}"
self.environment["AB_E2E_REAL_QB_URL"] = real_qb_url
self.environment["AB_E2E_QB_URL"] = real_qb_url
self.write_environment()
def start(self) -> dict[str, int]:
if self.profile == "downloader":
self._run_compose("pull", "qbittorrent")
self._run_compose("build", "mock-upstream", "fake-qb")
self._run_compose(
"up",
"--detach",
"--wait",
"--wait-timeout",
str(DEFAULT_WAIT_SECONDS),
)
self._started = True
published = self.ports()
self._publish_urls(published)
if self.profile == "downloader":
verify_qb_runtime(self.environment["AB_E2E_REAL_QB_URL"])
return published
def _wait_for_health(self, timeout: float = 30) -> None:
url = f"{self.environment['AB_E2E_BASE_URL'].rstrip('/')}/health"
deadline = time.monotonic() + timeout
last_error = "no request attempted"
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response:
if response.status == 200:
return
last_error = f"HTTP {response.status}"
except (OSError, urllib.error.URLError) as exc:
last_error = repr(exc)
time.sleep(0.2)
raise TimeoutError(f"App did not become healthy at {url}: {last_error}")
def restart(self, service: str = "app") -> None:
self._run_compose("restart", service)
if service == "app" and "AB_E2E_BASE_URL" in self.environment:
self._wait_for_health()
else:
self._run_compose(
"up",
"--detach",
"--wait",
"--wait-timeout",
str(DEFAULT_WAIT_SECONDS),
service,
)
def collect(self) -> None:
collector = _load_artifact_module(self.repo_root)
admin_urls = {}
if "AB_E2E_MOCK_URL" in self.environment:
admin_urls["mock-upstream"] = self.environment["AB_E2E_MOCK_URL"]
if "AB_E2E_FAKE_QB_URL" in self.environment:
admin_urls["fake-qb"] = self.environment["AB_E2E_FAKE_QB_URL"]
collector.collect_artifacts(
compose_command=self.compose_command(),
artifact_dir=self.artifact_dir,
data_dir=self.data_dir,
log_file=self.log_file,
admin_urls=admin_urls,
qb_url=self.environment.get("AB_E2E_REAL_QB_URL"),
environment=self.process_environment,
secrets=(
self.environment.get("AB_E2E_SESSION_TOKEN", ""),
self.environment.get("AB_E2E_API_TOKEN", ""),
),
)
def stop(self, *, collect: bool = True) -> None:
try:
if collect and self._started:
self.collect()
finally:
self._run_compose(
"down",
"--volumes",
"--remove-orphans",
check=False,
)
self._started = False
CommandRunner = Callable[[Sequence[str], Mapping[str, str]], object]
def _default_command_runner(
command: Sequence[str], environment: Mapping[str, str]
) -> int:
return subprocess.run(list(command), env=dict(environment), check=False).returncode
@contextmanager
def scoped_signal_cleanup(stack):
"""Ensure SIGINT/SIGTERM tear down this stack, then restore prior handlers."""
previous: dict[int, object] = {}
cleaning = False
def handle(signum, _frame):
nonlocal cleaning
if not cleaning:
cleaning = True
stack.stop(collect=True)
raise SystemExit(128 + signum)
try:
for signum in (signal.SIGINT, signal.SIGTERM):
previous[signum] = signal.getsignal(signum)
signal.signal(signum, handle)
yield
finally:
for signum, handler in previous.items():
signal.signal(signum, handler)
def run_with_stack(
stack,
command: Sequence[str],
*,
runner: CommandRunner = _default_command_runner,
):
with scoped_signal_cleanup(stack):
stack.start()
try:
return runner(list(command), {**os.environ, **stack.environment})
finally:
stack.stop(collect=True)
def _add_stack_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--profile", choices=sorted(PROFILES), default="browser")
parser.add_argument("--repo-root", type=Path, default=repository_root())
parser.add_argument("--work-dir", type=Path)
parser.add_argument("--project-name")
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="action", required=True)
for action in ("validate", "start", "stop", "ports", "env", "smoke"):
subparser = subparsers.add_parser(action)
_add_stack_arguments(subparser)
restart = subparsers.add_parser("restart")
_add_stack_arguments(restart)
restart.add_argument("service", nargs="?", default="app")
run = subparsers.add_parser("run")
_add_stack_arguments(run)
run.add_argument("command", nargs=argparse.REMAINDER)
return parser
def _stack_from_args(args) -> Stack:
return Stack(
profile=args.profile,
repo_root=args.repo_root,
work_dir=args.work_dir,
project_name=args.project_name,
)
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
stack = _stack_from_args(args)
if args.action == "validate":
stack.validate()
return 0
if args.action == "start":
print(json.dumps(stack.start(), sort_keys=True))
print(stack.env_file)
return 0
if args.action == "stop":
stack.stop(collect=True)
return 0
if args.action == "ports":
ports = stack.ports()
stack._publish_urls(ports)
print(json.dumps(ports, sort_keys=True))
return 0
if args.action == "env":
print(stack.env_file.read_text(encoding="utf-8"), end="")
return 0
if args.action == "restart":
stack.restart(args.service)
return 0
if args.action == "smoke":
with scoped_signal_cleanup(stack):
stack.start()
try:
stack._wait_for_health()
finally:
stack.stop(collect=True)
return 0
if args.action == "run":
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command:
raise SystemExit("A command is required after --")
result = run_with_stack(stack, command)
return int(result) if isinstance(result, int) else 0
raise AssertionError(f"Unhandled action: {args.action}")
if __name__ == "__main__":
raise SystemExit(main())