1#!/usr/bin/env python3
2import importlib.util
3import json
4from pathlib import Path
5import sys
6import subprocess
7import unittest
8from unittest.mock import patch
9
10
11spec = importlib.util.spec_from_file_location("dashboard_host", Path(__file__).with_name("dashboard-host.py"))
12host_module = importlib.util.module_from_spec(spec)
13spec.loader.exec_module(host_module)
14
15
16class Boundary(unittest.TestCase):
17 def setUp(self):
18 self.host = host_module.Host("studio-demo")
19
20 def test_rejects_before_executing(self):
21 requests = [None, [], {}, {"operation": []},
22 {"operation": "command", "argv": ["sh", "-c", "id"]},
23 {"operation": "storage.datasets", "pool": "other"},
24 {"operation": "storage.datasets", "env": {"PATH": "/tmp"}},
25 {"operation": "host.sample", "path": "/root/private"},
26 {"operation": "host.usage", "refresh": True, "pid": 1},
27 {"operation": "deploy.managed", "path": "/var/lib/studio/nomad.token"},
28 {"operation": "storage.snapshots"}]
29 for refresh in [None, [], {}, 0, 1, "true"]:
30 requests.append({"operation": "host.usage", "refresh": refresh})
31 for dataset in [None, [], "other", "studio-demo-other", "studio-demo/../other",
32 "studio-demo//child", "studio-demo/child\nother", "-r", "studio-demo/a@snap"]:
33 requests.append({"operation": "storage.snapshots", "dataset": dataset})
34 for snapshot in [None, [], "a%b", "a,b", "a@b", "a\nb", "../other", "a;id"]:
35 requests.append({"operation": "storage.reclaim", "dataset": "studio-demo/a",
36 "from": snapshot, "to": "safe"})
37 with patch.object(host_module, "command") as command:
38 for request in requests:
39 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
40 self.host.handle(request)
41 command.assert_not_called()
42
43 def test_reclaim_is_always_a_dry_run(self):
44 with patch.object(host_module, "command", return_value="reclaim\t1024") as command:
45 self.host.handle({"operation": "storage.reclaim", "dataset": "studio-demo/a",
46 "from": "snapshot one", "to": "snapshot two"})
47 command.assert_called_once_with("zfs", "destroy", "-nvp",
48 "studio-demo/a@snapshot one%snapshot two")
49
50 def test_mounts_exclude_other_pools(self):
51 mounts = {"filesystems": [{"source": source, "target": "/srv"}
52 for source in ["studio-demo", "studio-demo/a", "studio-demo-other/a", "other/a", "studio-demo/a@dash-123"]]}
53 with patch.object(host_module, "command", return_value=json.dumps(mounts)):
54 self.assertEqual([m["source"] for m in self.host.handle({"operation": "storage.mounts"})["filesystems"]],
55 ["studio-demo", "studio-demo/a"])
56
57 def test_snapshot_arguments_cannot_be_options(self):
58 with patch.object(host_module, "command", return_value="") as command:
59 self.host.handle({"operation": "storage.removed", "dataset": "studio-demo/a", "snapshot": "snapshot -r"})
60 command.assert_called_once_with("zfs", "diff", "-H", "studio-demo/a@snapshot -r", "studio-demo/a")
61
62 def test_snapshot_mutations_are_scoped(self):
63 requests = []
64 for datasets in [None, {}, [], [None], ["other/private"], ["studio-demo/../escape"], ["-r"]]:
65 requests.append({"operation": "files.snapshot", "datasets": datasets})
66 for snapshot in ["before", "dash-1%after", "dash-1,after", "dash-../other", "dash--r"]:
67 requests.append({"operation": "files.discard", "dataset": "studio-demo/a", "snapshot": snapshot})
68 for value in ["before%after", "-r", "before,after", "before@after"]:
69 requests.append({"operation": "storage.destroy", "dataset": "studio-demo/a", "from": value, "to": "after"})
70 requests.append({"operation": "files.snapshot", "datasets": ["studio-demo/a"], "recursive": True})
71 with patch.object(host_module, "command") as command:
72 for request in requests:
73 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
74 self.host.handle(request)
75 command.assert_not_called()
76
77 def test_file_snapshots_use_only_the_undo_namespace(self):
78 with patch.object(host_module.time, "time_ns", return_value=123), patch.object(host_module, "command", return_value="") as command:
79 name = self.host.handle({"operation": "files.snapshot", "datasets": ["studio-demo/b", "studio-demo/a", "studio-demo/a"]})
80 self.assertEqual(name, "dash-123")
81 command.assert_called_once_with("zfs", "snapshot", "studio-demo/a@dash-123", "studio-demo/b@dash-123")
82 with patch.object(host_module, "command", return_value="studio-demo/a@before\nstudio-demo/a@dash-123\nstudio-demo/a@dash-manual\n"):
83 self.assertEqual(self.host.handle({"operation": "files.snapshots", "datasets": ["studio-demo/a"]}), ["studio-demo/a@dash-123"])
84 with patch.object(host_module, "command", return_value="") as command:
85 self.host.handle({"operation": "files.discard", "dataset": "studio-demo/a", "snapshot": "dash-123"})
86 command.assert_called_once_with("zfs", "destroy", "studio-demo/a@dash-123")
87
88 def test_child_output_is_bounded(self):
89 with patch.object(host_module, "MAX_RESPONSE", 4096):
90 with self.assertRaisesRegex(RuntimeError, "too large"):
91 host_module.command(sys.executable, "-c", "import os; os.write(2, b'x' * 1000000)")
92
93 def test_index_operations_preserve_other_snapshots(self):
94 dataset = "studio-demo/a"
95 with patch.object(host_module, "command") as command:
96 for name in ["manual", "dash-123", "index-fixture", "index-123%manual", "index-../escape"]:
97 for request in [{"operation": "index.discard", "dataset": dataset, "snapshot": name},
98 {"operation": "index.diff", "dataset": dataset, "from": name, "to": "index-456"}]:
99 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
100 self.host.handle(request)
101 command.assert_not_called()
102 with patch.object(host_module.time, "time_ns", return_value=123), patch.object(host_module, "command", return_value="") as command:
103 self.assertEqual(self.host.handle({"operation": "index.snapshot", "dataset": dataset}), dataset + "@index-123")
104 command.assert_called_once_with("zfs", "snapshot", dataset + "@index-123")
105 legacy = "index-123-12345678-1234-1234-1234-123456789abc"
106 with patch.object(host_module, "command", return_value="\n".join(dataset + "@" + name for name in ["manual", "index-123", legacy, "index-fixture"])):
107 self.assertEqual(self.host.handle({"operation": "index.snapshots", "dataset": dataset}),
108 [dataset + "@index-123", dataset + "@" + legacy])
109 with patch.object(host_module, "command", return_value="") as command:
110 self.host.handle({"operation": "index.diff", "dataset": dataset, "from": legacy, "to": "index-456"})
111 command.assert_called_once_with("zfs", "diff", "-FH", dataset + "@" + legacy, dataset + "@index-456")
112 with patch.object(host_module, "command", return_value="") as command:
113 self.host.handle({"operation": "index.discard", "dataset": dataset, "snapshot": legacy})
114 command.assert_called_once_with("zfs", "destroy", "-d", dataset + "@" + legacy)
115
116 def test_nonzero_exit_is_reported(self):
117 with self.assertRaises(subprocess.CalledProcessError) as failed:
118 host_module.command(sys.executable, "-c", "import sys; sys.stderr.write('fixture problem'); sys.exit(1)")
119 self.assertEqual(failed.exception.stderr, "fixture problem")
120
121 def test_child_deadline_is_enforced(self):
122 with self.assertRaises(TimeoutError):
123 host_module.command(sys.executable, "-c", "import time; time.sleep(10)", timeout=.05)
124
125 def test_vm_requests_reject_before_executing(self):
126 spec = {"mode": "iso", "firmware": "bios", "platform": "linux", "name": "fixture", "owner": "fixture-user", "description": "", "image": "blank", "vcpus": 1,
127 "memory": 2**29, "disk": 2**30, "autostart": False, "start": False}
128 with patch.object(host_module.vms, "node", return_value={"cpus": 8, "memory": 8 * 2**30}), patch.object(host_module, "command") as command:
129 for field, value in [("name", "../escape"), ("image", "/root/disk.img"), ("vcpus", True),
130 ("memory", 16 * 2**30), ("disk", 2**64), ("vcpus", 9), ("argv", ["sh"])]:
131 with self.subTest(field=field), self.assertRaises(host_module.Rejected):
132 self.host.handle({"operation": "vm.create", "payload": {**spec, field: value}})
133 for request in [{"operation": "vm.xml", "payload": {}}, {"operation": "vm.node", "payload": {}},
134 {"operation": "vm.act", "payload": {"name": "fixture", "action": []}},
135 {"operation": "vm.update", "payload": {"name": "fixture"}}]:
136 with self.assertRaises(host_module.Rejected):
137 self.host.handle(request)
138 command.assert_not_called()
139
140
141if __name__ == "__main__":
142 unittest.main()