This commit is contained in:
2026-10-10 14:32:07 +07:00
parent 6a198f2e2c
commit 3efdb52c3c
12 changed files with 524 additions and 105 deletions
+12 -2
View File
@@ -2,13 +2,23 @@
Для работы площадки эти файлы не нужны. Обычный запуск: `make venue`.
- `deploy.py` — проверяет первый запуск, запуск без интернета и обновление без Docker.
- `deploy.py` — запускает проверки единого HTTPS-деплоя из `https.py` без Docker:
первый запуск, повторная сборка Docker-образа, принудительный перезапуск,
отсутствие артефактов, сертификаты и сохранность настроек.
- `smoke.py` — проверяет работающую площадку через API. Без `--verify` создаёт
тестовые данные; с `--verify` проверяет ранее созданные данные.
тестовые данные; с `--verify` проверяет ранее созданные данные. Всегда
использует HTTPS и прежние домены с проверкой доверия TLS; требует локального DNS.
- `https.py` — проверяет сертификаты и HTTPS-запуск с подставными командами.
`--docker` дополнительно запускает два временных Nginx-контейнера в отдельной
сети, проверяет TLS, маршруты, перенаправления и перезапуск, затем удаляет
свои контейнеры и сеть. Использует локальный `nginx:alpine` и временный тестовый CA;
работающие окружения, production и системное хранилище доверия не меняет.
Команды из корня проекта:
```sh
python3 tests/venue/deploy.py
python3 tests/venue/smoke.py --verify
python3 tests/venue/https.py
python3 tests/venue/https.py --docker
```
+3 -48
View File
@@ -1,52 +1,7 @@
"""Check first, offline and update runs without Docker or production writes."""
"""Check the unified HTTPS deployment without Docker or production writes."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import sys
source = Path(__file__).resolve().parents[2] / "venue" / "deploy.sh"
for name, cold, update in [("first", True, False), ("offline", False, False), ("update", False, True)]:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
venue = root / "venue"
venue.mkdir()
shutil.copy(source, venue / "deploy.sh")
log = root / "commands.log"
bin_dir = root / "bin"
bin_dir.mkdir()
for command in ["docker", "make", "npm"]:
stub = bin_dir / command
stub.write_text(
'#!/bin/sh\n'
'echo "$(basename "$0") $*" >> "$TEST_LOG"\n'
'if [ "$1 $2" = "image inspect" ] && [ "$TEST_COLD" = 1 ]; then exit 1; fi\n'
)
stub.chmod(0o755)
if not cold:
(venue / "migrations").mkdir()
(venue / ".env").write_text("EXISTING_SECRET=unchanged\n")
for artifact in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html"]:
path = venue / artifact
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], "TEST_LOG": str(log), "TEST_COLD": str(int(cold))}
subprocess.run(["bash", str(venue / "deploy.sh"), *(["--build"] if update else [])], env=env, check=True, capture_output=True)
commands = log.read_text()
assert "volume create evening-detective-local_postgres_data" in commands, name
assert "volume create evening-detective-local_rustfs_data" in commands, name
assert "compose up -d --no-build --pull never" in commands, name
assert "compose --profile setup run --rm bootstrap" in commands, name
assert ("make -C .. build" in commands) == (cold or update), name
assert ("compose pull" in commands) == (cold or update), name
assert ("compose build --pull=false" in commands) == (cold or update), name
if cold:
config = dict(line.split("=", 1) for line in (venue / ".env").read_text().splitlines())
assert len(config["JWT_SECRET"]) == 64
assert len(config["VENUE_USER_PASSWORD"]) == 16
assert (venue / ".env").stat().st_mode & 0o777 == 0o600
else:
assert (venue / ".env").read_text() == "EXISTING_SECRET=unchanged\n", name
print("PASS: automatic first setup, offline reuse, explicit update and preserved secrets")
subprocess.run([sys.executable, str(Path(__file__).with_name("https.py"))], check=True)
+239
View File
@@ -0,0 +1,239 @@
"""Check HTTPS preflight; --docker also exercises Nginx in isolated containers."""
from pathlib import Path
import http.client
import json
import os
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
import time
import uuid
workspace = Path(__file__).resolve().parents[2]
ui = "evening-detective.crabs-games.art"
api = "evening-detective-api.crabs-games.art"
def run(*args, **kwargs):
return subprocess.run(args, check=True, capture_output=True, text=True, **kwargs).stdout.strip()
with tempfile.TemporaryDirectory(prefix="venue-https-") as directory:
root = Path(directory)
venue = root / "venue"
venue.mkdir()
shutil.copy(workspace / "venue/deploy.sh", venue / "deploy.sh")
certificates = venue / "certificates"
certificates.mkdir()
for artifact in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html"]:
path = venue / artifact
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
(venue / "migrations").mkdir()
(venue / "migrations/001.sql").touch()
original_env = "EXISTING_SECRET=unchanged\n"
(venue / ".env").write_text(original_env)
bin_dir = root / "bin"
bin_dir.mkdir()
log = root / "commands.log"
for command in ["docker", "make", "npm"]:
stub = bin_dir / command
stub.write_text('#!/bin/sh\necho "$(basename "$0") $*" >> "$TEST_LOG"\n'
'if [ "$1 $2" = "image inspect" ] && [ "$TEST_COLD" = 1 ]; then exit 1; fi\n')
stub.chmod(0o755)
env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], "TEST_LOG": str(log)}
run("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2",
"-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign",
"-subj", "/CN=Venue test root", "-keyout", str(root / "ca.key"), "-out", str(root / "ca.pem"))
run("openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=Venue intermediate",
"-keyout", str(root / "intermediate.key"), "-out", str(root / "intermediate.csr"))
extension = root / "extensions.cnf"
extension.write_text("basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n")
run("openssl", "x509", "-req", "-in", str(root / "intermediate.csr"), "-CA", str(root / "ca.pem"),
"-CAkey", str(root / "ca.key"), "-CAcreateserial", "-days", "2", "-extfile", str(extension),
"-out", str(root / "intermediate.pem"))
run("openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=Venue test server",
"-keyout", str(certificates / "privkey.pem"), "-out", str(root / "leaf.csr"))
def issue(domains, days="1"):
extension.write_text("basicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\nsubjectAltName=" +
",".join("DNS:" + domain for domain in domains) + "\n")
if days == "-1":
(root / "index").touch()
(root / "serial").write_text("01\n")
config = root / "ca.cnf"
config.write_text(f"[ca]\ndefault_ca=issuer\n[issuer]\ndatabase={root}/index\n"
f"serial={root}/serial\nnew_certs_dir={root}\ncertificate={root}/intermediate.pem\n"
f"private_key={root}/intermediate.key\ndefault_md=sha256\npolicy=policy\n"
"x509_extensions=server\n[policy]\ncommonName=supplied\n[server]\n" + extension.read_text())
run("openssl", "ca", "-batch", "-notext", "-config", str(config), "-in", str(root / "leaf.csr"),
"-startdate", "20200101000000Z", "-enddate", "20200102000000Z", "-out", str(root / "leaf.pem"))
else:
run("openssl", "x509", "-req", "-in", str(root / "leaf.csr"), "-CA", str(root / "intermediate.pem"),
"-CAkey", str(root / "intermediate.key"), "-CAcreateserial", "-days", days, "-extfile", str(extension),
"-out", str(root / "leaf.pem"))
(certificates / "fullchain.pem").write_text((root / "leaf.pem").read_text() + (root / "intermediate.pem").read_text())
def check(success, trust=True, cold=False, first=False):
log.unlink(missing_ok=True)
test_env = {**env, "SSL_CERT_FILE": str(root / "ca.pem" if trust else root / "untrusted.pem"),
"TEST_COLD": str(int(cold))}
result = subprocess.run(["bash", str(venue / "deploy.sh")], env=test_env,
capture_output=True, text=True)
assert (result.returncode == 0) == success, result.stderr
if first:
config = dict(line.split("=", 1) for line in (venue / ".env").read_text().splitlines())
assert len(config["JWT_SECRET"]) == 64 and len(config["VENUE_USER_PASSWORD"]) == 16
assert config["APP_BASE_URL"] == "https://" + ui
assert (venue / ".env").stat().st_mode & 0o777 == 0o600
else:
assert (venue / ".env").read_text() == original_env
if success:
commands = log.read_text()
prefix = "compose -f docker-compose.yml -f docker-compose.https.yml"
assert prefix + " up -d --force-recreate --no-build --pull never" in commands
assert prefix + " --profile setup run --rm bootstrap" in commands
assert prefix + " exec -T proxy nginx -t" in commands
assert prefix + " exec -T proxy nginx -s reload" in commands
assert prefix + " build --pull=false" in commands
assert commands.index(prefix + " build --pull=false") < commands.index(prefix + " up -d")
assert (prefix + " pull " in commands) == cold
assert ("docker pull alpine:3.22" in commands) == cold
assert "make " not in commands and "npm " not in commands
else:
assert "HTTPS:" in result.stderr
assert not log.exists(), "Invalid certificate reached Docker"
check(False) # Missing chain.
issue([ui, api])
check(True)
check(True) # Offline reuse, unchanged settings.
check(False, trust=False)
chain = (certificates / "fullchain.pem").read_text()
(certificates / "fullchain.pem").write_text((root / "leaf.pem").read_text())
check(False) # Missing intermediate.
(certificates / "fullchain.pem").write_text("invalid certificate")
check(False)
(certificates / "fullchain.pem").write_text(chain)
key = (certificates / "privkey.pem").read_text()
(certificates / "privkey.pem").write_text((root / "ca.key").read_text())
check(False) # Different valid private key.
(certificates / "privkey.pem").write_text("invalid key")
check(False)
(certificates / "privkey.pem").unlink()
check(False)
(certificates / "privkey.pem").write_text(key)
run("openssl", "pkey", "-in", str(certificates / "privkey.pem"), "-aes-256-cbc",
"-passout", "pass:test-only", "-out", str(root / "encrypted.pem"))
(certificates / "privkey.pem").write_text((root / "encrypted.pem").read_text())
check(False)
(certificates / "privkey.pem").write_text(key)
issue([ui])
check(False) # API domain missing.
issue([api])
check(False) # UI domain missing.
issue([ui, api], "-1")
check(False) # Expired leaf.
issue([ui, api])
(venue / ".env").unlink()
check(True, cold=True, first=True)
(venue / ".env").write_text(original_env)
for missing in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html", "migrations/001.sql"]:
path = venue / missing
path.unlink()
log.unlink(missing_ok=True)
result = subprocess.run(["bash", str(venue / "deploy.sh")],
env={**env, "SSL_CERT_FILE": str(root / "ca.pem")}, capture_output=True, text=True)
assert result.returncode != 0 and "make prod-build" in result.stderr, missing
assert not log.exists(), "Missing artifacts reached Docker"
assert (venue / ".env").read_text() == original_env
path.touch()
print("PASS: HTTPS certificates, first setup, offline Docker rebuild, forced restart, missing artifacts and preserved settings")
if "--docker" in sys.argv:
compose = ["docker", "compose", "--env-file", "/dev/null", "-f", str(workspace / "venue/docker-compose.yml")]
compose_env = {**os.environ, **dict.fromkeys([
"POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB", "JWT_SECRET", "PARTICIPATION_SECRET",
"S3_USER", "S3_PASSWORD", "VENUE_USER_PASSWORD"], "test-only"),
"APP_BASE_URL": "http://localhost:8088", "VENUE_BIND_IP": "127.0.0.1", "VENUE_PORT": "8088"}
http_config = json.loads(run(*compose, "config", "--format", "json", env=compose_env))
https = json.loads(run(*compose, "-f", str(workspace / "venue/docker-compose.https.yml"),
"config", "--format", "json", env=compose_env))
assert http_config["services"]["evening_detective_server"]["environment"]["APP_BASE_URL"] == "http://localhost:8088"
server = https["services"]["evening_detective_server"]["environment"]
assert server["APP_MODE"] == "venue" and server["APP_BASE_URL"] == "https://" + ui
assert server["FILE_PREFIX_DOMAIN"] == server["APP_BASE_URL"] + "/api/files/"
assert {str(p["published"]) for p in https["services"]["proxy"]["ports"]} == {"80", "443", "8088"}
configs = [v for v in https["services"]["proxy"]["volumes"] if v["target"] == "/etc/nginx/conf.d/default.conf"]
assert len(configs) == 1 and configs[0]["source"].endswith("/nginx.https.conf") and configs[0]["read_only"]
assert any(v["target"] == "/etc/nginx/certificates" and v["read_only"] for v in https["services"]["proxy"]["volumes"])
assert https["networks"]["default"]["internal"] and https["volumes"] == http_config["volumes"]
print("PASS: Compose merge, HTTPS addresses, ports, read-only mounts and preserved data volumes")
suffix = uuid.uuid4().hex[:10]
network = "venue-https-test-" + suffix
upstream = network + "-upstream"
proxy = network + "-proxy"
upstream_config = root / "upstream.conf"
upstream_config.write_text('server { listen 8090; return 200 "api:$request_uri"; }\n'
'server { listen 8080; return 200 "spa:$request_uri"; }\n')
# Test-only CA is explicit; public HTTPS verification is never disabled.
context = ssl.create_default_context(cafile=str(root / "ca.pem"))
def request(domain, path, tls=True):
port = int(run("docker", "port", proxy, "443/tcp" if tls else "80/tcp").split(":")[-1])
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
if tls:
connection.sock = context.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=10),
server_hostname=domain)
try:
connection.request("GET", path, headers={"Host": domain})
response = connection.getresponse()
return response.status, response.getheader("Location"), response.read().decode()
finally:
connection.close()
def serial():
port = int(run("docker", "port", proxy, "443/tcp").split(":")[-1])
with context.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=10), server_hostname=ui) as connection:
return connection.getpeercert()["serialNumber"]
try:
run("docker", "network", "create", network)
run("docker", "run", "-d", "--name", upstream, "--network", network,
"--network-alias", "evening_detective_server", "--network-alias", "evening_detective_frontend",
"-v", str(upstream_config) + ":/etc/nginx/conf.d/default.conf:ro", "nginx:alpine")
run("docker", "run", "-d", "--name", proxy, "--network", network,
"-p", "127.0.0.1::80", "-p", "127.0.0.1::443",
"-v", str(workspace / "venue/nginx.https.conf") + ":/etc/nginx/conf.d/default.conf:ro",
"-v", str(certificates) + ":/etc/nginx/certificates:ro", "nginx:alpine")
run("docker", "exec", proxy, "nginx", "-t")
for domain in [ui, api]:
assert request(domain, "/api/test/ping?value=1", tls=False)[:2] == (308, "https://" + domain + "/api/test/ping?value=1")
assert request(domain, "/api/test/ping?value=1") == (200, None, "api:/api/test/ping?value=1")
assert request(domain, "/api/files/material.pdf") == (200, None, "api:/api/files/material.pdf")
assert request(ui, "/team-story/1?password=test") == (200, None, "spa:/team-story/1?password=test")
assert request(api, "/docs/") == (200, None, "api:/docs/")
run("docker", "restart", proxy)
run("docker", "exec", proxy, "nginx", "-t")
assert request(ui, "/games") == (200, None, "spa:/games")
previous_serial = serial()
issue([ui, api])
run("docker", "exec", proxy, "nginx", "-t")
run("docker", "exec", proxy, "nginx", "-s", "reload")
for attempt in range(40):
if serial() != previous_serial:
break
time.sleep(0.05)
else:
raise AssertionError("Nginx did not reload renewed certificate")
assert request(ui, "/games") == (200, None, "spa:/games")
print("PASS: real Nginx TLS/SNI, both domains, redirects, API, materials, SPA paths, restart and certificate renewal")
finally:
for container in [proxy, upstream]:
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
subprocess.run(["docker", "network", "rm", network], capture_output=True)
+3 -4
View File
@@ -20,10 +20,7 @@ config = dict(
for line in (root / ".env").read_text().splitlines()
if line and not line.startswith("#")
)
bind = config.get("VENUE_BIND_IP", "127.0.0.1")
if bind == "0.0.0.0":
bind = "127.0.0.1"
base = f'http://{bind}:{config.get("VENUE_PORT", "8088")}'
base = "https://evening-detective.crabs-games.art"
token = ""
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
@@ -57,6 +54,8 @@ def verify(state):
try:
with opener.open("https://evening-detective-api.crabs-games.art/api/test/ping", timeout=10) as response:
assert response.status == 200, "HTTPS API domain failed"
token = request(
"/api/auth/login",
{