| 1 | #!/usr/bin/env python3 |
| 2 | import argparse |
| 3 | import concurrent.futures |
| 4 | import json |
| 5 | from pathlib import Path |
| 6 | import subprocess |
| 7 | import time |
| 8 | import urllib.request |
| 9 | |
| 10 | |
| 11 | def main(): |
| 12 | parser = argparse.ArgumentParser() |
| 13 | parser.add_argument("container") |
| 14 | parser.add_argument("--url", default="http://127.0.0.1:7074") |
| 15 | parser.add_argument("--clients", type=int, default=40) |
| 16 | parser.add_argument("--requests", type=int, default=1000) |
| 17 | parser.add_argument("--output", type=Path) |
| 18 | parser.add_argument("--proxy-token-file", type=Path, required=True) |
| 19 | parser.add_argument("--stop", action="store_true") |
| 20 | args = parser.parse_args() |
| 21 | info = json.loads(subprocess.check_output(["podman", "inspect", args.container]))[0] |
| 22 | host = info["HostConfig"] |
| 23 | assert info["Config"]["User"].split(":")[0] not in {"", "0", "root"} |
| 24 | assert host["ReadonlyRootfs"] is True |
| 25 | assert "no-new-privileges" in host["SecurityOpt"] |
| 26 | assert not host["Privileged"] and not host["Devices"] |
| 27 | assert host["NetworkMode"] != "host" |
| 28 | assert all(m["Destination"] in ["/run/studio-host", "/run/secrets/dashboard-proxy.token", "/data"] for m in info["Mounts"]), info["Mounts"] |
| 29 | assert next(m for m in info["Mounts"] if m["Destination"] == "/run/studio-host")["RW"] is False |
| 30 | status = subprocess.check_output(["podman", "exec", args.container, "/bin/cat", "/proc/1/status"], text=True) |
| 31 | uid = next(line.split()[1:] for line in status.splitlines() if line.startswith("Uid:")) |
| 32 | assert all(int(value) != 0 for value in uid), uid |
| 33 | for field in ["CapEff", "CapBnd", "CapPrm", "CapAmb"]: |
| 34 | assert field + ":\t0000000000000000" in status, status |
| 35 | assert "NoNewPrivs:\t1" in status, status |
| 36 | mounts = subprocess.check_output(["podman", "exec", args.container, "/bin/cat", "/proc/1/mountinfo"], text=True) |
| 37 | root = next(line.split() for line in mounts.splitlines() if line.split()[4] == "/") |
| 38 | assert "ro" in root[5].split(","), root |
| 39 | assert subprocess.run(["podman", "exec", args.container, "/bin/touch", "/escaped"], capture_output=True).returncode != 0 |
| 40 | for denied in ["/var/lib/studio/nomad.token", "/run/podman/podman.sock", "/run/libvirt/libvirt-sock", "/dev/zfs", "/opt/studio/current"]: |
| 41 | assert subprocess.run(["podman", "exec", args.container, "/bin/test", "-e", denied], capture_output=True).returncode != 0, denied |
| 42 | pid = info["State"]["Pid"] |
| 43 | for namespace in ["net", "pid", "mnt"]: |
| 44 | assert Path(f"/proc/{pid}/ns/{namespace}").stat().st_ino != Path(f"/proc/self/ns/{namespace}").stat().st_ino |
| 45 | |
| 46 | headers = {"User-Name": "fixture", "User-Groups": "infra-admin", "Studio-Proxy-Token": args.proxy_token_file.read_text().strip()} |
| 47 | with urllib.request.urlopen(urllib.request.Request(args.url + "/api/host", headers=headers), timeout=10) as response: |
| 48 | machine = json.load(response) |
| 49 | expected_memory = next(int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:")) |
| 50 | expected_cores = sum(line.startswith("cpu") and line[3:4].isdigit() for line in Path("/proc/stat").read_text().splitlines()) |
| 51 | assert machine["memory"] == expected_memory and machine["cores"] == expected_cores, machine |
| 52 | with urllib.request.urlopen(urllib.request.Request(args.url + "/api/live", headers=headers), timeout=10) as response: |
| 53 | for _ in range(20): |
| 54 | line = response.readline() |
| 55 | if line.startswith(b"data:"): |
| 56 | live = json.loads(line[5:]) |
| 57 | break |
| 58 | else: |
| 59 | raise AssertionError("host sample did not arrive") |
| 60 | assert 0 <= live["host"]["cpu"] <= 100 and live["host"]["memory"] > 0, live |
| 61 | assert live["host"]["arc"] is not None, live |
| 62 | |
| 63 | def request(_): |
| 64 | start = time.monotonic() |
| 65 | req = urllib.request.Request(args.url + "/api/storage", headers=headers) |
| 66 | with urllib.request.urlopen(req, timeout=10) as response: |
| 67 | assert json.load(response)["pool"]["state"] == "ONLINE" |
| 68 | return (time.monotonic() - start) * 1000 |
| 69 | |
| 70 | with concurrent.futures.ThreadPoolExecutor(max_workers=args.clients) as clients: |
| 71 | latency = sorted(clients.map(request, range(args.requests))) |
| 72 | result = {"nonroot_container": "passed", "readonly_rootfs": "passed", "zero_capabilities": "passed", |
| 73 | "no_new_privileges": "passed", "private_namespaces": "passed", "control_sockets_and_management_token_absent": "passed", |
| 74 | "host_identity_outside_container_quota": "passed", "host_live_sample": "passed", |
| 75 | "storage_requests": len(latency), "storage_clients": args.clients, |
| 76 | "storage_p95_ms": round(latency[int(len(latency) * .95)], 2), "storage_max_ms": round(latency[-1], 2)} |
| 77 | if args.stop: |
| 78 | req = urllib.request.Request(args.url + "/api/live", headers=headers) |
| 79 | with urllib.request.urlopen(req, timeout=10): |
| 80 | started = time.monotonic() |
| 81 | stopped = subprocess.run(["podman", "stop", "--time=8", args.container], capture_output=True, text=True, check=True) |
| 82 | elapsed = time.monotonic() - started |
| 83 | ended = json.loads(subprocess.check_output(["podman", "inspect", args.container]))[0]["State"] |
| 84 | assert ended["ExitCode"] == 0 and not ended["OOMKilled"], (ended, stopped.stderr) |
| 85 | assert elapsed < 8, elapsed |
| 86 | result["stop_with_active_stream"] = "passed" |
| 87 | result["stop_seconds"] = round(elapsed, 2) |
| 88 | if args.output: |
| 89 | args.output.write_text(json.dumps(result, indent=2) + "\n") |
| 90 | print(json.dumps(result)) |
| 91 | |
| 92 | |
| 93 | if __name__ == "__main__": |
| 94 | main() |