1#!/usr/bin/env python3
2import argparse
3from contextlib import ExitStack
4import importlib
5import json
6import os
7from pathlib import Path
8import pwd
9import re
10import shutil
11import socket
12import subprocess
13import sys
14import tempfile
15import time
16import urllib.request
17import uuid
18
19import router
20
21
22def 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
302if __name__ == "__main__":
303 main()