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:
2026-05-29 19:20:56 +01:00
parent 265eae5365
commit 6841048aae
12 changed files with 624 additions and 0 deletions

50
tests/mumble/PARITY.md Normal file
View 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.

View 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

View 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')}"

View 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}"
)

View 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}")

View 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"

View 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
View 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})"

View 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),
}

View 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"
)

View 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}"
)

View 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)"
)