mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-03 14:16:03 +08:00
test: add hermetic e2e support services
This commit is contained in:
165
e2e/scripts/audit_sources.py
Normal file
165
e2e/scripts/audit_sources.py
Normal 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())
|
||||
176
e2e/scripts/build_test_image.py
Normal file
176
e2e/scripts/build_test_image.py
Normal 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())
|
||||
190
e2e/scripts/collect_artifacts.py
Normal file
190
e2e/scripts/collect_artifacts.py
Normal 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())
|
||||
125
e2e/scripts/generate_fixtures.py
Normal file
125
e2e/scripts/generate_fixtures.py
Normal 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
509
e2e/scripts/stack.py
Normal 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())
|
||||
Reference in New Issue
Block a user