1#!/usr/bin/env python3
2import importlib.util
3import json
4from pathlib import Path
5import sys
6import subprocess
7import os
8import socket
9import struct
10import threading
11import unittest
12from unittest.mock import patch
13
14
15spec = importlib.util.spec_from_file_location("dashboard_host", Path(__file__).with_name("dashboard-host.py"))
16host_module = importlib.util.module_from_spec(spec)
17spec.loader.exec_module(host_module)
18
19
20class Boundary(unittest.TestCase):
21 def setUp(self):
22 self.host = host_module.Host("studio-demo")
23
24 @unittest.skipUnless(sys.platform == "linux", "Linux Unix peer credentials")
25 def test_large_json_header_preserves_stream_bytes_and_enforces_limit(self):
26 def exchange(payload, expected=None):
27 client, server = socket.socketpair()
28 client.settimeout(5)
29 handler = unittest.mock.Mock()
30 handler.handle.return_value = True
31 def stream_console(connection, host, request):
32 self.assertEqual(connection.recv(len(expected)), expected)
33 host_module.send_response(connection, {"value": True})
34 with patch.object(host_module, "stream_console", side_effect=stream_console):
35 worker = threading.Thread(target=host_module.serve_connection, args=(server, handler, os.getuid()), daemon=True)
36 worker.start()
37 with client:
38 client.sendall(payload)
39 length = struct.unpack('>I', client.recv(4))[0]
40 response = bytearray()
41 while len(response) < length:
42 response.extend(client.recv(length - len(response)))
43 worker.join(timeout=5)
44 self.assertFalse(worker.is_alive())
45 return json.loads(response), handler
46 message = {"operation": "shale.write", "arguments": {"comment": "\x01" * 240000}}
47 response, handler = exchange(json.dumps(message).encode() + b"\n")
48 self.assertEqual(response, {"value": True})
49 handler.handle.assert_called_once_with(message)
50 tail = b"stream bytes\nnot JSON"
51 response, handler = exchange(b'{"operation":"vm.serial"}\n' + tail, tail)
52 self.assertEqual(response, {"value": True})
53 handler.handle.assert_not_called()
54 response, handler = exchange(b'x' * (host_module.MAX_REQUEST + 1))
55 self.assertEqual(response['status'], 400)
56 handler.handle.assert_not_called()
57
58 def test_rejects_before_executing(self):
59 requests = [None, [], {}, {"operation": []},
60 {"operation": "command", "argv": ["sh", "-c", "id"]},
61 {"operation": "storage.datasets", "pool": "other"},
62 {"operation": "storage.datasets", "env": {"PATH": "/tmp"}},
63 {"operation": "host.sample", "path": "/root/private"},
64 {"operation": "host.usage", "refresh": True, "pid": 1},
65 {"operation": "deploy.managed", "path": "/var/lib/studio/nomad.token"},
66 {"operation": "storage.snapshots"}]
67 for refresh in [None, [], {}, 0, 1, "true"]:
68 requests.append({"operation": "host.usage", "refresh": refresh})
69 for dataset in [None, [], "other", "studio-demo-other", "studio-demo/../other",
70 "studio-demo//child", "studio-demo/child\nother", "-r", "studio-demo/a@snap"]:
71 requests.append({"operation": "storage.snapshots", "dataset": dataset})
72 for snapshot in [None, [], "a%b", "a,b", "a@b", "a\nb", "../other", "a;id"]:
73 requests.append({"operation": "storage.reclaim", "dataset": "studio-demo/a",
74 "from": snapshot, "to": "safe"})
75 with patch.object(host_module, "command") as command:
76 for request in requests:
77 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
78 self.host.handle(request)
79 command.assert_not_called()
80
81 def test_reclaim_is_always_a_dry_run(self):
82 with patch.object(host_module, "command", return_value="reclaim\t1024") as command:
83 self.host.handle({"operation": "storage.reclaim", "dataset": "studio-demo/a",
84 "from": "snapshot one", "to": "snapshot two"})
85 command.assert_called_once_with("zfs", "destroy", "-nvp",
86 "studio-demo/a@snapshot one%snapshot two")
87
88 def test_mounts_exclude_other_pools(self):
89 mounts = {"filesystems": [{"source": source, "target": "/srv"}
90 for source in ["studio-demo", "studio-demo/a", "studio-demo-other/a", "other/a", "studio-demo/a@dash-123"]]}
91 with patch.object(host_module, "command", return_value=json.dumps(mounts)):
92 self.assertEqual([m["source"] for m in self.host.handle({"operation": "storage.mounts"})["filesystems"]],
93 ["studio-demo", "studio-demo/a"])
94
95 def test_snapshot_arguments_cannot_be_options(self):
96 with patch.object(host_module, "command", return_value="") as command:
97 self.host.handle({"operation": "storage.removed", "dataset": "studio-demo/a", "snapshot": "snapshot -r"})
98 command.assert_called_once_with("zfs", "diff", "-H", "studio-demo/a@snapshot -r", "studio-demo/a")
99
100 def test_snapshot_mutations_are_scoped(self):
101 requests = []
102 for datasets in [None, {}, [], [None], ["other/private"], ["studio-demo/../escape"], ["-r"]]:
103 requests.append({"operation": "files.snapshot", "datasets": datasets})
104 for snapshot in ["before", "dash-1%after", "dash-1,after", "dash-../other", "dash--r"]:
105 requests.append({"operation": "files.discard", "dataset": "studio-demo/a", "snapshot": snapshot})
106 for value in ["before%after", "-r", "before,after", "before@after"]:
107 requests.append({"operation": "storage.destroy", "dataset": "studio-demo/a", "from": value, "to": "after"})
108 requests.append({"operation": "files.snapshot", "datasets": ["studio-demo/a"], "recursive": True})
109 with patch.object(host_module, "command") as command:
110 for request in requests:
111 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
112 self.host.handle(request)
113 command.assert_not_called()
114
115 def test_file_snapshots_use_only_the_undo_namespace(self):
116 with patch.object(host_module.time, "time_ns", return_value=123), patch.object(host_module, "command", return_value="") as command:
117 name = self.host.handle({"operation": "files.snapshot", "datasets": ["studio-demo/b", "studio-demo/a", "studio-demo/a"]})
118 self.assertEqual(name, "dash-123")
119 command.assert_called_once_with("zfs", "snapshot", "studio-demo/a@dash-123", "studio-demo/b@dash-123")
120 with patch.object(host_module, "command", return_value="studio-demo/a@before\nstudio-demo/a@dash-123\nstudio-demo/a@dash-manual\n"):
121 self.assertEqual(self.host.handle({"operation": "files.snapshots", "datasets": ["studio-demo/a"]}), ["studio-demo/a@dash-123"])
122 with patch.object(host_module, "command", return_value="") as command:
123 self.host.handle({"operation": "files.discard", "dataset": "studio-demo/a", "snapshot": "dash-123"})
124 command.assert_called_once_with("zfs", "destroy", "studio-demo/a@dash-123")
125
126 def test_child_output_is_bounded(self):
127 with patch.object(host_module, "MAX_RESPONSE", 4096):
128 with self.assertRaisesRegex(RuntimeError, "too large"):
129 host_module.command(sys.executable, "-c", "import os; os.write(2, b'x' * 1000000)")
130
131 def test_index_operations_preserve_other_snapshots(self):
132 dataset = "studio-demo/a"
133 with patch.object(host_module, "command") as command:
134 for name in ["manual", "dash-123", "index-fixture", "index-123%manual", "index-../escape"]:
135 for request in [{"operation": "index.discard", "dataset": dataset, "snapshot": name},
136 {"operation": "index.diff", "dataset": dataset, "from": name, "to": "index-456"}]:
137 with self.subTest(request=request), self.assertRaises(host_module.Rejected):
138 self.host.handle(request)
139 command.assert_not_called()
140 with patch.object(host_module.time, "time_ns", return_value=123), patch.object(host_module, "command", return_value="") as command:
141 self.assertEqual(self.host.handle({"operation": "index.snapshot", "dataset": dataset}), dataset + "@index-123")
142 command.assert_called_once_with("zfs", "snapshot", dataset + "@index-123")
143 legacy = "index-123-12345678-1234-1234-1234-123456789abc"
144 with patch.object(host_module, "command", return_value="\n".join(dataset + "@" + name for name in ["manual", "index-123", legacy, "index-fixture"])):
145 self.assertEqual(self.host.handle({"operation": "index.snapshots", "dataset": dataset}),
146 [dataset + "@index-123", dataset + "@" + legacy])
147 with patch.object(host_module, "command", return_value="") as command:
148 self.host.handle({"operation": "index.diff", "dataset": dataset, "from": legacy, "to": "index-456"})
149 command.assert_called_once_with("zfs", "diff", "-FH", dataset + "@" + legacy, dataset + "@index-456")
150 with patch.object(host_module, "command", return_value="") as command:
151 self.host.handle({"operation": "index.discard", "dataset": dataset, "snapshot": legacy})
152 command.assert_called_once_with("zfs", "destroy", "-d", dataset + "@" + legacy)
153
154 def test_nonzero_exit_is_reported(self):
155 with self.assertRaises(subprocess.CalledProcessError) as failed:
156 host_module.command(sys.executable, "-c", "import sys; sys.stderr.write('fixture problem'); sys.exit(1)")
157 self.assertEqual(failed.exception.stderr, "fixture problem")
158
159 def test_child_deadline_is_enforced(self):
160 with self.assertRaises(TimeoutError):
161 host_module.command(sys.executable, "-c", "import time; time.sleep(10)", timeout=.05)
162
163 def test_vm_requests_reject_before_executing(self):
164 spec = {"mode": "iso", "firmware": "bios", "platform": "linux", "name": "fixture", "owner": "fixture-user", "description": "", "image": "blank", "vcpus": 1,
165 "memory": 2**29, "disk": 2**30, "autostart": False, "start": False}
166 with patch.object(host_module.vms, "node", return_value={"cpus": 8, "memory": 8 * 2**30}), patch.object(host_module, "command") as command:
167 for field, value in [("name", "../escape"), ("image", "/root/disk.img"), ("vcpus", True),
168 ("memory", 16 * 2**30), ("disk", 2**64), ("vcpus", 9), ("argv", ["sh"])]:
169 with self.subTest(field=field), self.assertRaises(host_module.Rejected):
170 self.host.handle({"operation": "vm.create", "payload": {**spec, field: value}})
171 for request in [{"operation": "vm.xml", "payload": {}}, {"operation": "vm.node", "payload": {}},
172 {"operation": "vm.act", "payload": {"name": "fixture", "action": []}},
173 {"operation": "vm.update", "payload": {"name": "fixture"}}]:
174 with self.assertRaises(host_module.Rejected):
175 self.host.handle(request)
176 command.assert_not_called()
177
178
179if __name__ == "__main__":
180 unittest.main()