feat(2): Q4.2 mumble — parity port (health/protocol-handshake/web) + 2 specific + P4 sqlite
- functional/_mumble_proto.py: stdlib Mumble TLS protocol client (adapted from corpus mumble_connect.py) - 3 parity ports: test_tcp_health, test_protocol_handshake (channel presence+ServerSync), test_web_client - 2 NEW recipe-specific (P3): welcome-text + max-users config round-trips over the protocol - P4: ops.py + test_backup/test_restore seed ci_marker in /data/mumble-server.sqlite (recipe's own backupbot DB), busy_timeout for live-server locks - test_install overlay: voice server listening on 64738 (beyond web-sidecar readiness) - recipe_meta: COMPOSE_FILE=compose.yml:mumbleweb:host-ports; WELCOME_TEXT/USERS markers - PARITY.md mapping table Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
50
tests/mumble/PARITY.md
Normal file
50
tests/mumble/PARITY.md
Normal file
@ -0,0 +1,50 @@
|
||||
# mumble — recipe-maintainer → cc-ci parity (Phase 2 P2)
|
||||
|
||||
Source corpus: `references/recipe-maintainer/recipe-info/mumble/tests/` (`/srv/recipe-maintainer/
|
||||
recipe-info/mumble/`). mumble is a TLS **voice** server (port 64738), not an HTTP app, plus an
|
||||
optional `mumble-web` HTTP client overlay. cc-ci deploys it with
|
||||
`COMPOSE_FILE=compose.yml:compose.mumbleweb.yml:compose.host-ports.yml` (recipe_meta.EXTRA_ENV):
|
||||
the web overlay gives the generic harness an HTTP readiness/serving signal; the host-ports overlay
|
||||
publishes 64738 on the cc-ci host so the on-host (cc-ci-run) protocol tests connect to
|
||||
127.0.0.1:64738. Both overlays are shipped by the upstream recipe (documented deployment mode).
|
||||
|
||||
## Parity port mapping (P2 — every recipe-maintainer test has a comparable cc-ci test)
|
||||
|
||||
| recipe-maintainer test (`recipe-info/mumble/tests/`) | what it verifies | cc-ci test | same thing? |
|
||||
|---|---|---|---|
|
||||
| `health_check.py` | mumble server listening on TCP 64738 | `functional/test_tcp_health.py` | yes — TCP connect to 64738 (host-published) |
|
||||
| `mumble_connect.py` | full TLS protocol handshake: TLS connect, server Version, auth accepted (no Reject), channel list present, ServerSync handshake completes, welcome text | `functional/test_protocol_handshake.py` (+ `functional/_mumble_proto.py`, adapted from the corpus's stdlib protobuf/protocol code) | yes — same handshake; asserts tls_connect + version + auth_accepted + channel presence + ServerSync |
|
||||
| `web_client.py` | mumble-web client reachable over HTTPS, HTTP 200, page contains `Mumble` + `config.js`, valid HTML | `functional/test_web_client.py` | yes — same 200 + body markers (`Mumble`, `config.js`, `<!DOCTYPE html>`) |
|
||||
|
||||
No recipe-maintainer mumble test is omitted — all three are ported. No `DECISIONS.md` non-port
|
||||
entry is needed for mumble.
|
||||
|
||||
## Recipe-specific functional tests (P3 — ≥2 beyond parity)
|
||||
|
||||
mumble has no REST "create-an-object" API; its characteristic behaviour is the voice-server control
|
||||
protocol and its deploy-time server configuration. Both new tests are **config round-trips** that
|
||||
prove our deploy-time configuration propagated into the running murmur server and is enforced/
|
||||
delivered over the real protocol (version-independent — they assert OUR configured markers, not
|
||||
hard-coded upstream values):
|
||||
|
||||
1. `functional/test_welcome_text_roundtrip.py` — deploys with a unique `WELCOME_TEXT` marker
|
||||
(`recipe_meta.EXTRA_ENV` → `MUMBLE_CONFIG_WELCOMETEXT`); asserts that exact marker surfaces in the
|
||||
server's ServerSync `welcome_text` delivered to a connecting client. (create config → read back.)
|
||||
2. `functional/test_server_config_limits.py` — deploys with a distinctive non-default `USERS=42`
|
||||
(max-users cap → `MUMBLE_CONFIG_USERS`); asserts the server's ServerConfig message reports
|
||||
`max_users == 42` (and a well-formed `allow_html`), proving the recipe wires deploy-time
|
||||
server-capacity policy into the running server.
|
||||
|
||||
## Backup data-integrity (P4 — real, recipe-aware)
|
||||
|
||||
`ops.py` + `test_backup.py` + `test_restore.py`: seed a `ci_marker` row into the recipe's own state
|
||||
DB `/data/mumble-server.sqlite` (the exact file the recipe's backupbot hooks `.backup`/restore),
|
||||
back up, drop the marker (mutate), restore, and assert the row returns as `original`. Writes use
|
||||
`PRAGMA busy_timeout` to wait out the running murmur server's transient sqlite locks. The upgrade
|
||||
tier seeds `upgrade-survives` and asserts it survives the prev→PR-head chaos crossover.
|
||||
|
||||
## Browser flow (P6)
|
||||
|
||||
Not applicable as a primary UX: mumble's core UX is the native desktop client over the voice
|
||||
protocol (covered by the protocol handshake tests). The mumble-web HTTP UI is asserted via
|
||||
`test_web_client.py` (HTTP, no interactive flow to drive). No Playwright test.
|
||||
252
tests/mumble/functional/_mumble_proto.py
Normal file
252
tests/mumble/functional/_mumble_proto.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""Minimal Mumble protocol client — vendored/adapted from the recipe-maintainer corpus
|
||||
`recipe-info/mumble/tests/mumble_connect.py` (zero external deps; stdlib ssl/socket/struct only).
|
||||
|
||||
Mumble has no HTTP API for its voice server, so the canonical "is it really working" check is the
|
||||
control-channel TLS handshake: connect, send Version + Authenticate, and read messages until the
|
||||
server completes the handshake with a ServerSync. This module performs that handshake and returns a
|
||||
structured result the cc-ci functional tests (parity port + recipe-specific) assert against.
|
||||
|
||||
On cc-ci the recipe is deployed with `compose.host-ports.yml`, which publishes 64738 directly on the
|
||||
cc-ci host (`mode: host`); tests run on-host via cc-ci-run, so they connect to 127.0.0.1:64738.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import time
|
||||
|
||||
PORT = 64738
|
||||
|
||||
# Message types (Mumble.proto)
|
||||
MSG_VERSION = 0
|
||||
MSG_AUTHENTICATE = 2
|
||||
MSG_REJECT = 4
|
||||
MSG_SERVERSYNC = 5
|
||||
MSG_CHANNELSTATE = 7
|
||||
MSG_USERSTATE = 9
|
||||
MSG_SERVERCONFIG = 24
|
||||
|
||||
REJECT_TYPES = {
|
||||
0: "None", 1: "WrongVersion", 2: "InvalidUsername", 3: "WrongUserPW",
|
||||
4: "WrongServerPW", 5: "UsernameInUse", 6: "ServerFull", 7: "NoCertificate",
|
||||
8: "AuthenticatorFail",
|
||||
}
|
||||
|
||||
_HEADER = ">HI"
|
||||
_HEADER_SIZE = 6
|
||||
|
||||
|
||||
def _enc_varint(value: int) -> bytes:
|
||||
out = bytearray()
|
||||
while value > 0x7F:
|
||||
out.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
out.append(value & 0x7F)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _enc_field_varint(field: int, value: int) -> bytes:
|
||||
return _enc_varint(field << 3) + _enc_varint(value)
|
||||
|
||||
|
||||
def _enc_field_string(field: int, value: str) -> bytes:
|
||||
raw = value.encode("utf-8")
|
||||
return _enc_varint((field << 3) | 2) + _enc_varint(len(raw)) + raw
|
||||
|
||||
|
||||
def _dec_varint(data: bytes, off: int) -> tuple[int, int]:
|
||||
result = shift = 0
|
||||
while off < len(data):
|
||||
b = data[off]
|
||||
result |= (b & 0x7F) << shift
|
||||
off += 1
|
||||
if not (b & 0x80):
|
||||
break
|
||||
shift += 7
|
||||
return result, off
|
||||
|
||||
|
||||
def _dec_fields(data: bytes) -> dict:
|
||||
fields: dict = {}
|
||||
off = 0
|
||||
while off < len(data):
|
||||
tag, off = _dec_varint(data, off)
|
||||
field, wire = tag >> 3, tag & 0x07
|
||||
if wire == 0:
|
||||
value, off = _dec_varint(data, off)
|
||||
elif wire == 1:
|
||||
value = struct.unpack_from("<Q", data, off)[0]
|
||||
off += 8
|
||||
elif wire == 2:
|
||||
length, off = _dec_varint(data, off)
|
||||
raw = data[off:off + length]
|
||||
off += length
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
value = raw
|
||||
elif wire == 5:
|
||||
value = struct.unpack_from("<I", data, off)[0]
|
||||
off += 4
|
||||
else:
|
||||
raise ValueError(f"unknown wire type {wire}")
|
||||
fields[field] = value
|
||||
return fields
|
||||
|
||||
|
||||
def _send(sock, msg_type: int, payload: bytes = b"") -> None:
|
||||
sock.sendall(struct.pack(_HEADER, msg_type, len(payload)) + payload)
|
||||
|
||||
|
||||
def _recv(sock, timeout: float) -> tuple[int, bytes]:
|
||||
sock.settimeout(timeout)
|
||||
header = b""
|
||||
while len(header) < _HEADER_SIZE:
|
||||
chunk = sock.recv(_HEADER_SIZE - len(header))
|
||||
if not chunk:
|
||||
raise ConnectionError("connection closed reading header")
|
||||
header += chunk
|
||||
msg_type, length = struct.unpack(_HEADER, header)
|
||||
payload = b""
|
||||
while len(payload) < length:
|
||||
chunk = sock.recv(length - len(payload))
|
||||
if not chunk:
|
||||
raise ConnectionError("connection closed reading payload")
|
||||
payload += chunk
|
||||
return msg_type, payload
|
||||
|
||||
|
||||
def _build_version() -> bytes:
|
||||
v = (1 << 16) | (5 << 8) | 0 # pretend client 1.5.0
|
||||
return (_enc_field_varint(1, v)
|
||||
+ _enc_field_string(2, "cc-ci mumble probe 1.0")
|
||||
+ _enc_field_string(3, "Linux"))
|
||||
|
||||
|
||||
def _build_authenticate(username: str, password: str = "") -> bytes:
|
||||
payload = _enc_field_string(1, username)
|
||||
if password:
|
||||
payload += _enc_field_string(2, password)
|
||||
payload += _enc_field_varint(5, 1) # opus = true
|
||||
return payload
|
||||
|
||||
|
||||
def handshake(host: str = "127.0.0.1", port: int = PORT, username: str = "cc-ci-probe",
|
||||
password: str = "", timeout: float = 20.0) -> dict:
|
||||
"""Full Mumble control-channel handshake. Returns a result dict:
|
||||
|
||||
tls_connect (bool), server_version (dict|None), auth_accepted (bool), channels (list[str]),
|
||||
users (list[str]), server_sync (bool), welcome_text (str|None), server_config (dict),
|
||||
error (str|None).
|
||||
"""
|
||||
result = {
|
||||
"tls_connect": False, "server_version": None, "auth_accepted": False,
|
||||
"channels": [], "users": [], "server_sync": False, "welcome_text": None,
|
||||
"server_config": {}, "error": None,
|
||||
}
|
||||
raw = tls = None
|
||||
try:
|
||||
raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw.settimeout(timeout)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
tls = ctx.wrap_socket(raw, server_hostname=host)
|
||||
tls.connect((host, port))
|
||||
result["tls_connect"] = True
|
||||
|
||||
_send(tls, MSG_VERSION, _build_version())
|
||||
_send(tls, MSG_AUTHENTICATE, _build_authenticate(username, password))
|
||||
|
||||
channels: dict = {}
|
||||
users: dict = {}
|
||||
deadline = time.time() + timeout
|
||||
# Murmur usually sends ServerConfig around ServerSync; the exact order varies by version.
|
||||
# So we don't stop at ServerSync — once it arrives we keep reading for a short grace window
|
||||
# to also capture ServerConfig (used by the recipe-specific config round-trip tests), then
|
||||
# stop. If both arrive we stop immediately.
|
||||
grace_deadline = None
|
||||
while time.time() < deadline:
|
||||
if grace_deadline is not None and time.time() >= grace_deadline:
|
||||
break
|
||||
if result["server_sync"] and result["server_config"]:
|
||||
break
|
||||
remaining = deadline - time.time()
|
||||
if grace_deadline is not None:
|
||||
remaining = min(remaining, grace_deadline - time.time())
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
msg_type, payload = _recv(tls, timeout=remaining)
|
||||
except (socket.timeout, ConnectionError):
|
||||
break
|
||||
if msg_type == MSG_VERSION:
|
||||
f = _dec_fields(payload)
|
||||
v1 = f.get(1, 0)
|
||||
result["server_version"] = {
|
||||
"string": f"{(v1 >> 16) & 0xFF}.{(v1 >> 8) & 0xFF}.{v1 & 0xFF}",
|
||||
"release": f.get(2, ""), "os": f.get(3, ""),
|
||||
}
|
||||
elif msg_type == MSG_REJECT:
|
||||
f = _dec_fields(payload)
|
||||
result["error"] = (f"Rejected: {REJECT_TYPES.get(f.get(1, 0), 'Unknown')} "
|
||||
f"— {f.get(2, '')}")
|
||||
return result
|
||||
elif msg_type == MSG_CHANNELSTATE:
|
||||
f = _dec_fields(payload)
|
||||
ch_id = f.get(1, 0)
|
||||
channels[ch_id] = f.get(3, f"channel-{ch_id}")
|
||||
elif msg_type == MSG_USERSTATE:
|
||||
f = _dec_fields(payload)
|
||||
name = f.get(3, "")
|
||||
if name:
|
||||
users[f.get(1, 0)] = name
|
||||
elif msg_type == MSG_SERVERCONFIG:
|
||||
f = _dec_fields(payload)
|
||||
# ServerConfig fields: 1 max_bandwidth, 2 welcome_text, 3 allow_html,
|
||||
# 4 message_length, 5 image_message_length, 6 max_users
|
||||
result["server_config"] = {
|
||||
"max_bandwidth": f.get(1), "welcome_text": f.get(2),
|
||||
"allow_html": f.get(3), "message_length": f.get(4),
|
||||
"image_message_length": f.get(5), "max_users": f.get(6),
|
||||
}
|
||||
elif msg_type == MSG_SERVERSYNC:
|
||||
f = _dec_fields(payload)
|
||||
result["welcome_text"] = f.get(3, "")
|
||||
result["server_sync"] = True
|
||||
result["auth_accepted"] = True
|
||||
# keep reading a short grace window to catch a trailing ServerConfig
|
||||
if grace_deadline is None:
|
||||
grace_deadline = time.time() + 3.0
|
||||
|
||||
result["channels"] = list(channels.values())
|
||||
result["users"] = list(users.values())
|
||||
if not result["server_sync"] and not result["error"]:
|
||||
result["error"] = "timed out waiting for ServerSync"
|
||||
except (ConnectionError, ssl.SSLError, OSError) as e:
|
||||
result["error"] = f"{type(e).__name__}: {e}"
|
||||
finally:
|
||||
if tls is not None:
|
||||
try:
|
||||
tls.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
tls.close()
|
||||
elif raw is not None:
|
||||
raw.close()
|
||||
return result
|
||||
|
||||
|
||||
def retry_handshake(attempts: int = 12, interval: float = 5.0, **kwargs) -> dict:
|
||||
"""Retry the handshake until ServerSync completes (cold-boot readiness) or attempts exhaust.
|
||||
Returns the last result (caller asserts on it)."""
|
||||
last: dict = {}
|
||||
for _ in range(attempts):
|
||||
last = handshake(**kwargs)
|
||||
if last.get("server_sync"):
|
||||
return last
|
||||
time.sleep(interval)
|
||||
return last
|
||||
31
tests/mumble/functional/test_protocol_handshake.py
Normal file
31
tests/mumble/functional/test_protocol_handshake.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""mumble — protocol integration parity port (Phase 2 P2 + §4.3 recipe-specific depth).
|
||||
|
||||
SOURCE: recipe-info/mumble/tests/mumble_connect.py — "connects via TLS, authenticates, verifies
|
||||
server version, channel list, welcome text, and ServerSync handshake. Zero external dependencies."
|
||||
|
||||
This is mumble's characteristic "is it really working" check: the voice server has no HTTP API, so
|
||||
the meaningful liveness/behaviour proof is the control-channel handshake — a TLS connection that is
|
||||
ACCEPTED (anonymous auth, no Reject), receives the server Version, sees the root channel
|
||||
(channel presence — §4.3 "channel presence beyond TCP health"), and completes with ServerSync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import _mumble_proto # noqa: E402
|
||||
|
||||
|
||||
def test_handshake_completes_with_channel_presence(live_app):
|
||||
r = _mumble_proto.retry_handshake(attempts=12, interval=5.0)
|
||||
|
||||
assert r["tls_connect"], f"TLS connection to 127.0.0.1:64738 failed — {r.get('error')}"
|
||||
assert r["server_version"] is not None, "server did not send a Version message"
|
||||
assert r["auth_accepted"], f"authentication not accepted — {r.get('error')}"
|
||||
# Channel presence: the server must expose at least the root channel (beyond a bare TCP open).
|
||||
assert len(r["channels"]) >= 1, (
|
||||
f"server reported no channels (expected >=1 root channel) — {r!r}"
|
||||
)
|
||||
assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}"
|
||||
37
tests/mumble/functional/test_server_config_limits.py
Normal file
37
tests/mumble/functional/test_server_config_limits.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""mumble — recipe-specific functional test #2 (Phase 2 P3, beyond parity).
|
||||
|
||||
Server-limit config round-trip via the ServerConfig protocol message. The harness deploys with a
|
||||
distinctive USERS value (recipe_meta.EXTRA_ENV -> MUMBLE_CONFIG_USERS, the server's max-users cap,
|
||||
set to a non-default 42). A client that completes the handshake receives a ServerConfig message
|
||||
carrying the server's enforced limits (max_users, allow_html, message_length, ...). Asserting
|
||||
max_users == our configured value proves the recipe wires deploy-time limits into the running
|
||||
server and enforces them — a distinctive, characteristic mumble behaviour (server capacity policy),
|
||||
not health-only. Version-independent (asserts OUR configured value).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
import _mumble_proto # noqa: E402
|
||||
import recipe_meta # noqa: E402
|
||||
|
||||
|
||||
def test_configured_max_users_surfaces_in_serverconfig(live_app):
|
||||
r = _mumble_proto.retry_handshake(attempts=12, interval=5.0)
|
||||
|
||||
assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}"
|
||||
cfg = r["server_config"]
|
||||
assert cfg, f"server did not send a ServerConfig message — {r!r}"
|
||||
assert cfg.get("max_users") == recipe_meta.MAX_USERS, (
|
||||
f"ServerConfig.max_users={cfg.get('max_users')!r} does not match the configured "
|
||||
f"USERS={recipe_meta.MAX_USERS} — deploy-time server-limit config did not propagate"
|
||||
)
|
||||
# allow_html defaults true in the recipe; assert it is present/boolean to prove the field set
|
||||
# is the real ServerConfig (not an empty/garbled decode).
|
||||
assert cfg.get("allow_html") in (0, 1), (
|
||||
f"ServerConfig.allow_html unexpected: {cfg.get('allow_html')!r}"
|
||||
)
|
||||
26
tests/mumble/functional/test_tcp_health.py
Normal file
26
tests/mumble/functional/test_tcp_health.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""mumble — TCP health parity port (Phase 2 P2).
|
||||
|
||||
SOURCE: recipe-info/mumble/tests/health_check.py — "Confirms the mumble server is listening on
|
||||
port 64738 via TCP connection test." The original SSHes to the server and probes localhost:64738;
|
||||
here the cc-ci run executes on the cc-ci host (cc-ci-run) and the recipe is deployed with
|
||||
compose.host-ports.yml, so 64738 is published on the host — we probe 127.0.0.1:64738 directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
||||
def test_mumble_listening_on_64738(live_app):
|
||||
"""The mumble voice server accepts a TCP connection on 64738 (host-published)."""
|
||||
deadline = time.time() + 60
|
||||
last_err = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", 64738), timeout=10):
|
||||
return # connected — server is listening
|
||||
except OSError as e: # noqa: PERF203
|
||||
last_err = e
|
||||
time.sleep(3)
|
||||
raise AssertionError(f"mumble not listening on 127.0.0.1:64738 — last error: {last_err}")
|
||||
35
tests/mumble/functional/test_web_client.py
Normal file
35
tests/mumble/functional/test_web_client.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""mumble — web client parity port (Phase 2 P2).
|
||||
|
||||
SOURCE: recipe-info/mumble/tests/web_client.py — "Verifies the web client is reachable via HTTPS,
|
||||
returns HTTP 200, and serves the Mumble Web UI with expected page content." Requires the
|
||||
compose.mumbleweb.yml overlay (enabled via recipe_meta.EXTRA_ENV COMPOSE_FILE).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
|
||||
from harness import http as harness_http # noqa: E402
|
||||
|
||||
|
||||
def _fetch_body(url: str) -> str:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
with urllib.request.urlopen(urllib.request.Request(url), timeout=15, context=ctx) as resp:
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def test_web_client_serves_mumble_web_ui(live_app):
|
||||
url = f"https://{live_app}/"
|
||||
status, _ = harness_http.retry_http_get(url, expect_status=200, max_wait=120, interval=5)
|
||||
assert status == 200, f"GET {url} HTTP {status} (expected 200)"
|
||||
|
||||
body = _fetch_body(url)
|
||||
assert "Mumble" in body, "web client page does not contain 'Mumble' content"
|
||||
assert "config.js" in body, "web client page does not load config.js"
|
||||
assert body.strip().startswith("<!DOCTYPE html>"), "web client response is not valid HTML"
|
||||
31
tests/mumble/functional/test_welcome_text_roundtrip.py
Normal file
31
tests/mumble/functional/test_welcome_text_roundtrip.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""mumble — recipe-specific functional test #1 (Phase 2 P3, beyond parity).
|
||||
|
||||
Config round-trip: the harness deploys the recipe with a unique WELCOME_TEXT marker
|
||||
(recipe_meta.EXTRA_ENV -> MUMBLE_CONFIG_WELCOMETEXT). A client that completes the handshake
|
||||
receives the server's welcome text in the ServerSync message. This proves our deploy-time
|
||||
configuration actually propagated into the running murmur server AND is delivered to clients over
|
||||
the real protocol — not just that a port is open. Version-independent (asserts OUR marker, not a
|
||||
hard-coded upstream string).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
import _mumble_proto # noqa: E402
|
||||
import recipe_meta # noqa: E402
|
||||
|
||||
|
||||
def test_configured_welcome_text_surfaces_in_serversync(live_app):
|
||||
marker = recipe_meta.WELCOME_TEXT_MARKER
|
||||
r = _mumble_proto.retry_handshake(attempts=12, interval=5.0)
|
||||
|
||||
assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}"
|
||||
welcome = r["welcome_text"] or ""
|
||||
assert marker in welcome, (
|
||||
f"configured welcome-text marker {marker!r} not present in the server's ServerSync "
|
||||
f"welcome_text (got {welcome!r}) — deploy-time config did not propagate"
|
||||
)
|
||||
51
tests/mumble/ops.py
Normal file
51
tests/mumble/ops.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""mumble — pre-op seed hooks (Phase 1e HC3 / Phase 2 P4 backup data-integrity).
|
||||
|
||||
The orchestrator runs these BEFORE each op; the matching test_<op>.py asserts post-op (assertion
|
||||
only). mumble persists its server state (registered users/channels/ACLs/config) in the sqlite DB
|
||||
`/data/mumble-server.sqlite`, and the recipe's backupbot hooks dump/restore exactly that file
|
||||
(`sqlite3 ... ".backup backup.sqlite"` pre-hook; `mv backup.sqlite mumble-server.sqlite` restore
|
||||
post-hook). So real backup data-integrity = seed a marker row into that sqlite, back up, mutate,
|
||||
restore, and prove the seeded row survived — the same DB the recipe actually backs up.
|
||||
|
||||
The murmur server holds the DB open, so all writes use `PRAGMA busy_timeout` to wait out the
|
||||
server's transient locks rather than failing with "database is locked". The marker lives in a
|
||||
dedicated `ci_marker` table murmur never touches.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import lifecycle # noqa: E402
|
||||
|
||||
DB = "/data/mumble-server.sqlite"
|
||||
|
||||
|
||||
def _sqlite(domain, sql):
|
||||
cmd = f'sqlite3 {DB} "PRAGMA busy_timeout=20000; {sql}"'
|
||||
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="app").strip()
|
||||
|
||||
|
||||
def _seed(domain, value):
|
||||
_sqlite(
|
||||
domain,
|
||||
"CREATE TABLE IF NOT EXISTS ci_marker(v TEXT); DELETE FROM ci_marker; "
|
||||
f"INSERT INTO ci_marker VALUES('{value}');",
|
||||
)
|
||||
got = _sqlite(domain, "SELECT v FROM ci_marker;")
|
||||
assert got == value, f"seed did not commit (read back {got!r}, expected {value!r})"
|
||||
|
||||
|
||||
def pre_upgrade(domain, meta):
|
||||
_seed(domain, "upgrade-survives")
|
||||
|
||||
|
||||
def pre_backup(domain, meta):
|
||||
_seed(domain, "original")
|
||||
|
||||
|
||||
def pre_restore(domain, meta):
|
||||
# diverge from the backup so a successful restore is observable: drop the marker table.
|
||||
_sqlite(domain, "DROP TABLE IF EXISTS ci_marker;")
|
||||
got = _sqlite(domain, "SELECT name FROM sqlite_master WHERE type='table' AND name='ci_marker';")
|
||||
assert got == "", f"drop did not take (sqlite_master still lists ci_marker: {got!r})"
|
||||
31
tests/mumble/recipe_meta.py
Normal file
31
tests/mumble/recipe_meta.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Per-recipe harness config for mumble (Phase 2 Q4.2 — a TCP/voice recipe, not HTTP-native).
|
||||
#
|
||||
# Mumble's voice server speaks its own TLS protocol on 64738 (no HTTP API). To fit cc-ci's
|
||||
# HTTP-readiness + on-host test model we deploy two recipe overlays:
|
||||
# - compose.mumbleweb.yml -> a mumble-web HTTP client routed through Traefik on the app domain,
|
||||
# giving the generic harness a real HTTP readiness/serving signal (HEALTH_PATH "/") AND the
|
||||
# web_client.py parity surface.
|
||||
# - compose.host-ports.yml -> publishes 64738 (tcp+udp) directly on the cc-ci host (mode: host).
|
||||
# Tests run on-host (cc-ci-run), so the protocol tests connect to 127.0.0.1:64738.
|
||||
# Both overlays are shipped by the upstream recipe; this is a documented deployment mode, not a fork.
|
||||
#
|
||||
# Distinctive config markers (read back by the recipe-specific functional tests, proving our config
|
||||
# actually propagated into the running server — version-independent, not hard-coded upstream values):
|
||||
# WELCOME_TEXT -> MUMBLE_CONFIG_WELCOMETEXT, surfaced in the ServerSync welcome_text.
|
||||
# USERS -> MUMBLE_CONFIG_USERS (max users), surfaced in the ServerConfig.max_users.
|
||||
|
||||
HEALTH_PATH = "/" # mumble-web client UI
|
||||
HEALTH_OK = (200,)
|
||||
DEPLOY_TIMEOUT = 900 # two images to pull (mumble-server + mumble-web) on a cold node
|
||||
HTTP_TIMEOUT = 300
|
||||
|
||||
# A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol.
|
||||
WELCOME_TEXT_MARKER = "cc-ci-mumble-welcome-7f3a9c"
|
||||
# A distinctive max-users value (not the recipe default 100) the server_config test asserts.
|
||||
MAX_USERS = 42
|
||||
|
||||
EXTRA_ENV = {
|
||||
"COMPOSE_FILE": "compose.yml:compose.mumbleweb.yml:compose.host-ports.yml",
|
||||
"WELCOME_TEXT": WELCOME_TEXT_MARKER,
|
||||
"USERS": str(MAX_USERS),
|
||||
}
|
||||
26
tests/mumble/test_backup.py
Normal file
26
tests/mumble/test_backup.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""mumble — BACKUP overlay (Phase 1e HC3 / Phase 2 P4): assertion-only + additive.
|
||||
|
||||
ops.pre_backup seeded ci_marker='original' into /data/mumble-server.sqlite before the backup op
|
||||
(the recipe's backupbot pre-hook `.backup`s that exact file). The orchestrator performed the backup
|
||||
once (generic tier asserted a snapshot artifact). This overlay ADDS: the seeded row is intact at
|
||||
backup time. The backup→restore divergence (dropping the table) is in ops.pre_restore.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import lifecycle # noqa: E402
|
||||
|
||||
DB = "/data/mumble-server.sqlite"
|
||||
|
||||
|
||||
def _sqlite(domain, sql):
|
||||
cmd = f'sqlite3 {DB} "PRAGMA busy_timeout=20000; {sql}"'
|
||||
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="app").strip()
|
||||
|
||||
|
||||
def test_backup_captures_state(live_app):
|
||||
assert _sqlite(live_app, "SELECT v FROM ci_marker;") == "original", (
|
||||
"the seeded mumble sqlite marker was not present at backup time"
|
||||
)
|
||||
26
tests/mumble/test_install.py
Normal file
26
tests/mumble/test_install.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""mumble — INSTALL overlay (Phase 1e HC3): assertion-only + additive, runs alongside the generic
|
||||
install tier (which proves the mumble-web HTTP sidecar serves over Traefik — the readiness signal).
|
||||
|
||||
This overlay ADDS the assertion that mumble's actual purpose — the voice server — is up: the murmur
|
||||
control channel accepts a TLS connection on the host-published 64738 right after install. (The full
|
||||
protocol handshake + channel presence is exercised in the custom tier; here we assert the install
|
||||
produced a listening voice server, not only a web UI.)
|
||||
"""
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
||||
def test_voice_server_listening(live_app):
|
||||
deadline = time.time() + 120
|
||||
last_err = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", 64738), timeout=10):
|
||||
return
|
||||
except OSError as e: # noqa: PERF203
|
||||
last_err = e
|
||||
time.sleep(3)
|
||||
raise AssertionError(
|
||||
f"mumble voice server not listening on 127.0.0.1:64738 after install — {last_err}"
|
||||
)
|
||||
28
tests/mumble/test_restore.py
Normal file
28
tests/mumble/test_restore.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""mumble — RESTORE overlay (Phase 1e HC3 / Phase 2 P4): data-integrity, assertion-only + additive.
|
||||
|
||||
ops.pre_restore dropped the ci_marker table (diverge from the backup); the orchestrator restored
|
||||
once (generic tier asserted healthy/serving; the recipe's restore.post-hook `mv`s the backed-up
|
||||
sqlite back over /data/mumble-server.sqlite). This overlay ADDS: the restored DB carries the
|
||||
pre-mutation 'original' marker — proving the seeded data actually survived backup→restore, not just
|
||||
that the service came back up. Read via a fresh sqlite3 CLI in the app container (reads the restored
|
||||
on-disk file).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import lifecycle # noqa: E402
|
||||
|
||||
DB = "/data/mumble-server.sqlite"
|
||||
|
||||
|
||||
def _sqlite(domain, sql):
|
||||
cmd = f'sqlite3 {DB} "PRAGMA busy_timeout=20000; {sql}"'
|
||||
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="app").strip()
|
||||
|
||||
|
||||
def test_restore_returns_state(live_app):
|
||||
assert _sqlite(live_app, "SELECT v FROM ci_marker;") == "original", (
|
||||
"restore did not return the pre-mutation mumble sqlite marker (data-integrity failure)"
|
||||
)
|
||||
Reference in New Issue
Block a user