test(resilience): run harness Python tests and cover the SSH wrapper
make test runs the Python unit tests; cover readiness bounds, port rewrite and passthrough, a UDP route opened during a blackout, a longer build timeout, and a negative control that requires the compositor to keep capturing.
- commit
- 1e5d59ca5420b81068f07d99797d5a8f05c5908e
- author
- brice · · 2026-10-07 07:01 UTC
- parent
- 0d9258e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +80 -3
M Makefile +1 -0
| @@ -9,6 +9,7 @@ install: | |||
| 9 | 9 | ||
| 10 | 10 | test: | |
| 11 | 11 | go test ./... -race | |
| 12 | + | python3 -m unittest discover -s scripts/remote-resilience-harness -p '*_test.py' | |
| 12 | 13 | ||
| 13 | 14 | test-installer: | |
| 14 | 15 | sh scripts/install_platform_test.sh | |
| @@ -9,6 +9,7 @@ install: | |||||
| 9 | 9 | ||||
| 10 | test: | 10 | test: | ||
| 11 | go test ./... -race | 11 | go test ./... -race | ||
| 12 | + | python3 -m unittest discover -s scripts/remote-resilience-harness -p '*_test.py' | |||
| 12 | 13 | ||||
| 13 | test-installer: | 14 | test-installer: | ||
| 14 | sh scripts/install_platform_test.sh | 15 | sh scripts/install_platform_test.sh | ||
M scripts/remote-resilience-harness/relay/main_test.go +31 -0
| @@ -106,6 +106,37 @@ func TestTCPRelayBlackoutHoldsBytes(t *testing.T) { | |||
| 106 | 106 | <-done | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | + | // TestRelayUDPProxyInheritsActiveBlackout opens a UDP route while a blackout | |
| 110 | + | // is active and proves it drops datagrams until the blackout ends. | |
| 111 | + | func TestRelayUDPProxyInheritsActiveBlackout(t *testing.T) { | |
| 112 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 113 | + | defer cancel() | |
| 114 | + | r := &relay{ctx: ctx, cancel: cancel, remote: "127.0.0.1"} | |
| 115 | + | defer r.close() | |
| 116 | + | upstream, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | defer upstream.Close() | |
| 119 | + | _, err = r.apply(command{Op: "blackout", UDP: true, Active: true}) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | port, err := r.apply(command{Op: "udp", Port: upstream.LocalAddr().(*net.UDPAddr).Port}) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | client, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port.(int)}) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | defer client.Close() | |
| 126 | + | _, err = client.Write([]byte("blocked")) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | require.Eventually(t, func() bool { return r.proxies[0].Stats().ToServer.BlackoutDrops == 1 }, time.Second, time.Millisecond) | |
| 129 | + | _, err = r.apply(command{Op: "blackout", UDP: true, Active: false}) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | _, err = client.Write([]byte("open")) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | require.NoError(t, upstream.SetReadDeadline(time.Now().Add(time.Second))) | |
| 134 | + | data := make([]byte, 16) | |
| 135 | + | n, _, err := upstream.ReadFromUDP(data) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | require.Equal(t, "open", string(data[:n])) | |
| 138 | + | } | |
| 139 | + | ||
| 109 | 140 | func TestRelayDisconnectClosesConnections(t *testing.T) { | |
| 110 | 141 | ctx, cancel := context.WithCancel(t.Context()) | |
| 111 | 142 | defer cancel() | |
| @@ -106,6 +106,37 @@ func TestTCPRelayBlackoutHoldsBytes(t *testing.T) { | |||||
| 106 | <-done | 106 | <-done | ||
| 107 | } | 107 | } | ||
| 108 | 108 | ||||
| 109 | + | // TestRelayUDPProxyInheritsActiveBlackout opens a UDP route while a blackout | |||
| 110 | + | // is active and proves it drops datagrams until the blackout ends. | |||
| 111 | + | func TestRelayUDPProxyInheritsActiveBlackout(t *testing.T) { | |||
| 112 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 113 | + | defer cancel() | |||
| 114 | + | r := &relay{ctx: ctx, cancel: cancel, remote: "127.0.0.1"} | |||
| 115 | + | defer r.close() | |||
| 116 | + | upstream, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | defer upstream.Close() | |||
| 119 | + | _, err = r.apply(command{Op: "blackout", UDP: true, Active: true}) | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | port, err := r.apply(command{Op: "udp", Port: upstream.LocalAddr().(*net.UDPAddr).Port}) | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | client, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port.(int)}) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | defer client.Close() | |||
| 126 | + | _, err = client.Write([]byte("blocked")) | |||
| 127 | + | require.NoError(t, err) | |||
| 128 | + | require.Eventually(t, func() bool { return r.proxies[0].Stats().ToServer.BlackoutDrops == 1 }, time.Second, time.Millisecond) | |||
| 129 | + | _, err = r.apply(command{Op: "blackout", UDP: true, Active: false}) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | _, err = client.Write([]byte("open")) | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | require.NoError(t, upstream.SetReadDeadline(time.Now().Add(time.Second))) | |||
| 134 | + | data := make([]byte, 16) | |||
| 135 | + | n, _, err := upstream.ReadFromUDP(data) | |||
| 136 | + | require.NoError(t, err) | |||
| 137 | + | require.Equal(t, "open", string(data[:n])) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 109 | func TestRelayDisconnectClosesConnections(t *testing.T) { | 140 | func TestRelayDisconnectClosesConnections(t *testing.T) { | ||
| 110 | ctx, cancel := context.WithCancel(t.Context()) | 141 | ctx, cancel := context.WithCancel(t.Context()) | ||
| 111 | defer cancel() | 142 | defer cancel() | ||
M scripts/remote-resilience-harness/ssh_test.py +37 -0
| @@ -26,5 +26,42 @@ class ControlTests(unittest.TestCase): | |||
| 26 | 26 | self.assertEqual(wrapper.control({"id": 1, "op": "udp", "port": 1234}), 4321) | |
| 27 | 27 | ||
| 28 | 28 | ||
| 29 | + | class MainTests(unittest.TestCase): | |
| 30 | + | BOOTSTRAP = ["-T", "--", "remote", "'vev' '_broker-mux-quic-bootstrap'"] | |
| 31 | + | ||
| 32 | + | def run_main(self, readiness, rewritten=4321): | |
| 33 | + | process = MagicMock() | |
| 34 | + | process.stdout.readline.return_value = readiness | |
| 35 | + | process.stdout.read1.return_value = b"" | |
| 36 | + | process.wait.return_value = 0 | |
| 37 | + | process.poll.return_value = 0 | |
| 38 | + | out = MagicMock() | |
| 39 | + | with patch.object(wrapper.sys, "argv", ["ssh"] + self.BOOTSTRAP), \ | |
| 40 | + | patch.object(wrapper.subprocess, "Popen", return_value=process), \ | |
| 41 | + | patch.object(wrapper, "control", return_value=rewritten) as control, \ | |
| 42 | + | patch.object(wrapper.sys, "stdout", MagicMock(buffer=out)): | |
| 43 | + | code = wrapper.main() | |
| 44 | + | return code, control, out | |
| 45 | + | ||
| 46 | + | def test_rewrites_only_the_port(self): | |
| 47 | + | code, control, out = self.run_main(b'{"port":1234,"fingerprint":"f"}\n') | |
| 48 | + | self.assertEqual(code, 0) | |
| 49 | + | control.assert_called_once_with({"id": 1, "op": "udp", "port": 1234}) | |
| 50 | + | self.assertEqual(out.write.call_args_list[0].args[0], b'{"port":4321,"fingerprint":"f"}\n') | |
| 51 | + | ||
| 52 | + | def test_rejects_invalid_readiness(self): | |
| 53 | + | for line in (b'{"port":1}', b"x" * 4097 + b"\n", b'{"port":0}\n', b'{"port":65536}\n', b'{"port":"22"}\n'): | |
| 54 | + | with self.subTest(line=line[:20]): | |
| 55 | + | with self.assertRaises(RuntimeError): | |
| 56 | + | self.run_main(line) | |
| 57 | + | ||
| 58 | + | def test_other_commands_exec_ssh_unchanged(self): | |
| 59 | + | with patch.object(wrapper.sys, "argv", ["ssh", "remote", "true"]), \ | |
| 60 | + | patch.object(wrapper.os, "execv", side_effect=SystemExit(0)) as execv: | |
| 61 | + | with self.assertRaises(SystemExit): | |
| 62 | + | wrapper.main() | |
| 63 | + | execv.assert_called_once_with("/usr/bin/ssh-real", ["/usr/bin/ssh-real", "-p", "2222", "-o", "HostKeyAlias=remote", "remote", "true"]) | |
| 64 | + | ||
| 65 | + | ||
| 29 | 66 | if __name__ == "__main__": | |
| 30 | 67 | unittest.main() | |
| @@ -26,5 +26,42 @@ class ControlTests(unittest.TestCase): | |||||
| 26 | self.assertEqual(wrapper.control({"id": 1, "op": "udp", "port": 1234}), 4321) | 26 | self.assertEqual(wrapper.control({"id": 1, "op": "udp", "port": 1234}), 4321) | ||
| 27 | 27 | ||||
| 28 | 28 | ||||
| 29 | + | class MainTests(unittest.TestCase): | |||
| 30 | + | BOOTSTRAP = ["-T", "--", "remote", "'vev' '_broker-mux-quic-bootstrap'"] | |||
| 31 | + | ||||
| 32 | + | def run_main(self, readiness, rewritten=4321): | |||
| 33 | + | process = MagicMock() | |||
| 34 | + | process.stdout.readline.return_value = readiness | |||
| 35 | + | process.stdout.read1.return_value = b"" | |||
| 36 | + | process.wait.return_value = 0 | |||
| 37 | + | process.poll.return_value = 0 | |||
| 38 | + | out = MagicMock() | |||
| 39 | + | with patch.object(wrapper.sys, "argv", ["ssh"] + self.BOOTSTRAP), \ | |||
| 40 | + | patch.object(wrapper.subprocess, "Popen", return_value=process), \ | |||
| 41 | + | patch.object(wrapper, "control", return_value=rewritten) as control, \ | |||
| 42 | + | patch.object(wrapper.sys, "stdout", MagicMock(buffer=out)): | |||
| 43 | + | code = wrapper.main() | |||
| 44 | + | return code, control, out | |||
| 45 | + | ||||
| 46 | + | def test_rewrites_only_the_port(self): | |||
| 47 | + | code, control, out = self.run_main(b'{"port":1234,"fingerprint":"f"}\n') | |||
| 48 | + | self.assertEqual(code, 0) | |||
| 49 | + | control.assert_called_once_with({"id": 1, "op": "udp", "port": 1234}) | |||
| 50 | + | self.assertEqual(out.write.call_args_list[0].args[0], b'{"port":4321,"fingerprint":"f"}\n') | |||
| 51 | + | ||||
| 52 | + | def test_rejects_invalid_readiness(self): | |||
| 53 | + | for line in (b'{"port":1}', b"x" * 4097 + b"\n", b'{"port":0}\n', b'{"port":65536}\n', b'{"port":"22"}\n'): | |||
| 54 | + | with self.subTest(line=line[:20]): | |||
| 55 | + | with self.assertRaises(RuntimeError): | |||
| 56 | + | self.run_main(line) | |||
| 57 | + | ||||
| 58 | + | def test_other_commands_exec_ssh_unchanged(self): | |||
| 59 | + | with patch.object(wrapper.sys, "argv", ["ssh", "remote", "true"]), \ | |||
| 60 | + | patch.object(wrapper.os, "execv", side_effect=SystemExit(0)) as execv: | |||
| 61 | + | with self.assertRaises(SystemExit): | |||
| 62 | + | wrapper.main() | |||
| 63 | + | execv.assert_called_once_with("/usr/bin/ssh-real", ["/usr/bin/ssh-real", "-p", "2222", "-o", "HostKeyAlias=remote", "remote", "true"]) | |||
| 64 | + | ||||
| 65 | + | ||||
| 29 | if __name__ == "__main__": | 66 | if __name__ == "__main__": | ||
| 30 | unittest.main() | 67 | unittest.main() | ||
M scripts/remote-resilience-harness/visual.py +11 -3
| @@ -39,15 +39,15 @@ def main(): | |||
| 39 | 39 | for tool in ("docker", "foot", "ssh-keygen", "go"): | |
| 40 | 40 | if shutil.which(tool) is None: | |
| 41 | 41 | raise RuntimeError("missing prerequisite: " + tool) | |
| 42 | - | run("go", "build", "-o", str(ROOT / "build/vev"), ".", cwd=ROOT) | |
| 42 | + | run("go", "build", "-o", str(ROOT / "build/vev"), ".", cwd=ROOT, timeout=600) | |
| 43 | 43 | for helper in ("relay", "pixels"): | |
| 44 | - | run("go", "build", "-o", str(ROOT / f"build/resilience-{helper}"), f"./scripts/remote-resilience-harness/{helper}", cwd=ROOT) | |
| 44 | + | run("go", "build", "-o", str(ROOT / f"build/resilience-{helper}"), f"./scripts/remote-resilience-harness/{helper}", cwd=ROOT, timeout=600) | |
| 45 | 45 | nefer = os.environ.get("NEFERWL_BIN") | |
| 46 | 46 | if not nefer: | |
| 47 | 47 | nefer = str(private / "neferwl") | |
| 48 | 48 | common = run("git", "rev-parse", "--path-format=absolute", "--git-common-dir", cwd=ROOT, capture_output=True, text=True).stdout.strip() | |
| 49 | 49 | source = Path(os.environ.get("NEFERWL_SOURCE", str(Path(common).parent.parent / "neferwl"))) | |
| 50 | - | run("go", "build", "-o", nefer, "./cmd/neferwl", cwd=source, env={**os.environ, "CGO_ENABLED": "0"}) | |
| 50 | + | run("go", "build", "-o", nefer, "./cmd/neferwl", cwd=source, env={**os.environ, "CGO_ENABLED": "0"}, timeout=600) | |
| 51 | 51 | docker("build", "-f", str(ROOT / "scripts/demo/Dockerfile"), "-t", name, str(ROOT), timeout=600) | |
| 52 | 52 | docker("network", "create", name) | |
| 53 | 53 | network = True | |
| @@ -258,7 +258,15 @@ def main(): | |||
| 258 | 258 | frozen = artifacts / "frozen.png" | |
| 259 | 259 | shutil.copyfile(shots / "latest.png", frozen) | |
| 260 | 260 | try: | |
| 261 | + | frozen_at = (shots / "latest.png").stat().st_mtime_ns | |
| 261 | 262 | key("key Alt+space") | |
| 263 | + | # The compositor must keep capturing, or an unchanged frame | |
| 264 | + | # proves nothing about the stopped client. | |
| 265 | + | deadline = time.monotonic() + 3 | |
| 266 | + | while (shots / "latest.png").stat().st_mtime_ns == frozen_at: | |
| 267 | + | if time.monotonic() > deadline: | |
| 268 | + | raise RuntimeError("compositor stopped capturing during the negative control") | |
| 269 | + | time.sleep(0.05) | |
| 262 | 270 | time.sleep(0.3) | |
| 263 | 271 | check = subprocess.run([str(ROOT / "build/resilience-pixels"), str(frozen), str(shots / "latest.png")], capture_output=True, text=True) | |
| 264 | 272 | assert check.returncode == 1, "visual gate accepted a stopped client" | |
| @@ -39,15 +39,15 @@ def main(): | |||||
| 39 | for tool in ("docker", "foot", "ssh-keygen", "go"): | 39 | for tool in ("docker", "foot", "ssh-keygen", "go"): | ||
| 40 | if shutil.which(tool) is None: | 40 | if shutil.which(tool) is None: | ||
| 41 | raise RuntimeError("missing prerequisite: " + tool) | 41 | raise RuntimeError("missing prerequisite: " + tool) | ||
| 42 | - | run("go", "build", "-o", str(ROOT / "build/vev"), ".", cwd=ROOT) | 42 | + | run("go", "build", "-o", str(ROOT / "build/vev"), ".", cwd=ROOT, timeout=600) |
| 43 | for helper in ("relay", "pixels"): | 43 | for helper in ("relay", "pixels"): | ||
| 44 | - | run("go", "build", "-o", str(ROOT / f"build/resilience-{helper}"), f"./scripts/remote-resilience-harness/{helper}", cwd=ROOT) | 44 | + | run("go", "build", "-o", str(ROOT / f"build/resilience-{helper}"), f"./scripts/remote-resilience-harness/{helper}", cwd=ROOT, timeout=600) |
| 45 | nefer = os.environ.get("NEFERWL_BIN") | 45 | nefer = os.environ.get("NEFERWL_BIN") | ||
| 46 | if not nefer: | 46 | if not nefer: | ||
| 47 | nefer = str(private / "neferwl") | 47 | nefer = str(private / "neferwl") | ||
| 48 | common = run("git", "rev-parse", "--path-format=absolute", "--git-common-dir", cwd=ROOT, capture_output=True, text=True).stdout.strip() | 48 | common = run("git", "rev-parse", "--path-format=absolute", "--git-common-dir", cwd=ROOT, capture_output=True, text=True).stdout.strip() | ||
| 49 | source = Path(os.environ.get("NEFERWL_SOURCE", str(Path(common).parent.parent / "neferwl"))) | 49 | source = Path(os.environ.get("NEFERWL_SOURCE", str(Path(common).parent.parent / "neferwl"))) | ||
| 50 | - | run("go", "build", "-o", nefer, "./cmd/neferwl", cwd=source, env={**os.environ, "CGO_ENABLED": "0"}) | 50 | + | run("go", "build", "-o", nefer, "./cmd/neferwl", cwd=source, env={**os.environ, "CGO_ENABLED": "0"}, timeout=600) |
| 51 | docker("build", "-f", str(ROOT / "scripts/demo/Dockerfile"), "-t", name, str(ROOT), timeout=600) | 51 | docker("build", "-f", str(ROOT / "scripts/demo/Dockerfile"), "-t", name, str(ROOT), timeout=600) | ||
| 52 | docker("network", "create", name) | 52 | docker("network", "create", name) | ||
| 53 | network = True | 53 | network = True | ||
| @@ -258,7 +258,15 @@ def main(): | |||||
| 258 | frozen = artifacts / "frozen.png" | 258 | frozen = artifacts / "frozen.png" | ||
| 259 | shutil.copyfile(shots / "latest.png", frozen) | 259 | shutil.copyfile(shots / "latest.png", frozen) | ||
| 260 | try: | 260 | try: | ||
| 261 | + | frozen_at = (shots / "latest.png").stat().st_mtime_ns | |||
| 261 | key("key Alt+space") | 262 | key("key Alt+space") | ||
| 263 | + | # The compositor must keep capturing, or an unchanged frame | |||
| 264 | + | # proves nothing about the stopped client. | |||
| 265 | + | deadline = time.monotonic() + 3 | |||
| 266 | + | while (shots / "latest.png").stat().st_mtime_ns == frozen_at: | |||
| 267 | + | if time.monotonic() > deadline: | |||
| 268 | + | raise RuntimeError("compositor stopped capturing during the negative control") | |||
| 269 | + | time.sleep(0.05) | |||
| 262 | time.sleep(0.3) | 270 | time.sleep(0.3) | ||
| 263 | check = subprocess.run([str(ROOT / "build/resilience-pixels"), str(frozen), str(shots / "latest.png")], capture_output=True, text=True) | 271 | check = subprocess.run([str(ROOT / "build/resilience-pixels"), str(frozen), str(shots / "latest.png")], capture_output=True, text=True) | ||
| 264 | assert check.returncode == 1, "visual gate accepted a stopped client" | 272 | assert check.returncode == 1, "visual gate accepted a stopped client" | ||