up
This commit is contained in:
+12
-2
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user