137 lines
6.0 KiB
Python
137 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise the built frontend against a backend that disappears and changes IP.
|
|
|
|
Uses an isolated Podman network and disposable containers, never the node stack.
|
|
Usage: python3 scripts/test-mempool-dns-recovery.py [frontend-image]
|
|
"""
|
|
import ipaddress
|
|
import json
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
|
|
IMAGE = sys.argv[1] if len(sys.argv) > 1 else (
|
|
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
|
|
)
|
|
BACKEND = "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
|
|
prefix = "mempool-dns-test-" + uuid.uuid4().hex[:8]
|
|
network, frontend, backend = prefix, prefix + "-web", prefix + "-api"
|
|
|
|
|
|
def podman(*args, check=True):
|
|
return subprocess.run(["podman", *args], capture_output=True, text=True,
|
|
check=check, timeout=60).stdout.strip()
|
|
|
|
|
|
def eventually(check, timeout=25):
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
try:
|
|
return check()
|
|
except (AssertionError, OSError, urllib.error.URLError):
|
|
if time.monotonic() >= deadline:
|
|
raise
|
|
time.sleep(1)
|
|
|
|
|
|
server = r"""
|
|
const http = require('http'), crypto = require('crypto');
|
|
const server = http.createServer((req, res) => {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({url: req.url, instance: process.env.INSTANCE}));
|
|
});
|
|
server.on('upgrade', (req, socket) => {
|
|
const key = crypto.createHash('sha1')
|
|
.update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
.digest('base64');
|
|
socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n' +
|
|
'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + key + '\r\n' +
|
|
'X-Upstream-Url: ' + req.url + '\r\nX-Instance: ' + process.env.INSTANCE + '\r\n\r\n');
|
|
});
|
|
server.listen(8999, '0.0.0.0');
|
|
"""
|
|
|
|
try:
|
|
podman("network", "create", network)
|
|
subnet = ipaddress.ip_network(json.loads(podman("network", "inspect", network))[0]["subnets"][0]["subnet"])
|
|
podman("run", "-d", "--name", frontend, "--network", network,
|
|
"-p", "127.0.0.1::8080", "-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api",
|
|
"-e", "FRONTEND_HTTP_PORT=8080", IMAGE)
|
|
port = int(podman("port", frontend, "8080/tcp").rsplit(":", 1)[1])
|
|
url = f"http://127.0.0.1:{port}"
|
|
|
|
def static_ready():
|
|
assert urllib.request.urlopen(url, timeout=4).status == 200
|
|
|
|
eventually(static_ready)
|
|
started = podman("inspect", frontend, "--format", "{{.State.StartedAt}}")
|
|
try:
|
|
urllib.request.urlopen(url + "/api/v1/backend-info", timeout=6)
|
|
raise AssertionError("An absent backend must not appear healthy")
|
|
except urllib.error.HTTPError as error:
|
|
assert error.code == 502
|
|
print("PASS: frontend starts while backend DNS is absent", flush=True)
|
|
|
|
for instance, offset in [("first", 10), ("replacement", 11)]:
|
|
if instance == "replacement":
|
|
podman("rm", "-f", backend)
|
|
# Ensure the cached address has expired while the backend is absent.
|
|
time.sleep(6)
|
|
podman("run", "-d", "--name", backend, "--network", network,
|
|
"--network-alias", "mempool-api", "--ip", str(subnet[offset]),
|
|
"-e", "INSTANCE=" + instance, "--entrypoint", "node", BACKEND,
|
|
"-e", server)
|
|
|
|
for path, expected in [
|
|
("/api/blocks/tip/height?probe=one", "/api/v1/blocks/tip/height?probe=one"),
|
|
("/api/v1/fees/recommended?probe=two", "/api/v1/fees/recommended?probe=two"),
|
|
]:
|
|
def check_http():
|
|
with urllib.request.urlopen(url + path, timeout=4) as response:
|
|
result = json.load(response)
|
|
assert result == {"url": expected, "instance": instance}, result
|
|
eventually(check_http)
|
|
|
|
for path in ["/api/v1/ws?probe=ws", "/ws?probe=ws"]:
|
|
def check_ws():
|
|
with socket.create_connection(("127.0.0.1", port), timeout=4) as sock:
|
|
sock.sendall((f"GET {path} HTTP/1.1\r\nHost: localhost\r\n"
|
|
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
"Sec-WebSocket-Version: 13\r\n"
|
|
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n").encode())
|
|
response = b""
|
|
while b"\r\n\r\n" not in response:
|
|
part = sock.recv(4096)
|
|
assert part, response
|
|
response += part
|
|
assert b"101 Switching Protocols" in response, response
|
|
assert b"X-Upstream-Url: /?probe=ws" in response, response
|
|
assert ("X-Instance: " + instance).encode() in response, response
|
|
eventually(check_ws)
|
|
assert podman("inspect", frontend, "--format", "{{.State.StartedAt}}") == started
|
|
print(f"PASS: {instance} backend at {subnet[offset]}: HTTP paths, query strings, both WebSocket routes; frontend never restarted", flush=True)
|
|
|
|
before = podman("exec", frontend, "cat", "/etc/nginx/conf.d/nginx-mempool.conf")
|
|
podman("exec", frontend, "/patch/repair-nginx.sh")
|
|
assert podman("exec", frontend, "cat", "/etc/nginx/conf.d/nginx-mempool.conf") == before
|
|
podman("exec", frontend, "nginx", "-t")
|
|
print("PASS: repeated repair is idempotent and nginx configuration is valid", flush=True)
|
|
podman("restart", frontend)
|
|
eventually(static_ready)
|
|
|
|
def after_restart():
|
|
with urllib.request.urlopen(url + "/api/blocks/tip/height?restart=1", timeout=4) as response:
|
|
assert json.load(response) == {
|
|
"url": "/api/v1/blocks/tip/height?restart=1", "instance": "replacement"
|
|
}
|
|
eventually(after_restart)
|
|
eventually(check_ws)
|
|
print("PASS: frontend restart preserves DNS recovery and HTTP/WebSocket routing", flush=True)
|
|
finally:
|
|
podman("rm", "-f", frontend, backend, check=False)
|
|
podman("network", "rm", network, check=False)
|