| 1 | #!/usr/bin/env python3 |
| 2 | import argparse |
| 3 | from contextlib import ExitStack |
| 4 | import importlib |
| 5 | import json |
| 6 | import os |
| 7 | from pathlib import Path |
| 8 | import pwd |
| 9 | import re |
| 10 | import shutil |
| 11 | import socket |
| 12 | import subprocess |
| 13 | import sys |
| 14 | import tempfile |
| 15 | import time |
| 16 | import urllib.request |
| 17 | import uuid |
| 18 | |
| 19 | import router |
| 20 | |
| 21 | |
| 22 | def main(): |
| 23 | parser = argparse.ArgumentParser() |
| 24 | parser.add_argument("--output", type=Path) |
| 25 | parser.add_argument("--browser-ready-file", type=Path) |
| 26 | parser.add_argument("--browser-timeout", type=int, default=180) |
| 27 | parser.add_argument("--relay-agent-dir", type=Path) |
| 28 | args = parser.parse_args() |
| 29 | assert 1 <= args.browser_timeout <= 600 |
| 30 | repo = Path(__file__).resolve().parent.parent |
| 31 | nix = ["nix", "--extra-experimental-features", "nix-command flakes"] |
| 32 | base = "path:" + str(repo) + "#nixosConfigurations.vm.config" |
| 33 | |
| 34 | def shell(*argv, timeout=60): |
| 35 | result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) |
| 36 | if result.returncode: |
| 37 | raise AssertionError(result.stderr) |
| 38 | return result.stdout |
| 39 | |
| 40 | units = {name: shell(*nix, "eval", base + '.systemd.units."' + name + '.service".text', |
| 41 | "--offline", "--raw", "--option", "eval-cache", "false") |
| 42 | for name in ["studio-dashboard", "studio-host"]} |
| 43 | permissions = [rule for rule in json.loads(shell(*nix, "eval", base + ".systemd.tmpfiles.rules", |
| 44 | "--offline", "--json", "--option", "eval-cache", "false")) |
| 45 | if rule.startswith("A+ /srv/prod/ytdl/data ")] |
| 46 | assert len(permissions) == 1, permissions |
| 47 | scripts = re.findall(r"^Exec(?:Start|StartPre|StartPost)=(/nix/store/\S+)$", units["studio-dashboard"], re.M) |
| 48 | assert len(scripts) == 3, scripts |
| 49 | shell(*nix, "build", *[base + '.systemd.units."' + name + '.service".unit' |
| 50 | for name in units], "--offline", "--no-link", timeout=1200) |
| 51 | try: |
| 52 | account = pwd.getpwnam("studio-dashboard") |
| 53 | except KeyError: |
| 54 | shell("useradd", "--system", "--user-group", "--no-create-home", "studio-dashboard") |
| 55 | account = pwd.getpwnam("studio-dashboard") |
| 56 | assert account.pw_uid != 0 and account.pw_gid != 0 |
| 57 | app_unit = "studio-dashboard-unit-test" |
| 58 | host_unit = "studio-host-unit-test" |
| 59 | container = "studio-dashboard-unit-test" |
| 60 | unit_files = [Path("/run/systemd/system/" + name + ".service") for name in [app_unit, host_unit]] |
| 61 | assert not any(file.exists() for file in unit_files), unit_files |
| 62 | assert not json.loads(shell("podman", "ps", "--all", "--filter=name=^" + container + "$", "--format=json")) |
| 63 | proof = uuid.uuid4().hex + uuid.uuid4().hex |
| 64 | |
| 65 | with tempfile.TemporaryDirectory(prefix="studio-dashboard-unit-", dir="/run") as temporary, ExitStack() as fixtures: |
| 66 | root = Path(temporary) |
| 67 | library = root / "clover" |
| 68 | season = library / "Media/jellyfin/Indie Shows/Fixture/Season 1" |
| 69 | config = library / "Documents/Config/Youtube Downloader" |
| 70 | config.mkdir(parents=True) |
| 71 | season.mkdir(parents=True) |
| 72 | (config / "feed.yaml").write_text("shows: []\n") |
| 73 | (season / "S01E01 - Fixture.mp4").write_bytes(b"fixture") |
| 74 | (season / "S01E01 - Fixture.nfo").write_text("<episodedetails><title>Unit fixture</title></episodedetails>") |
| 75 | for directory in [library, *[p for p in library.rglob("*") if p.is_dir()]]: |
| 76 | os.chown(directory, 3000, 3000) |
| 77 | directory.chmod(0o2770) |
| 78 | data = root / "data" |
| 79 | data.mkdir(mode=0o700) |
| 80 | (data / "existing.json").write_text('{"fixture":true}\n') |
| 81 | state = root / "yt-state" |
| 82 | (state / "nested").mkdir(parents=True) |
| 83 | (state / "existing.json").write_text('{"fixture":true}\n') |
| 84 | for path in [state, *state.rglob("*")]: |
| 85 | os.chown(path, 3118, 3118) |
| 86 | path.chmod(0o750 if path.is_dir() else 0o640) |
| 87 | token = root / "proxy.token" |
| 88 | token.write_text(proof) |
| 89 | readonly = root / "nomad.token" |
| 90 | readonly.write_bytes(Path("/var/lib/studio/dashboard.token").read_bytes()) |
| 91 | ca = root / "ca.crt" |
| 92 | ca.write_bytes(Path("/var/lib/studio/ca-bundle.crt").read_bytes()) |
| 93 | ca.chmod(0o444) |
| 94 | acl = root / "permissions.conf" |
| 95 | acl.write_text(permissions[0].replace("/srv/prod/ytdl/data", str(state)) + "\n") |
| 96 | with socket.socket() as reservation: |
| 97 | reservation.bind(("127.0.0.1", 0)) |
| 98 | port = reservation.getsockname()[1] |
| 99 | sys.path.insert(0, str(repo / "service/keycloak")) |
| 100 | from api import Keycloak |
| 101 | keycloak = Keycloak("keycloak.studio.test", importlib.import_module("dashboard-run").secret("get", "keycloak", "password"), attempts=1) |
| 102 | shale_image = re.search(r'^\s*image = "([^"]+)"', (repo / "service/shale/service.pkl").read_text(), re.M).group(1) |
| 103 | shale_origin, shale_database, shale_container = fixtures.enter_context(importlib.import_module("dashboard-shale-test").instance( |
| 104 | keycloak, uuid.uuid4().hex, shale_image, None, False, host="shale.studio.test", dashboard_port=port)) |
| 105 | with socket.socket() as reservation: |
| 106 | for gateway_port in range(20000, 32001): |
| 107 | try: |
| 108 | reservation.bind(("0.0.0.0", gateway_port)) |
| 109 | break |
| 110 | except OSError: |
| 111 | continue |
| 112 | else: |
| 113 | raise AssertionError("no fixture gateway port available") |
| 114 | os.environ.update(STUDIO_DOMAIN="studio.test", STUDIO_INTERNAL_PORT=str(gateway_port), |
| 115 | STUDIO_DASHBOARD_PORT=str(port), STUDIO_PROXY_TOKEN_FILE=str(token)) |
| 116 | rendered = router.render(Path(router.TOKEN).read_text().strip()) |
| 117 | gateway = rendered[rendered.index(f"dashboard.internal.studio.test:{gateway_port} {{"):] |
| 118 | gateway_config = root / "Caddyfile" |
| 119 | gateway_config.write_text("{\n admin off\n auto_https disable_redirects\n skip_install_trust\n}\n" + gateway + |
| 120 | f"\nglobe.studio.test:{gateway_port} {{\n tls internal\n reverse_proxy 127.0.0.1:{port} {{\n header_up Host globe.studio.test\n }}\n}}\n") |
| 121 | gateway_config.chmod(0o600) |
| 122 | caddy_binary = (Path("/proc") / shell("systemctl", "show", "-P", "MainPID", "caddy").strip() / "exe").resolve(strict=True) |
| 123 | caddy = None |
| 124 | replacements = { |
| 125 | "https://dashboard.internal.studio.test:8448": f"https://dashboard.internal.studio.test:{gateway_port}", |
| 126 | "--name=studio-dashboard ": "--name=" + container + " ", |
| 127 | "--volume=/run/studio-host:/run/studio-host:ro": "--volume=/run/" + host_unit + ":/run/studio-host:ro", |
| 128 | "/var/lib/studio/dashboard-proxy.token": str(token), |
| 129 | "/var/lib/studio/dashboard.token": str(readonly), |
| 130 | "/var/lib/studio/ca-bundle.crt": str(ca), |
| 131 | "/var/lib/studio/dashboard": str(data), |
| 132 | "/srv/prod/ytdl/data": str(state), |
| 133 | "/srv/clover": str(library), |
| 134 | "--prefix=" + str(state): "--prefix=" + str(state) + " " + str(acl), |
| 135 | "--publish=127.0.0.1:7072:7072": f"--publish=127.0.0.1:{port}:7072", |
| 136 | "http://127.0.0.1:7072/": f"http://127.0.0.1:{port}/", |
| 137 | } |
| 138 | text = units["studio-dashboard"] |
| 139 | for source in scripts: |
| 140 | content = Path(source).read_text() |
| 141 | # Only host mount sources move; container paths retain the generated unit's contract. |
| 142 | for original, replacement in replacements.items(): |
| 143 | if original in ["/srv/prod/ytdl/data", "/srv/clover"]: |
| 144 | content = content.replace("test -d " + original, "test -d " + replacement) |
| 145 | content = content.replace("test -d '" + original, "test -d '" + replacement) |
| 146 | content = content.replace("src=" + original, "src=" + replacement) |
| 147 | content = content.replace("--volume=" + original + ":", "--volume=" + replacement + ":") |
| 148 | content = content.replace("--prefix=" + original, "--prefix=" + replacement) |
| 149 | else: |
| 150 | content = content.replace(original, replacement) |
| 151 | destination = root / Path(source).name |
| 152 | destination.write_text(content) |
| 153 | destination.chmod(0o700) |
| 154 | text = text.replace(source, str(destination)) |
| 155 | text = text.replace("https://dashboard.internal.studio.test:8448", f"https://dashboard.internal.studio.test:{gateway_port}") |
| 156 | text = text.replace('Environment="STUDIO_SHALE_URL=https://shale.studio.test"', 'Environment="STUDIO_SHALE_URL=' + shale_origin + '"') |
| 157 | text = re.sub(r'^Environment="STUDIO_INDEX_POOL=.*"\n', "", text, flags=re.M) |
| 158 | text = text.replace("studio-host.service", host_unit + ".service").replace("studio-router.service", "") |
| 159 | text = text.replace("--time=8 studio-dashboard", "--time=8 " + container).replace("--force studio-dashboard", "--force " + container) |
| 160 | for original in ["/var/lib/studio/dashboard.token", "/var/lib/studio/ca-bundle.crt"]: |
| 161 | text = text.replace("ConditionPathExists=" + original, "ConditionPathExists=" + replacements[original]) |
| 162 | unit_files[0].write_text(text) |
| 163 | host_text = units["studio-host"].replace("RuntimeDirectory=studio-host", "RuntimeDirectory=" + host_unit).replace("StateDirectory=studio/host", "StateDirectory=studio/host-unit-test") |
| 164 | host_text = host_text.replace("[Service]\n", "[Service]\n" + f'Environment="STUDIO_HOST_SOCKET=/run/{host_unit}/host.sock"\nEnvironment="STUDIO_HOST_STATE_ROOT=/var/lib/studio/host-unit-test"\n') |
| 165 | unit_files[1].write_text(host_text) |
| 166 | try: |
| 167 | with (root / "caddy.log").open("w") as log: |
| 168 | caddy = subprocess.Popen([str(caddy_binary), "run", "--config", str(gateway_config), "--adapter", "caddyfile"], |
| 169 | stdout=log, stderr=log, env={**os.environ, "XDG_DATA_HOME": str(root), "XDG_CONFIG_HOME": str(root)}) |
| 170 | gateway_ca = root / "caddy/pki/authorities/local/root.crt" |
| 171 | deadline = time.monotonic() + 15 |
| 172 | while not gateway_ca.exists(): |
| 173 | if caddy.poll() is not None or time.monotonic() > deadline: |
| 174 | raise AssertionError("fixture gateway did not start") |
| 175 | time.sleep(.1) |
| 176 | with ca.open("ab") as bundle: |
| 177 | bundle.write(gateway_ca.read_bytes()) |
| 178 | shell("systemctl", "daemon-reload") |
| 179 | shell("systemctl", "start", app_unit, timeout=180) |
| 180 | info = json.loads(shell("podman", "inspect", container))[0] |
| 181 | assert info["Config"]["User"] == f"{account.pw_uid}:{account.pw_gid}", info["Config"]["User"] |
| 182 | host = info["HostConfig"] |
| 183 | assert host["ReadonlyRootfs"] and not host["Privileged"] and not host["Devices"] |
| 184 | assert host["NetworkMode"] != "host" and host["PidsLimit"] == 256 |
| 185 | assert host["Memory"] == 2 * 1024 ** 3 and host["NanoCpus"] == 4 * 10 ** 9 |
| 186 | assert "no-new-privileges" in host["SecurityOpt"] |
| 187 | mounts = {mount["Destination"]: mount for mount in info["Mounts"]} |
| 188 | sources = {"/data": data, "/run/studio-host": Path("/run/" + host_unit), |
| 189 | "/run/secrets/dashboard-proxy.token": token, "/run/secrets/dashboard-nomad.token": readonly, |
| 190 | "/run/secrets/ca-bundle.crt": ca, "/srv/clover": library, |
| 191 | "/srv/clover/Media": library / "Media", "/srv/prod/ytdl/data": state} |
| 192 | assert mounts.keys() == sources.keys(), mounts.keys() |
| 193 | for target, source in sources.items(): |
| 194 | assert mounts[target]["Source"] == str(source), mounts[target] |
| 195 | assert mounts[target]["RW"] == (target in ["/data", "/srv/clover", "/srv/prod/ytdl/data"]), mounts[target] |
| 196 | for namespace in ["net", "pid", "mnt"]: |
| 197 | assert Path(f"/proc/{info['State']['Pid']}/ns/{namespace}").stat().st_ino != Path(f"/proc/self/ns/{namespace}").stat().st_ino |
| 198 | status = shell("podman", "exec", container, "/bin/cat", "/proc/1/status") |
| 199 | assert f"Uid:\t{account.pw_uid}\t{account.pw_uid}\t{account.pw_uid}\t{account.pw_uid}" in status |
| 200 | assert all(field + ":\t0000000000000000" in status for field in ["CapEff", "CapPrm", "CapBnd", "CapAmb"]) |
| 201 | for denied in ["/var/lib/studio/nomad.token", "/run/podman/podman.sock", "/run/libvirt/libvirt-sock", "/dev/zfs", "/opt/studio/current", "/srv/vm", "/srv/prod/keycloak"]: |
| 202 | result = subprocess.run(["podman", "exec", container, "/bin/test", "-e", denied], capture_output=True) |
| 203 | assert result.returncode != 0, denied |
| 204 | assert data.stat().st_uid == account.pw_uid and (data / "existing.json").stat().st_uid == account.pw_uid |
| 205 | python = next(value.split("=", 1)[1] for value in info["Config"]["Env"] if value.startswith("STUDIO_YT_PYTHON=")) |
| 206 | shell("podman", "exec", container, python, "-c", "import ssl; assert ssl.create_default_context().cert_store_stats()['x509_ca'] > 0") |
| 207 | refused = "import socket; s=socket.socket(socket.AF_UNIX); s.connect('/run/studio-host/host.sock'); " + "\ntry:\n s.sendall(b'{\"operation\":\"host.sample\"}\\n'); assert s.recv(4) == b''\nexcept (BrokenPipeError, ConnectionResetError):\n pass\n" |
| 208 | shell("podman", "exec", f"--user=65534:{account.pw_gid}", container, python, "-c", refused) |
| 209 | shell("podman", "exec", container, python, "-c", "from pathlib import Path; p=Path('/srv/prod/ytdl/data'); (p/'existing.json').write_text('fixture'); (p/'nested/new.json').write_text('fixture')") |
| 210 | assert (state / "existing.json").stat().st_uid == 3118 and state.stat().st_uid == 3118 |
| 211 | assert (state / "nested/new.json").exists() |
| 212 | shell("podman", "exec", "--user=3118:3000", container, python, "-c", "from pathlib import Path; p=Path('/srv/prod/ytdl/data'); (p/'nested/new.json').open('a').write('service'); (p/'nested/service.json').write_text('service')") |
| 213 | shell("podman", "exec", container, python, "-c", "from pathlib import Path; p=Path('/srv/prod/ytdl/data/nested/service.json'); assert p.read_text() == 'service'; p.open('a').write('dashboard')") |
| 214 | assert subprocess.run(["podman", "exec", container, "/bin/touch", "/srv/clover/Media/escaped"], capture_output=True).returncode != 0 |
| 215 | |
| 216 | def get(path): |
| 217 | request = urllib.request.Request(f"http://127.0.0.1:{port}" + path, headers={ |
| 218 | "Studio-Proxy-Token": proof, "User-Name": "fixture", "User-Groups": "infra-admin", |
| 219 | }) |
| 220 | with urllib.request.urlopen(request, timeout=30) as response: |
| 221 | return json.load(response) |
| 222 | |
| 223 | machine = get("/api/host") |
| 224 | assert machine["cores"] > 0 and machine["memory"] > 2 * 1024 ** 3 |
| 225 | assert get("/api/media/list?path=jellyfin/Indie%20Shows/Fixture/Season%201")["entries"] |
| 226 | assert get("/api/youtube/library") |
| 227 | assert get("/api/youtube") |
| 228 | iam_output = root / "iam-checks.json" |
| 229 | shell(sys.executable, str(repo / "tools/dashboard-iam-test.py"), "--url", f"http://127.0.0.1:{port}", |
| 230 | "--socket", f"/run/{host_unit}/host.sock", "--proof-file", str(token), |
| 231 | "--output", str(iam_output), timeout=180) |
| 232 | iam_checks = json.loads(iam_output.read_text()) |
| 233 | mcp_output = root / "mcp-checks.json" |
| 234 | shell(sys.executable, str(repo / "tools/dashboard-mcp-test.py"), "--url", f"http://127.0.0.1:{port}", |
| 235 | "--proof-file", str(token), "--restart-unit", app_unit, |
| 236 | "--output", str(mcp_output), timeout=240) |
| 237 | mcp_checks = json.loads(mcp_output.read_text()) |
| 238 | relay_output = root / "relay-checks.json" |
| 239 | shell(sys.executable, str(repo / "tools/dashboard-relay-test.py"), "--url", f"http://127.0.0.1:{port}", |
| 240 | "--proof-file", str(token), "--data-dir", str(data), "--restart-unit", app_unit, |
| 241 | "--output", str(relay_output), *(["--agent-dir", str(args.relay_agent_dir), "--agent-origin", f"https://globe.studio.test:{gateway_port}", "--agent-ca", str(gateway_ca)] if args.relay_agent_dir else []), timeout=240) |
| 242 | relay_checks = json.loads(relay_output.read_text()) |
| 243 | shale_output = root / "shale-link-checks.json" |
| 244 | shell(sys.executable, str(repo / "tools/dashboard-shale-link-test.py"), "--url", f"http://127.0.0.1:{port}", |
| 245 | "--proof-file", str(token), "--data-dir", str(data), "--restart-unit", app_unit, |
| 246 | "--shale-origin", shale_origin, "--shale-database", str(shale_database), "--shale-container", shale_container, |
| 247 | "--output", str(shale_output), timeout=240) |
| 248 | shale_checks = json.loads(shale_output.read_text()) |
| 249 | worker = "from pathlib import Path; found=[]\nfor p in Path('/proc').iterdir():\n if p.name.isdigit():\n try:\n argv=(p/'cmdline').read_bytes().rstrip(b'\\0').split(b'\\0')\n if argv[1:] == [b'server/youtube-worker.py']:\n found.append((p/'status').read_text())\n except (FileNotFoundError, PermissionError):\n pass\nassert len(found) == 1, len(found)\n" + f"assert 'Uid:\\t{account.pw_uid}\\t{account.pw_uid}\\t{account.pw_uid}\\t{account.pw_uid}' in found[0]\nassert 'CapEff:\\t0000000000000000' in found[0] and 'NoNewPrivs:\\t1' in found[0]\n" |
| 250 | shell("podman", "exec", container, python, "-c", worker) |
| 251 | socket_inode = Path("/run/" + host_unit).stat().st_ino |
| 252 | shell("systemctl", "restart", host_unit) |
| 253 | assert Path("/run/" + host_unit).stat().st_ino == socket_inode |
| 254 | time.sleep(1.1) |
| 255 | assert get("/api/host")["memory"] == machine["memory"] |
| 256 | started = time.monotonic() |
| 257 | shell("systemctl", "restart", app_unit, timeout=180) |
| 258 | restart_seconds = time.monotonic() - started |
| 259 | assert restart_seconds < 15, restart_seconds |
| 260 | assert get("/api/me")["name"] == "fixture" and (data / "existing.json").exists() |
| 261 | browser_checks = None |
| 262 | if args.browser_ready_file: |
| 263 | browser_checks = json.loads(shell(sys.executable, str(repo / "tools/dashboard-browser-test.py"), |
| 264 | "--ready-file", str(args.browser_ready_file), "--timeout", str(args.browser_timeout), timeout=args.browser_timeout + 60)) |
| 265 | started = time.monotonic() |
| 266 | shell("systemctl", "stop", app_unit, timeout=30) |
| 267 | elapsed = time.monotonic() - started |
| 268 | assert elapsed < 15 and shell("systemctl", "show", "-P", "Result", app_unit).strip() == "success" |
| 269 | result = {"image": info["ImageName"], "generated_nixos_unit": True, "dedicated_uid": account.pw_uid, "readonly_rootfs": True, |
| 270 | "zero_capabilities": True, "quotas": True, "private_network": True, "bounded_mounts": True, |
| 271 | "host_broker_uid_auth": True, "other_uid_same_group_refused": True, |
| 272 | "state_ownership_migration": True, "shared_youtube_acl": True, |
| 273 | "youtube_owner_preserved": True, "readonly_media": True, "unprivileged_youtube_worker": True, |
| 274 | "python_public_tls_roots": True, |
| 275 | "iam": iam_checks, "mcp": mcp_checks, "relay": relay_checks, "shale_link": shale_checks, |
| 276 | "broker_restart_socket_preserved": True, "container_restart_state_preserved": True, |
| 277 | "cached_image_restart_seconds": round(restart_seconds, 2), |
| 278 | "systemd_stop_seconds": round(elapsed, 2)} |
| 279 | if browser_checks is not None: |
| 280 | result["browser_route_fixture"] = browser_checks |
| 281 | if args.output: |
| 282 | args.output.parent.mkdir(parents=True, exist_ok=True) |
| 283 | args.output.write_text(json.dumps(result, indent=2) + "\n") |
| 284 | print(json.dumps(result)) |
| 285 | finally: |
| 286 | subprocess.run(["systemctl", "stop", app_unit, host_unit], capture_output=True, timeout=60) |
| 287 | subprocess.run(["podman", "rm", "--ignore", "--force", container], capture_output=True, timeout=15) |
| 288 | if caddy is not None: |
| 289 | caddy.terminate() |
| 290 | try: |
| 291 | caddy.wait(timeout=10) |
| 292 | except subprocess.TimeoutExpired: |
| 293 | caddy.kill() |
| 294 | caddy.wait(timeout=5) |
| 295 | for file in unit_files: |
| 296 | file.unlink(missing_ok=True) |
| 297 | for directory in ["/var/lib/studio/host-unit-test", "/run/" + host_unit]: |
| 298 | shutil.rmtree(directory, ignore_errors=True) |
| 299 | shell("systemctl", "daemon-reload") |
| 300 | |
| 301 | |
| 302 | if __name__ == "__main__": |
| 303 | main() |