1#!/usr/bin/env python3
2import argparse
3import json
4import os
5from pathlib import Path
6import shutil
7import sqlite3
8import subprocess
9import sys
10import tempfile
11import time
12import urllib.error
13import urllib.parse
14import urllib.request
15import uuid
16
17
18def 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
279if __name__ == "__main__":
280 main()