| 1 | #!/usr/bin/env python3 |
| 2 | import argparse |
| 3 | import json |
| 4 | import os |
| 5 | from pathlib import Path |
| 6 | import shutil |
| 7 | import sqlite3 |
| 8 | import subprocess |
| 9 | import sys |
| 10 | import tempfile |
| 11 | import time |
| 12 | import urllib.error |
| 13 | import urllib.parse |
| 14 | import urllib.request |
| 15 | import uuid |
| 16 | |
| 17 | |
| 18 | def main(): |
| 19 | parser = argparse.ArgumentParser() |
| 20 | parser.add_argument("socket", type=Path) |
| 21 | parser.add_argument("--pool", default="studio-demo") |
| 22 | parser.add_argument("--image", required=True) |
| 23 | parser.add_argument("--output", type=Path) |
| 24 | args = parser.parse_args() |
| 25 | suffix = uuid.uuid4().hex |
| 26 | proof = uuid.uuid4().hex + uuid.uuid4().hex |
| 27 | dataset = args.pool + "/files-boundary-test-" + suffix |
| 28 | mount = Path("/srv/.files-boundary-test-" + suffix) |
| 29 | private = Path("/srv/.files-private-test-" + suffix) |
| 30 | container = "studio-dashboard-files-test-" + suffix |
| 31 | |
| 32 | def run(*argv): |
| 33 | return subprocess.run(argv, check=True, capture_output=True, text=True).stdout |
| 34 | |
| 35 | def call(path, body=None, status=200): |
| 36 | req = urllib.request.Request("http://127.0.0.1:7075" + path, |
| 37 | data=json.dumps(body).encode() if body is not None else None, |
| 38 | headers={"User-Name": "fixture", "User-Groups": "infra-admin", "Studio-Proxy-Token": proof, "Content-Type": "application/json"}) |
| 39 | try: |
| 40 | response = urllib.request.urlopen(req, timeout=30) |
| 41 | except urllib.error.HTTPError as error: |
| 42 | response = error |
| 43 | with response: |
| 44 | payload = response.read() |
| 45 | assert response.status == status, (path, response.status, payload[:500]) |
| 46 | return json.loads(payload) if payload and response.headers.get("content-type", "").startswith("application/json") else payload |
| 47 | |
| 48 | def ready(): |
| 49 | deadline = time.monotonic() + 30 |
| 50 | while time.monotonic() < deadline: |
| 51 | try: |
| 52 | call("/api/me") |
| 53 | return |
| 54 | except OSError: |
| 55 | time.sleep(.1) |
| 56 | raise RuntimeError("file fixture did not start") |
| 57 | |
| 58 | def metadata(name): |
| 59 | file = data / "index" / (urllib.parse.quote_plus(name, safe="") + ".db") |
| 60 | with sqlite3.connect(file.as_uri() + "?mode=ro", uri=True) as db: |
| 61 | return db.execute("SELECT snapshot,scanned,updated FROM meta").fetchone() |
| 62 | |
| 63 | def indexed(size, files, previous=None): |
| 64 | deadline = time.monotonic() + 60 |
| 65 | while time.monotonic() < deadline: |
| 66 | tree = call("/api/storage/files/map?width=1000&height=1000") |
| 67 | try: |
| 68 | meta = {name: metadata(name) for name in [dataset, dataset + "/media", dataset + "/docs"]} |
| 69 | except sqlite3.OperationalError: |
| 70 | meta = {} |
| 71 | if (not tree["scanning"] and tree["tree"] and tree["tree"][1:3] == [size, files] |
| 72 | and len(meta) == 3 and all(meta.values()) |
| 73 | and (previous is None or all(meta[name][0] != previous[name][0] for name in meta))): |
| 74 | return meta |
| 75 | time.sleep(.1) |
| 76 | raise AssertionError((tree, meta)) |
| 77 | |
| 78 | with tempfile.TemporaryDirectory(prefix="studio-file-boundary-", dir="/run") as temporary: |
| 79 | os.chown(temporary, 65534, 65534) |
| 80 | data = Path(temporary) / "data" |
| 81 | data.mkdir() |
| 82 | os.chown(data, 65534, 65534) |
| 83 | proxy_token = Path(temporary) / "proxy.token" |
| 84 | proxy_token.write_text(proof) |
| 85 | os.chown(proxy_token, 0, 65534) |
| 86 | proxy_token.chmod(0o440) |
| 87 | try: |
| 88 | for name, path in [(dataset, mount), (dataset + "/media", mount / "media"), (dataset + "/docs", mount / "docs"), (dataset + "/private", private)]: |
| 89 | run("zfs", "create", "-o", "mountpoint=" + str(path), name) |
| 90 | os.chown(path, 65534, 65534) |
| 91 | for relative, content in [("media/a.txt", "media fixture"), ("docs/notes", "document fixture")]: |
| 92 | target = mount / relative |
| 93 | target.write_text(content) |
| 94 | os.chown(target, 65534, 65534) |
| 95 | (private / "secret").write_text("unmounted fixture") |
| 96 | run("zfs", "snapshot", dataset + "@manual") |
| 97 | run("zfs", "snapshot", dataset + "@index-fixture") |
| 98 | launch = ["podman", "run", "-d", "--name=" + container, "--pull=never", "--user=65534:65534", |
| 99 | "--read-only", "--cap-drop=all", "--security-opt=no-new-privileges", "--pids-limit=128", |
| 100 | "--memory=512m", "--cpus=2", "--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=64m", |
| 101 | "--publish=127.0.0.1:7075:7072", "--volume=" + str(args.socket.parent) + ":/run/studio-host:ro", |
| 102 | "--volume=" + str(proxy_token) + ":/run/secrets/dashboard-proxy.token:ro", |
| 103 | "--volume=" + str(data) + ":/data:rw,nosuid,nodev", |
| 104 | "--env=STUDIO_DATA_DIR=/data", "--env=STUDIO_FILES_WRITABLE=1"] |
| 105 | run(*launch, |
| 106 | "--mount=type=bind,src=" + str(mount) + ",dst=" + str(mount) + ",bind-nonrecursive,bind-propagation=rslave", |
| 107 | "--mount=type=bind,src=" + str(mount / "media") + ",dst=" + str(mount / "media") + ",bind-nonrecursive,bind-propagation=rslave", |
| 108 | "--mount=type=bind,src=" + str(mount / "docs") + ",dst=" + str(mount / "docs") + ",bind-nonrecursive,bind-propagation=rslave", |
| 109 | "--env=STUDIO_STORE_ROOT=" + str(mount), |
| 110 | "--env=STUDIO_MEDIA_ROOT=" + str(mount / "media"), |
| 111 | "--env=STUDIO_INDEX_POOL=" + dataset, "--env=STUDIO_INDEX_DIR=/data/index", args.image) |
| 112 | ready() |
| 113 | baseline_size = len("media fixture") + len("document fixture") |
| 114 | baseline = indexed(baseline_size, 2) |
| 115 | assert subprocess.run(["podman", "exec", container, "/bin/test", "-e", str(private / "secret")], capture_output=True).returncode != 0 |
| 116 | private_db = data / "index" / (urllib.parse.quote_plus(dataset + "/private", safe="") + ".db") |
| 117 | assert not private_db.exists() |
| 118 | assert not run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-d", "1", dataset + "/private").strip() |
| 119 | shutil.copyfile(data / "index" / (urllib.parse.quote_plus(dataset + "/media", safe="") + ".db"), private_db) |
| 120 | os.chown(private_db, 65534, 65534) |
| 121 | largest = call("/api/storage/files/largest") |
| 122 | assert {item["path"]: item["size"] for item in largest} == {"media/a.txt": len("media fixture"), "docs/notes": len("document fixture")}, largest |
| 123 | (mount / "media/a.txt").rename(mount / "media/renamed") |
| 124 | (mount / "docs/notes").write_text("updated document fixture") |
| 125 | run("podman", "stop", "--time=8", container) |
| 126 | run("podman", "start", container) |
| 127 | ready() |
| 128 | updated = indexed(len("media fixture") + len("updated document fixture"), 2, baseline) |
| 129 | assert all(updated[name][1] == baseline[name][1] for name in baseline), (baseline, updated) |
| 130 | assert {item["path"] for item in call("/api/storage/files/largest")} == {"media/renamed", "docs/notes"} |
| 131 | assert private_db.exists() |
| 132 | assert not run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-d", "1", dataset + "/private").strip() |
| 133 | print("unmounted dataset and previous catalog isolation passed", flush=True) |
| 134 | (mount / "media/renamed").rename(mount / "media/a.txt") |
| 135 | (mount / "docs/notes").write_text("document fixture") |
| 136 | run("podman", "stop", "--time=8", container) |
| 137 | run("podman", "start", container) |
| 138 | ready() |
| 139 | baseline = indexed(baseline_size, 2, updated) |
| 140 | print("fresh and incremental snapshot indexing passed", flush=True) |
| 141 | |
| 142 | media_db = data / "index" / (urllib.parse.quote_plus(dataset + "/media", safe="") + ".db") |
| 143 | with sqlite3.connect(media_db) as db: |
| 144 | db.execute("UPDATE meta SET scanned=0") |
| 145 | locked = mount / "media/locked" |
| 146 | locked.mkdir(mode=0o700) |
| 147 | (locked / "file").write_text("x") |
| 148 | run("podman", "stop", "--time=8", container) |
| 149 | run("podman", "start", container) |
| 150 | ready() |
| 151 | deadline = time.monotonic() + 30 |
| 152 | while time.monotonic() < deadline: |
| 153 | logs = subprocess.run(["podman", "logs", container], check=True, capture_output=True, text=True) |
| 154 | if "Permission denied" in logs.stdout + logs.stderr and not call("/api/storage/files/map?width=1000&height=1000")["scanning"]: |
| 155 | break |
| 156 | time.sleep(.1) |
| 157 | else: |
| 158 | raise AssertionError("unreadable snapshot was accepted") |
| 159 | assert metadata(dataset + "/media")[0] == baseline[dataset + "/media"][0] |
| 160 | assert call("/api/storage/files/map?width=1000&height=1000")["tree"][1:3] == [baseline_size, 2] |
| 161 | locked.chmod(0o755) |
| 162 | run("podman", "stop", "--time=8", container) |
| 163 | run("podman", "start", container) |
| 164 | ready() |
| 165 | recovered = indexed(baseline_size + 1, 3, baseline) |
| 166 | assert recovered[dataset + "/media"][1] > 0, recovered |
| 167 | print("unreadable snapshot recovery passed", flush=True) |
| 168 | |
| 169 | bulk = mount / "media/bulk" |
| 170 | bulk.mkdir() |
| 171 | for i in range(20000): |
| 172 | (bulk / str(i)).write_bytes(b"x") |
| 173 | with sqlite3.connect(media_db) as db: |
| 174 | db.execute("UPDATE meta SET scanned=0") |
| 175 | run("podman", "stop", "--time=8", container) |
| 176 | run("podman", "start", container) |
| 177 | deadline = time.monotonic() + 30 |
| 178 | while time.monotonic() < deadline: |
| 179 | snapshots = run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-d", "1", dataset + "/media").splitlines() |
| 180 | if any(name != recovered[dataset + "/media"][0] for name in snapshots): |
| 181 | run("podman", "kill", "--signal=KILL", container) |
| 182 | break |
| 183 | time.sleep(.05) |
| 184 | else: |
| 185 | raise AssertionError("scan did not start") |
| 186 | assert metadata(dataset + "/media")[0] == recovered[dataset + "/media"][0], "scan finished before cancellation" |
| 187 | run("podman", "start", container) |
| 188 | ready() |
| 189 | recovered = indexed(baseline_size + 20001, 20003, recovered) |
| 190 | assert media_db.stat().st_uid == 65534 |
| 191 | print("cancelled scan recovery passed", flush=True) |
| 192 | |
| 193 | run("podman", "stop", "--time=8", container) |
| 194 | run(sys.executable, "-c", "import os,sqlite3,sys; os.chdir(sys.argv[1]); os.setgroups([]); os.setgid(65534); os.setuid(65534); db=sqlite3.connect(sys.argv[2]); db.executescript('PRAGMA cache_size=1; BEGIN IMMEDIATE; UPDATE file SET size=99;'); os._exit(0)", str(media_db.parent), media_db.name) |
| 195 | journal = media_db.with_suffix(".db-journal") |
| 196 | assert journal.stat().st_uid == 65534 and any(journal.read_bytes()[:8]) |
| 197 | run("podman", "start", container) |
| 198 | ready() |
| 199 | recovered = indexed(baseline_size + 20001, 20003, recovered) |
| 200 | assert not journal.exists() |
| 201 | print("interrupted incremental transaction recovery passed", flush=True) |
| 202 | shutil.rmtree(bulk) |
| 203 | shutil.rmtree(locked) |
| 204 | run("podman", "stop", "--time=8", container) |
| 205 | run("podman", "start", container) |
| 206 | ready() |
| 207 | baseline = indexed(baseline_size, 2, recovered) |
| 208 | remaining = run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset).splitlines() |
| 209 | assert set(remaining) == {dataset + "@manual", dataset + "@index-fixture", *(meta[0] for meta in baseline.values())}, remaining |
| 210 | |
| 211 | assert call("/api/media/peek?path=a.txt")["text"] == "media fixture" |
| 212 | call("/api/media/list?path=../docs", status=403) |
| 213 | (mount / "media/escape").symlink_to(mount / "docs", target_is_directory=True) |
| 214 | call("/api/media/list?path=escape", status=403) |
| 215 | call("/api/storage/files/delete", {"paths": ["media", "docs"]}, 409) |
| 216 | deleted = call("/api/storage/files/delete", {"paths": ["media/a.txt", "docs/notes"]}) |
| 217 | assert not (mount / "media/a.txt").exists() and not (mount / "docs/notes").exists() |
| 218 | run("podman", "stop", "--time=8", container) |
| 219 | run("podman", "start", container) |
| 220 | ready() |
| 221 | assert call("/api/storage/files/ops")[0]["id"] == deleted["id"] |
| 222 | call("/api/storage/files/ops/" + deleted["id"] + "/undo", {}, 204) |
| 223 | assert (mount / "media/a.txt").read_text() == "media fixture" |
| 224 | assert (mount / "docs/notes").read_text() == "document fixture" |
| 225 | (mount / "media/escape").unlink() |
| 226 | run("podman", "stop", "--time=8", container) |
| 227 | run("podman", "start", container) |
| 228 | ready() |
| 229 | baseline = indexed(baseline_size, 2) |
| 230 | remaining = run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset).splitlines() |
| 231 | assert set(remaining) == {dataset + "@manual", dataset + "@index-fixture", *(meta[0] for meta in baseline.values())}, remaining |
| 232 | call("/api/storage/destroy", {"dataset": dataset, "from": "manual", "to": "manual"}, 204) |
| 233 | remaining = run("zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset).splitlines() |
| 234 | assert set(remaining) == {dataset + "@index-fixture", *(meta[0] for meta in baseline.values())}, remaining |
| 235 | status = run("podman", "exec", container, "/bin/cat", "/proc/1/status") |
| 236 | assert "Uid:\t65534\t65534\t65534\t65534" in status, status |
| 237 | assert "CapEff:\t0000000000000000" in status, status |
| 238 | run("podman", "rm", "--force", container) |
| 239 | partial = private / "data" |
| 240 | partial.mkdir() |
| 241 | os.chown(partial, 65534, 65534) |
| 242 | (partial / "fixture").write_text("partial mount fixture") |
| 243 | os.chown(partial / "fixture", 65534, 65534) |
| 244 | run(*launch, "--volume=" + str(partial) + ":" + str(partial) + ":rw,nosuid,nodev", |
| 245 | "--env=STUDIO_STORE_ROOT=" + str(private), args.image) |
| 246 | ready() |
| 247 | call("/api/storage/files/delete", {"paths": ["data/fixture"]}, 409) |
| 248 | assert (partial / "fixture").read_text() == "partial mount fixture" |
| 249 | result = {"image": args.image, "container_multi_dataset_delete_undo": "passed", "journal_survives_restart": "passed", |
| 250 | "undo_pruning_preserves_other_snapshots": "passed", "container_snapshot_deletion": "passed", |
| 251 | "file_path_and_dataset_confinement": "passed", "nonroot_file_operations": "passed", |
| 252 | "read_only_snapshot_index": "passed", "incremental_index_survives_restart": "passed", |
| 253 | "unreadable_snapshot_preserves_last_good_index": "passed", "cancelled_scan_recovers": "passed", |
| 254 | "interrupted_incremental_transaction_recovers": "passed", |
| 255 | "index_pruning_preserves_other_snapshots": "passed", "nonroot_index_database": "passed", |
| 256 | "unmounted_dataset_is_not_snapshotted": "passed", "unmounted_previous_catalog_is_not_served": "passed", |
| 257 | "partial_mount_delete_preserves_file": "passed"} |
| 258 | if args.output: |
| 259 | args.output.write_text(json.dumps(result, indent=2) + "\n") |
| 260 | print(json.dumps(result)) |
| 261 | except BaseException as error: |
| 262 | if isinstance(error, subprocess.CalledProcessError): |
| 263 | print(error.stderr) |
| 264 | print(subprocess.run(["podman", "logs", "--tail=15", container], capture_output=True, text=True).stderr) |
| 265 | raise |
| 266 | finally: |
| 267 | subprocess.run(["podman", "rm", "--force", container], capture_output=True) |
| 268 | run("zpool", "sync", args.pool) |
| 269 | deadline = time.monotonic() + 30 |
| 270 | while True: |
| 271 | cleanup = subprocess.run(["zfs", "destroy", "-r", dataset], capture_output=True, text=True) |
| 272 | if cleanup.returncode == 0: |
| 273 | break |
| 274 | if time.monotonic() >= deadline or "dataset is busy" not in cleanup.stderr: |
| 275 | raise RuntimeError(cleanup.stderr) |
| 276 | time.sleep(.5) |
| 277 | |
| 278 | |
| 279 | if __name__ == "__main__": |
| 280 | main() |