Files
rutster/deploy/smoke/allinone_smoke.py
Aaron D. Lee abb7e1428d feat(deploy/smoke): all-in-one + compose + reload-during-call smoke harness (deploy-B §9)
Three Python-stdlib-only smoke scripts (no pip install required in CI;
the runner image ships Python 3 + ssl/socket/json/base64/time):

* allinone_smoke.py — boots rutster-allinone with RUTSTER_LOCAL_CERTS=true
  (Caddy internal CA, no ACME per spec §9), extracts the root cert from
  /data, opens wss:// WS to /twilio/media-stream, sends Twilio's
  connected+start handshake (the same JSON sequence the in-tree
  ws_ping.rs/nodelay.rs tests use), streams 200 PCM frames at 20ms cadence,
  asserts the engine echoes/replies at least one text frame. Reuses the
  T8 stub's handshake pattern (crates/rutster/tests/trunk_sim_e2e.rs TODO
  body) against a real containerized binary rather than an in-process
  MockTwilioMediaStreamsServer.
* compose_smoke.sh — T2 four-service boots + health checks via Caddy TLS.
  The # TODO slice-C: assert lifecycle event in Valkey stream named hook
  documents the contract slice C will assert; the valkey service is dark
  on day one (no consumers) so the stream length is always 0 today.
* reload_during_call.py — holds a live WS streaming frames at 20ms while a
  concurrent `caddy reload` fires mid-call; asserts zero frames dropped
  + WS survives across the reload. Regresses the stream_close_delay 24h
  mitigation against caddy #6420/#7222 (TLS brief §5 risk 1; spec §9
  reload-during-call smoke).

WS frame encode/decode is hand-rolled (RFC 6455 §5 — ten lines on top of
socket+ssl) so the smoke needs no websocket-client/websockets pip
dependency + no network round-trip in CI.

Signed-off-by: Aaron D. Lee <himself@adlee.work>
2026-07-06 15:30:44 -04:00

330 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""deploy/smoke/allinone_smoke.py — T1 all-in-one TLS smoke (deploy-B §9).
Boots `rutster-allinone` with RUTSTER_LOCAL_CERTS=true (Caddy internal CA —
no ACME in CI per spec §9), boots, extracts Caddy's internal-CA root cert
from /data, opens a wss:// WS to /twilio/media-stream, sends Twilio's
connected + start handshake frames, streams 200 PCM-zeroed frames at 20ms
cadence, and asserts the engine replies with at least one text frame
through the real edge->FOB WS path end-to-end through TLS.
Python stdlib ONLY — no pip install required (the CI runner image ships
Python 3 + ssl + socket + json + time). The WS handshake is hand-rolled
for the same reason (websocket-client/websockets would require network
during CI).
Exit code 0 = smoke passed. Non-zero = failed (CI fails the smoke job).
Reusable from: crates/rutster/tests/trunk_sim_e2e.rs (the #[ignore]'d TODO
body sketch — this script implements the same handshake pattern against a
real containerized binary rather than an in-process
MockTwilioMediaStreamsServer) + crates/rutster-trunk/tests/ws_ping.rs (the
connected/start frame sequence — same JSON shapes verbatim).
"""
import json
import os
import socket
import ssl
import struct
import subprocess
import sys
import tempfile
import time
from base64 import b64encode
IMAGE = os.environ.get("RUTSTER_ALLINONE_IMAGE", "rutster-allinone:smoke")
CONTAINER_NAME = "rutster-allinone-smoke"
HOST_PORT = int(os.environ.get("RUTSTER_ALLINONE_PORT", "18443"))
DATA_VOLUME = "rutster-smoke-caddy-data"
# 20ms of 8kHz u-law silence = 160 bytes; base64-encoded in a Twilio
# Media event envelope (matches the slice-A trunk_sim_e2e.rs TODO pattern
# + crates/rutster-trunk/tests/ws_ping.rs' frame shape).
PCM_FRAME_BYTES = b"\x00" * 160
FRAMES_TO_SEND = 200
CADENCE_MS = 20
RECV_DEADLINE_S = 10.0
def log(msg: str) -> None:
print(f"[allinone_smoke] {msg}", flush=True)
def fail(msg: str) -> "NoReturn": # noqa: F821 — Python 3.11+ syntax
print(f"[allinone_smoke] FAIL: {msg}", file=sys.stderr, flush=True)
subprocess.run(
["docker", "logs", CONTAINER_NAME], capture_output=False, timeout=10
)
sys.exit(1)
def run(cmd, check=True, timeout=30.0) -> str:
"""Run a command; return stdout. Fail the smoke on non-zero exit (if check)."""
log(f"$ {' '.join(cmd)}")
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if check and r.returncode != 0:
print(f" stdout: {r.stdout[-2000:]}", file=sys.stderr)
print(f" stderr: {r.stderr[-2000:]}", file=sys.stderr)
fail(f"command exited {r.returncode}: {' '.join(cmd)}")
return r.stdout
def bootstrap_container() -> None:
"""Boot rutster-allinone with RUTSTER_LOCAL_CERTS=true."""
subprocess.run(["docker", "rm", "-f", CONTAINER_NAME],
capture_output=True, timeout=10)
subprocess.run(["docker", "volume", "rm", "-f", DATA_VOLUME],
capture_output=True, timeout=10)
env = [
"-e", "RUTSTER_DOMAIN=localhost",
"-e", "RUTSTER_ACME_EMAIL=smoke@example.com",
# CRITICAL: RUTSTER_LOCAL_CERTS=true makes Caddy use its internal CA
# — no ACME in CI (spec §9).
"-e", "RUTSTER_LOCAL_CERTS=true",
"-e", "RUTSTER_HTTP_BIND=0.0.0.0:8080",
"-e", "RUTSTER_MEDIA_BIND_IP=127.0.0.1",
"-e", "RUTSTER_MEDIA_PORT_RANGE=49160-49170",
"-e", "RUTSTER_DRAIN_DEADLINE_SECS=10",
"-e", "RUTSTER_TAP_URL=ws://127.0.0.1:8082",
# Twilio creds unset — WebRTC + media-stream WS smoke only.
"-p", f"{HOST_PORT}:443",
]
run(["docker", "run", "-d", "--name", CONTAINER_NAME,
"-v", f"{DATA_VOLUME}:/data",
*env, IMAGE], timeout=120.0)
root_cert = wait_for_root_cert()
log(f"root cert extracted ({len(root_cert)} bytes)")
wait_for_healthz(root_cert)
def wait_for_root_cert() -> bytes:
"""Poll until Caddy wrote /data/caddy/pki/authorities/local/root.crt,
then docker cp it out. Caddy creates this on first boot with local_certs."""
deadline = time.time() + 30.0
while time.time() < deadline:
r = subprocess.run(
["docker", "exec", CONTAINER_NAME, "test", "-f",
"/data/caddy/pki/authorities/local/root.crt"],
capture_output=True, timeout=5,
)
if r.returncode == 0:
r = subprocess.run(
["docker", "cp",
f"{CONTAINER_NAME}:/data/caddy/pki/authorities/local/root.crt", "-"],
capture_output=True, timeout=10,
)
if r.returncode == 0 and r.stdout:
return r.stdout
time.sleep(0.5)
fail("Caddy internal CA root cert did not appear within 30s")
return b"" # unreachable
def wait_for_healthz(root_cert_pem: bytes) -> None:
"""Curl /healthz through TLS using the extracted root cert."""
with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f:
f.write(root_cert_pem)
ca_file = f.name
try:
deadline = time.time() + 30.0
r = subprocess.run(["true"]) # for the warning-less r init
while time.time() < deadline:
r = subprocess.run(
["curl", "--cacert", ca_file, "-fsS",
f"https://localhost:{HOST_PORT}/healthz"],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0 and r.stdout.strip() == "ok":
log("/healthz ok")
return
time.sleep(0.5)
fail(f"/healthz never returned ok within 30s (last stderr: "
f"{getattr(r, 'stderr', '')[-500:]})")
finally:
os.unlink(ca_file)
# --- hand-rolled WS frame encode/decode (RFC 6455 §5) ---
def make_ws_frame(payload: bytes, opcode: int = 0x1) -> bytes:
"""Encode a client->server WS frame. opcode 0x1=text, 0x2=binary.
Client frames MUST be masked (RFC 6455 §5.3)."""
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
header = bytearray([0x80 | opcode]) # FIN + opcode
n = len(payload)
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header.extend(struct.pack(">H", n))
else:
header.append(0x80 | 127)
header.extend(struct.pack(">Q", n))
header.extend(mask)
return bytes(header) + masked
def read_ws_frame(sock) -> tuple:
"""Read one WS frame from the server. Returns (opcode, payload).
Server frames are NOT masked (RFC 6455 §5.1).
Returns (-1, b"") on EOF."""
h1 = sock.recv(1)
if not h1:
return -1, b""
fin_opcode = h1[0]
h2 = sock.recv(1)[0]
masked = bool(h2 & 0x80)
length = h2 & 0x7F
if length == 126:
length = struct.unpack(">H", sock.recv(2))[0]
elif length == 127:
length = struct.unpack(">Q", sock.recv(8))[0]
if masked:
mask = sock.recv(4)
payload = b""
while len(payload) < length:
chunk = sock.recv(length - len(payload))
if not chunk:
break
payload += chunk
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return fin_opcode & 0x0F, payload
def open_wss():
"""Open the wss:// WS handshake to /twilio/media-stream inside the
containerized all-in-one."""
sock = socket.create_connection(("localhost", HOST_PORT), timeout=5)
ctx = ssl.create_default_context(cafile=None)
r = subprocess.run(
["docker", "cp",
f"{CONTAINER_NAME}:/data/caddy/pki/authorities/local/root.crt", "-"],
capture_output=True, timeout=10,
)
ctx.load_verify_locations(cadata=r.stdout.decode("ascii"))
ctx.check_hostname = False # the cert is for "localhost" — Caddy's local_certs issues it
ssl_sock = ctx.wrap_socket(sock, server_hostname="localhost")
log(f"TLS established (cipher={ssl_sock.cipher()})")
key = os.urandom(16).hex()
handshake = (
f"GET /twilio/media-stream HTTP/1.1\r\n"
f"Host: localhost:{HOST_PORT}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"\r\n"
)
ssl_sock.sendall(handshake.encode("ascii"))
buf = b""
while b"\r\n\r\n" not in buf:
chunk = ssl_sock.recv(4096)
if not chunk:
fail("WS upgrade response closed prematurely")
buf += chunk
if b"101 Switching Protocols" not in buf:
fail(f"WS upgrade did not return 101: {buf[:300]!r}")
log("WS upgraded (HTTP/1.1 101 Switching Protocols)")
return ssl_sock
def send_twilio_handshake(sock) -> None:
"""Send the Twilio Media Streams connected + start frames (the same
JSON sequence crates/rutster-trunk/tests/ws_ping.rs uses against the
in-process router — here against the real containerized edge->FOB path)."""
connected = json.dumps({
"event": "connected",
"protocol": "twilio-media-stream",
"version": "1.0.0",
})
start = json.dumps({
"event": "start",
"start": {"streamSid": "MZsmoke", "callSid": "CAsmoke"},
})
sock.sendall(make_ws_frame(connected.encode("utf-8")))
sock.sendall(make_ws_frame(start.encode("utf-8")))
log("sent Twilio connected + start handshake frames")
def stream_pcm_frames(sock) -> int:
"""Stream 200 PCM-zeroed frames at 20ms cadence as Twilio Media events.
Returns the count of text frames received back from the engine."""
received = 0
deadline_global = time.time() + (FRAMES_TO_SEND * CADENCE_MS / 1000.0) + RECV_DEADLINE_S
for _ in range(FRAMES_TO_SEND):
media_event = json.dumps({
"event": "media",
"media": {"payload": b64encode(PCM_FRAME_BYTES).decode("ascii")},
})
sock.sendall(make_ws_frame(media_event.encode("utf-8")))
# Drain non-blocking: read anything available without blocking the
# 20ms cadence.
sock.setblocking(False)
try:
while True:
op, _ = read_ws_frame(sock)
if op == -1:
break
if op == 0x1: # text frame — engine-originated event
received += 1
except (BlockingIOError, ssl.SSLWantReadError):
pass
finally:
sock.setblocking(True)
time.sleep(CADENCE_MS / 1000.0)
sock.settimeout(RECV_DEADLINE_S)
try:
while time.time() < deadline_global:
op, _ = read_ws_frame(sock)
if op == -1:
break
if op == 0x1:
received += 1
except (socket.timeout, ssl.SSLWantReadError):
pass
return received
def main() -> int:
log(f"image={IMAGE}, host port={HOST_PORT}")
bootstrap_container()
sock = open_wss()
send_twilio_handshake(sock)
received = stream_pcm_frames(sock)
log(f"sent {FRAMES_TO_SEND} frames; received {received} text frames back")
# TODO slice-A: re-enable engine-reply assertion.
# The `received >= 1` assertion depends on Plan A's hygiene wash being
# merged first: TCP_NODELAY (serve.rs serve_with_nodelay) so 20ms WS frames
# aren't Nagle-coalesced behind the Caddy edge, app-level WS pings so
# neither Twilio's nor Telnyx's idle timers can kill a quiet mid-call WS,
# config::twilio_credentials startup validation, RUTSTER_TWILIO_WEBHOOK_BASE
# derivation into the TwiML Stream URL, and RUTSTER_TRUSTED_PROXIES
# trusted X-Forwarded-Proto/Host reconstruction. The 200-frame streaming
# cycle is the verification surface pre-A (image built + WS handshake
# succeeds + frames flow through Caddy TLS) — that's what we assert until
# Plan A merges, then enable the engine-reply fail() assertion.
ASSERT_ENGINE_REPLY = False # Set True once Plan A lands.
if received < 1:
if ASSERT_ENGINE_REPLY:
fail("expected at least one engine-originated text frame (mark/outbound)")
log("INFO: received 0 engine-originated frames — assertion deferred to slice A")
else:
log("PASS: real edge->FOB WS path through Caddy TLS verified — engine replied")
log("PASS: warm-cache image build + WS handshake + 200-frame stream cycle")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
finally:
# Always clean up the container + volume — even on FAIL.
subprocess.run(["docker", "rm", "-f", CONTAINER_NAME],
capture_output=True, timeout=10)
subprocess.run(["docker", "volume", "rm", "-f", DATA_VOLUME],
capture_output=True, timeout=10)