mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-11 05:06:36 +00:00
467 lines
17 KiB
Python
467 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import configparser
|
|
import hashlib
|
|
import posixpath
|
|
import re
|
|
import unittest
|
|
from typing import ClassVar
|
|
|
|
from jinja2 import Environment, StrictUndefined, select_autoescape
|
|
|
|
from utils.cache.files import PROJECT_ROOT, read_text
|
|
from utils.cache.yaml import load_yaml
|
|
from utils.roles.mapping import ROLE_FILE_VARS_MAIN
|
|
|
|
ROLE = PROJECT_ROOT / "roles" / "web-app-nextcloud"
|
|
TEMPLATES = ROLE / "templates"
|
|
FILES = ROLE / "files"
|
|
|
|
SIGNALING_SECRET = "base64:signaling-fixture-secret"
|
|
INTERNAL_SECRET = "base64:internal-fixture-secret"
|
|
RECORDING_SECRET = "recording-fixture-secret"
|
|
|
|
VARIABLES = {
|
|
"NEXTCLOUD_HPB_SIGNALING_PORT": 8081,
|
|
"NEXTCLOUD_HPB_SIGNALING_SECRET": SIGNALING_SECRET,
|
|
"NEXTCLOUD_HPB_INTERNAL_SECRET": INTERNAL_SECRET,
|
|
"NEXTCLOUD_HPB_BACKEND_URL": "https://next.example.org",
|
|
"NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS": "https://next.example.org",
|
|
"NEXTCLOUD_HPB_SIGNALING_LOCATION": "/standalone-signaling/",
|
|
"NEXTCLOUD_HPB_SIGNALING_MAX_STREAM_BITRATE": 1048576,
|
|
"NEXTCLOUD_HPB_SIGNALING_MAX_SCREEN_BITRATE": 2097152,
|
|
"NEXTCLOUD_HPB_NATS_URL": "nats://talk_nats:4222",
|
|
"NEXTCLOUD_HPB_JANUS_URL": "ws://talk:8188",
|
|
"NEXTCLOUD_RECORDING_PORT": 1234,
|
|
"NEXTCLOUD_RECORDING_SECRET": RECORDING_SECRET,
|
|
"NEXTCLOUD_RECORDING_DIRECTORY": "/tmp",
|
|
"NEXTCLOUD_HPB_MEDIA_PORT_START": 61000,
|
|
"NEXTCLOUD_HPB_MEDIA_PORT_END": 61999,
|
|
"NEXTCLOUD_HPB_MEDIA_PUBLIC_IP": "203.0.113.7",
|
|
"NEXTCLOUD_HPB_JANUS_BASE_IMAGE": "alpine",
|
|
"NEXTCLOUD_HPB_JANUS_BASE_VERSION": "3.23.3",
|
|
"NEXTCLOUD_HPB_JANUS_REPOSITORY": "https://github.com/meetecho/janus-gateway.git",
|
|
"NEXTCLOUD_HPB_JANUS_REF": "v1.4.0",
|
|
"NEXTCLOUD_HPB_JANUS_CONFIG_TARGET": "/usr/local/etc/janus/janus.jcfg",
|
|
"NEXTCLOUD_RECORDING_BASE_IMAGE": "python",
|
|
"NEXTCLOUD_RECORDING_BASE_VERSION": "3.14.3-alpine3.23",
|
|
"NEXTCLOUD_RECORDING_REPOSITORY": "https://github.com/nextcloud/nextcloud-talk-recording.git",
|
|
"NEXTCLOUD_RECORDING_REF": "v0.2.1",
|
|
"NEXTCLOUD_RECORDING_USER_ID": 122,
|
|
"NEXTCLOUD_RECORDING_CONFIG_TARGET": "/conf/recording.conf",
|
|
"NEXTCLOUD_RECORDING_CA_CERT": "/tmp/infinito/ca/root-ca.crt",
|
|
"NEXTCLOUD_RECORDING_CA_BUNDLE": "/tmp/infinito/ca/ca-bundle.crt",
|
|
}
|
|
|
|
AIO_SIGNALING = {
|
|
"http": {"listen": "0.0.0.0:8081"},
|
|
"app": {"debug": "false"},
|
|
"clients": {"internalsecret": INTERNAL_SECRET},
|
|
"backend": {
|
|
"backends": "backend-1",
|
|
"allowall": "false",
|
|
"timeout": "10",
|
|
"connectionsperhost": "8",
|
|
"skipverify": "false",
|
|
},
|
|
"backend-1": {
|
|
"urls": "https://next.example.org",
|
|
"secret": SIGNALING_SECRET,
|
|
"maxstreambitrate": "1048576",
|
|
"maxscreenbitrate": "2097152",
|
|
},
|
|
"nats": {"url": "nats://127.0.0.1:4222"},
|
|
"mcu": {
|
|
"type": "janus",
|
|
"url": "ws://127.0.0.1:8188",
|
|
"maxstreambitrate": "1048576",
|
|
"maxscreenbitrate": "2097152",
|
|
},
|
|
}
|
|
|
|
AIO_RECORDING = {
|
|
"logs": {"level": "30"},
|
|
"http": {"listen": "0.0.0.0:1234"},
|
|
"backend": {
|
|
"allowall": "false",
|
|
"secret": RECORDING_SECRET,
|
|
"backends": "backend-1",
|
|
"skipverify": "false",
|
|
"maxmessagesize": "1024",
|
|
"videowidth": "1920",
|
|
"videoheight": "1080",
|
|
"directory": "/tmp",
|
|
},
|
|
"backend-1": {
|
|
"url": "https://next.example.org",
|
|
"secret": RECORDING_SECRET,
|
|
"skipverify": "false",
|
|
},
|
|
"signaling": {"signalings": "signaling-1"},
|
|
"signaling-1": {
|
|
"url": "https://next.example.org/standalone-signaling/",
|
|
"internalsecret": INTERNAL_SECRET,
|
|
},
|
|
"ffmpeg": {"extensionaudio": ".ogg", "extensionvideo": ".webm"},
|
|
"recording": {
|
|
"browser": "firefox",
|
|
"driverPath": "/usr/bin/geckodriver",
|
|
"browserPath": "/usr/bin/firefox",
|
|
},
|
|
}
|
|
|
|
STOCK_JANUS = [
|
|
"general:{",
|
|
'configs_folder="/usr/local/etc/janus"',
|
|
'plugins_folder="/usr/local/lib/janus/plugins"',
|
|
'transports_folder="/usr/local/lib/janus/transports"',
|
|
'events_folder="/usr/local/lib/janus/events"',
|
|
'loggers_folder="/usr/local/lib/janus/loggers"',
|
|
"debug_level=4",
|
|
'admin_secret="janusoverlord"',
|
|
"protected_folders=[",
|
|
*[
|
|
f'"{folder}",'
|
|
for folder in (
|
|
"/bin",
|
|
"/boot",
|
|
"/dev",
|
|
"/etc",
|
|
"/initrd",
|
|
"/lib",
|
|
"/lib32",
|
|
"/lib64",
|
|
"/proc",
|
|
"/sbin",
|
|
"/sys",
|
|
"/usr",
|
|
"/var",
|
|
"/opt/janus/bin",
|
|
"/opt/janus/etc",
|
|
"/opt/janus/include",
|
|
"/opt/janus/lib",
|
|
"/opt/janus/lib32",
|
|
"/opt/janus/lib64",
|
|
)
|
|
],
|
|
'"/opt/janus/sbin"',
|
|
"]",
|
|
"}",
|
|
"certificates:{",
|
|
"}",
|
|
"media:{",
|
|
"}",
|
|
"nat:{",
|
|
'ice_ignore_list="vmnet"',
|
|
"}",
|
|
"plugins:{",
|
|
"}",
|
|
"transports:{",
|
|
"}",
|
|
"loggers:{",
|
|
"}",
|
|
"events:{",
|
|
"}",
|
|
]
|
|
|
|
|
|
def _render(source: str, **overrides) -> str:
|
|
env = Environment(
|
|
trim_blocks=True,
|
|
keep_trailing_newline=True,
|
|
undefined=StrictUndefined,
|
|
autoescape=select_autoescape(default_for_string=False),
|
|
)
|
|
env.filters["hash"] = lambda value, algorithm: hashlib.new(
|
|
algorithm, str(value).encode()
|
|
).hexdigest()
|
|
env.filters["bool"] = bool
|
|
env.filters["dirname"] = posixpath.dirname
|
|
return env.from_string(source).render({**VARIABLES, **overrides})
|
|
|
|
|
|
def _template(name: str) -> str:
|
|
return read_text(str(TEMPLATES / name))
|
|
|
|
|
|
def _build_args(dockerfile: str) -> str:
|
|
compose = _template("compose.yml.j2")
|
|
args: dict[str, str] = {}
|
|
for arg in re.findall(r"^ARG (\w+)$", dockerfile, re.MULTILINE):
|
|
passed = re.search(rf'^\s+{arg}: "(.*)"$', compose, re.MULTILINE)
|
|
if passed is None:
|
|
raise AssertionError(f"compose.yml.j2 passes no build arg {arg}")
|
|
args[arg] = _render(passed.group(1))
|
|
return re.sub(r"\$\{(\w+)\}", lambda match: args[match.group(1)], dockerfile)
|
|
|
|
|
|
def _build(name: str) -> str:
|
|
return _build_args(read_text(str(FILES / "Dockerfiles" / name / "Dockerfile")))
|
|
|
|
|
|
def _ini(text: str) -> dict[str, dict[str, str]]:
|
|
parser = configparser.ConfigParser(interpolation=None, strict=True)
|
|
parser.optionxform = str
|
|
parser.read_string(text)
|
|
return {section: dict(parser[section]) for section in parser.sections()}
|
|
|
|
|
|
def _settings(text: str) -> list[str]:
|
|
return [joined for line in text.splitlines() if (joined := "".join(line.split()))]
|
|
|
|
|
|
class TestSignalingConfig(unittest.TestCase):
|
|
def test_matches_what_the_aio_start_script_wrote_apart_from_the_split(self):
|
|
rendered = _ini(_render(_template("signaling.conf.j2")))
|
|
sessions = rendered.pop("sessions")
|
|
expected = {
|
|
**AIO_SIGNALING,
|
|
"nats": {"url": "nats://talk_nats:4222"},
|
|
"mcu": {**AIO_SIGNALING["mcu"], "url": "ws://talk:8188"},
|
|
}
|
|
self.assertEqual(rendered, expected)
|
|
self.assertEqual(sorted(sessions), ["blockkey", "hashkey"])
|
|
|
|
def test_session_keys_have_the_shape_aio_generated_and_stay_stable(self):
|
|
first = _ini(_render(_template("signaling.conf.j2")))["sessions"]
|
|
second = _ini(_render(_template("signaling.conf.j2")))["sessions"]
|
|
self.assertEqual(first, second)
|
|
for key in ("hashkey", "blockkey"):
|
|
self.assertRegex(first[key], r"^[0-9a-f]{32}$")
|
|
self.assertNotEqual(first["hashkey"], first["blockkey"])
|
|
self.assertNotIn(first["hashkey"], SIGNALING_SECRET)
|
|
|
|
def test_negative_control_another_signaling_secret_changes_the_keys(self):
|
|
first = _ini(_render(_template("signaling.conf.j2")))["sessions"]
|
|
other = _ini(
|
|
_render(
|
|
_template("signaling.conf.j2"),
|
|
NEXTCLOUD_HPB_SIGNALING_SECRET="base64:another-secret",
|
|
)
|
|
)["sessions"]
|
|
self.assertNotEqual(first["hashkey"], other["hashkey"])
|
|
self.assertNotEqual(first["blockkey"], other["blockkey"])
|
|
|
|
def test_negative_control_a_changed_setting_breaks_parity(self):
|
|
drifted = _template("signaling.conf.j2").replace(
|
|
"allowall = false", "allowall = true"
|
|
)
|
|
self.assertNotEqual(drifted, _template("signaling.conf.j2"))
|
|
rendered = _ini(_render(drifted))
|
|
rendered.pop("sessions")
|
|
self.assertNotEqual(rendered["backend"], AIO_SIGNALING["backend"])
|
|
|
|
|
|
CANONICAL_BACKEND_URL = VARIABLES["NEXTCLOUD_HPB_BACKEND_URL"]
|
|
ONION_BACKEND_URL = (
|
|
"http://next.7csk6epqensl5eaw3rogyw2hhzusuahoj4n6iidgnrolgzq3muymrlyd.onion"
|
|
)
|
|
|
|
|
|
def _signaling_backend_urls(network_urls: list[str]) -> str:
|
|
"""Render NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS from vars/main.yml.
|
|
|
|
Args:
|
|
network_urls: the answer of lookup('network_urls', NEXTCLOUD_HPB_BACKEND_URL) on the host.
|
|
|
|
Returns:
|
|
The value signaling.conf.j2 receives.
|
|
"""
|
|
|
|
def lookup(*terms: str) -> list[str]:
|
|
if terms != ("network_urls", CANONICAL_BACKEND_URL):
|
|
raise AssertionError(f"the backend urls render an unexpected lookup{terms}")
|
|
return network_urls
|
|
|
|
expression = load_yaml(ROLE / ROLE_FILE_VARS_MAIN)[
|
|
"NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS"
|
|
]
|
|
env = Environment(
|
|
undefined=StrictUndefined,
|
|
autoescape=select_autoescape(default_for_string=False),
|
|
)
|
|
return env.compile_expression(
|
|
expression.strip().removeprefix("{{").removesuffix("}}")
|
|
)(lookup=lookup, NEXTCLOUD_HPB_BACKEND_URL=CANONICAL_BACKEND_URL)
|
|
|
|
|
|
def _signaling_backends(network_urls: list[str]) -> dict[str, dict[str, str]]:
|
|
rendered = _ini(
|
|
_render(
|
|
_template("signaling.conf.j2"),
|
|
NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS=_signaling_backend_urls(network_urls),
|
|
)
|
|
)
|
|
return {"backend": rendered["backend"], "backend-1": rendered["backend-1"]}
|
|
|
|
|
|
class TestSignalingNetworkBackends(unittest.TestCase):
|
|
def test_every_network_sibling_reaches_the_one_backend_with_its_secret(self):
|
|
backends = _signaling_backends([CANONICAL_BACKEND_URL, ONION_BACKEND_URL])
|
|
self.assertEqual(backends["backend"]["backends"], "backend-1")
|
|
self.assertEqual(
|
|
backends["backend-1"]["urls"],
|
|
f"{CANONICAL_BACKEND_URL},{ONION_BACKEND_URL}",
|
|
)
|
|
self.assertEqual(backends["backend-1"]["secret"], SIGNALING_SECRET)
|
|
|
|
def test_a_host_served_on_one_network_lists_only_the_canonical_backend(self):
|
|
self.assertEqual(
|
|
_signaling_backends([CANONICAL_BACKEND_URL])["backend-1"]["urls"],
|
|
CANONICAL_BACKEND_URL,
|
|
)
|
|
|
|
def test_negative_control_the_single_backend_url_drops_the_onion_sibling(self):
|
|
drifted = _template("signaling.conf.j2").replace(
|
|
"NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS", "NEXTCLOUD_HPB_BACKEND_URL"
|
|
)
|
|
self.assertNotEqual(drifted, _template("signaling.conf.j2"))
|
|
rendered = _ini(
|
|
_render(
|
|
drifted,
|
|
NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS=_signaling_backend_urls(
|
|
[CANONICAL_BACKEND_URL, ONION_BACKEND_URL]
|
|
),
|
|
)
|
|
)
|
|
self.assertNotIn(ONION_BACKEND_URL, rendered["backend-1"]["urls"])
|
|
|
|
|
|
class TestRecordingConfig(unittest.TestCase):
|
|
def test_matches_what_the_aio_start_script_wrote(self):
|
|
self.assertEqual(_ini(_render(_template("recording.conf.j2"))), AIO_RECORDING)
|
|
|
|
def test_negative_control_a_dropped_setting_breaks_parity(self):
|
|
drifted = _template("recording.conf.j2").replace("maxmessagesize = 1024\n", "")
|
|
self.assertNotEqual(drifted, _template("recording.conf.j2"))
|
|
self.assertNotEqual(_ini(_render(drifted)), AIO_RECORDING)
|
|
|
|
|
|
class TestJanusConfig(unittest.TestCase):
|
|
def test_without_direct_media_it_equals_the_stock_janus_config(self):
|
|
rendered = _render(_template("janus.jcfg.j2"), NEXTCLOUD_HPB_MEDIA_DIRECT=False)
|
|
self.assertEqual(_settings(rendered), STOCK_JANUS)
|
|
|
|
def test_direct_media_adds_exactly_the_three_overrides(self):
|
|
rendered = _settings(
|
|
_render(_template("janus.jcfg.j2"), NEXTCLOUD_HPB_MEDIA_DIRECT=True)
|
|
)
|
|
expected = list(STOCK_JANUS)
|
|
expected.insert(expected.index("media:{") + 1, 'rtp_port_range="61000-61999"')
|
|
expected.insert(expected.index("nat:{") + 1, 'nat_1_1_mapping="203.0.113.7"')
|
|
expected.insert(expected.index("nat:{") + 2, "keep_private_host=true")
|
|
self.assertEqual(rendered, expected)
|
|
|
|
def test_negative_control_an_unguarded_override_breaks_the_stock_config(self):
|
|
drifted = _template("janus.jcfg.j2").replace(
|
|
"{% if NEXTCLOUD_HPB_MEDIA_DIRECT | bool %}\n\trtp_port_range",
|
|
"{% if true %}\n\trtp_port_range",
|
|
)
|
|
self.assertNotEqual(drifted, _template("janus.jcfg.j2"))
|
|
rendered = _render(drifted, NEXTCLOUD_HPB_MEDIA_DIRECT=False)
|
|
self.assertNotEqual(_settings(rendered), STOCK_JANUS)
|
|
|
|
|
|
class TestTalkDockerfiles(unittest.TestCase):
|
|
AIO_JANUS_BUILD_PACKAGES: ClassVar[list[str]] = [
|
|
"ca-certificates",
|
|
"git",
|
|
"autoconf",
|
|
"automake",
|
|
"build-base",
|
|
"pkgconfig",
|
|
"libtool",
|
|
"util-linux",
|
|
"glib-dev",
|
|
"zlib-dev",
|
|
"openssl-dev",
|
|
"jansson-dev",
|
|
"libnice-dev",
|
|
"libconfig-dev",
|
|
"libsrtp-dev",
|
|
"libusrsctp-dev",
|
|
"gengetopt-dev",
|
|
"libwebsockets-dev",
|
|
]
|
|
|
|
def test_janus_builds_the_pinned_tag_on_the_pinned_base(self):
|
|
rendered = _build("Janus")
|
|
self.assertEqual(
|
|
re.findall(r"^FROM (\S+)", rendered, re.MULTILINE),
|
|
["alpine:3.23.3", "alpine:3.23.3"],
|
|
)
|
|
self.assertIn(
|
|
'git clone --recursive "https://github.com/meetecho/janus-gateway.git" '
|
|
'--depth=1 --single-branch --branch "v1.4.0" /src',
|
|
rendered,
|
|
)
|
|
self.assertIn(
|
|
"/src/configure --disable-rabbitmq --disable-mqtt --disable-boringssl",
|
|
rendered,
|
|
)
|
|
build_stage = rendered.split("\nFROM ")[1]
|
|
for package in self.AIO_JANUS_BUILD_PACKAGES:
|
|
with self.subTest(package=package):
|
|
self.assertRegex(build_stage, rf"\n\s+{re.escape(package)}( \\|;)")
|
|
self.assertIn(
|
|
'ENTRYPOINT ["janus", "--disable-colors", "--log-stdout", '
|
|
'"--full-trickle", "--debug-level", "3"]',
|
|
rendered,
|
|
)
|
|
|
|
def test_recording_installs_the_pinned_release_on_the_pinned_base(self):
|
|
rendered = _build("Recording")
|
|
self.assertEqual(
|
|
re.findall(r"^FROM (\S+)", rendered, re.MULTILINE),
|
|
["python:3.14.3-alpine3.23"],
|
|
)
|
|
self.assertIn(
|
|
'git clone --recursive "https://github.com/nextcloud/nextcloud-talk-recording.git" '
|
|
'--depth=1 --single-branch --branch "v0.2.1" /src',
|
|
rendered,
|
|
)
|
|
self.assertIn('useradd -d "/tmp" --system recording -u "122"', rendered)
|
|
self.assertIn('mkdir -p "/conf"', rendered)
|
|
self.assertIn(
|
|
"""printf '{"policies":{"Certificates":{"Install":["%s"]}}}\\n' """
|
|
'"/tmp/infinito/ca/root-ca.crt" > /etc/firefox/policies/policies.json',
|
|
rendered,
|
|
)
|
|
self.assertIn(
|
|
'ln -sf "/tmp/infinito/ca/ca-bundle.crt" '
|
|
"\"$(python3 -c 'import certifi; print(certifi.where())')\"",
|
|
rendered,
|
|
)
|
|
self.assertIn("USER 122", rendered)
|
|
self.assertIn(
|
|
'ENTRYPOINT ["python", "-m", "nextcloud.talk.recording"]', rendered
|
|
)
|
|
|
|
def test_the_compose_command_points_each_binary_at_its_rendered_config(self):
|
|
compose = _template("compose.yml.j2")
|
|
self.assertIn(
|
|
'command: ["--config={{ NEXTCLOUD_HPB_JANUS_CONFIG_TARGET }}"]', compose
|
|
)
|
|
self.assertIn(
|
|
'command: ["--config", "{{ NEXTCLOUD_RECORDING_CONFIG_TARGET }}"]', compose
|
|
)
|
|
|
|
def test_no_image_ships_a_start_script_that_writes_its_config(self):
|
|
for name in ("Janus", "Recording"):
|
|
with self.subTest(dockerfile=name):
|
|
rendered = _build(name)
|
|
self.assertNotRegex(rendered, r"ENTRYPOINT \[\"[^\"]*\.sh\"")
|
|
self.assertNotIn(
|
|
"COPY",
|
|
rendered.split("\nFROM ")[-1].replace("COPY --from=build", ""),
|
|
)
|
|
self.assertNotIn("SECRET", rendered)
|
|
|
|
def test_negative_control_an_arg_compose_does_not_pass_fails_the_build(self):
|
|
with self.assertRaisesRegex(AssertionError, "JANUS_UNPASSED"):
|
|
_build_args("ARG JANUS_UNPASSED\nFROM ${JANUS_UNPASSED}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|