"""使用真实 HTTP/socket 验证领取屏障,不连接线上 Gitea。""" import http.client import http.server import importlib.util import json import os from pathlib import Path import socket import tempfile import threading import time import unittest if os.name != "posix": raise unittest.SkipTest("领取屏障使用 Linux Unix socket 和目录 fsync;在 Linux/WSL 运行") SPEC = importlib.util.spec_from_file_location( "gitea_cache_gate", Path(__file__).with_name("gitea-runner-fetch-gate.py")) MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) FETCH = "/api/actions/runner.v1.RunnerService/FetchTask" UPDATE = "/api/actions/runner.v1.RunnerService/UpdateTask" def core_state(state): return {key: state[key] for key in ("paused", "inflight", "uncertain")} class Upstream(http.server.BaseHTTPRequestHandler): def log_message(self, *_args): pass def do_POST(self): data = self.rfile.read(int(self.headers.get("Content-Length", 0))) self.server.requests.append((self.path, data)) if self.path.endswith("/Redirect"): self.send_response(302) self.send_header("Location", "/api/actions/runner.v1.RunnerService/FetchTask") self.send_header("Content-Length", "0") self.end_headers() return if self.path == FETCH: self.server.entered.set() self.server.release.wait(10) if self.server.truncated and self.path == FETCH: self.send_response(200) self.send_header("Content-Length", "100") self.end_headers() self.wfile.write(b"incomplete") self.close_connection = True return body = b'{"task":null}' self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) class GateTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.gate = MODULE.Gate(self.temp.name) self.upstream = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Upstream) self.upstream.requests = [] self.upstream.entered = threading.Event() self.upstream.release = threading.Event() self.upstream.truncated = False self.proxy = MODULE.create_proxy(("127.0.0.1", 0), f"http://127.0.0.1:{self.upstream.server_port}", self.gate) self.servers = [self.upstream, self.proxy] for server in self.servers: threading.Thread(target=server.serve_forever, daemon=True).start() def tearDown(self): self.upstream.release.set() for server in reversed(self.servers): server.shutdown() server.server_close() self.temp.cleanup() def request(self, path, body=b"{}", chunked=False): connection = http.client.HTTPConnection("127.0.0.1", self.proxy.server_port, timeout=5) try: if chunked: connection.request("POST", path, body=[body], encode_chunked=True) else: connection.request("POST", path, body=body) response = connection.getresponse() result = response.status, response.read() return result finally: connection.close() def wait_for(self, predicate): deadline = time.monotonic() + 3 while time.monotonic() < deadline: if predicate(): return time.sleep(0.01) self.fail("condition did not become true") def start_fetch(self): self.result = [] thread = threading.Thread(target=lambda: self.result.append(self.request(FETCH))) thread.start() self.assertTrue(self.upstream.entered.wait(3)) return thread def test_pause_holds_inflight_and_allows_reporting(self): thread = self.start_fetch() self.assertEqual(core_state(self.gate.control("pause")), {"paused": True, "inflight": 1, "uncertain": False}) self.assertEqual(self.request(FETCH)[0], 503) self.assertEqual(self.request(UPDATE)[0], 200) self.assertEqual(sum(path == FETCH for path, _ in self.upstream.requests), 1) self.upstream.release.set() thread.join(3) self.assertFalse(thread.is_alive()) self.assertEqual(self.result[0][0], 200) self.assertEqual(self.gate.control("status")["inflight"], 0) self.assertEqual(self.gate.control("resume")["paused"], False) self.assertEqual(self.request(FETCH)[0], 200) def test_disconnected_client_does_not_release_upstream_request(self): client = socket.create_connection(self.proxy.server_address, timeout=3) client.sendall(f"POST {FETCH} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\n{{}}".encode()) self.assertTrue(self.upstream.entered.wait(3)) client.close() state = self.gate.control("pause") self.assertEqual(state["inflight"], 1) self.upstream.release.set() self.wait_for(lambda: self.gate.control("status")["inflight"] == 0) self.assertFalse(self.gate.control("status")["uncertain"]) def test_truncated_upstream_latches_uncertainty_across_restart(self): self.upstream.truncated = True self.upstream.release.set() self.assertEqual(self.request(FETCH)[0], 502) self.wait_for(lambda: self.gate.control("status")["uncertain"]) self.assertTrue(self.gate.control("resume")["paused"]) restored = MODULE.Gate(self.temp.name) self.assertTrue(restored.control("status")["uncertain"]) self.assertFalse(restored.enter()) def test_crashed_inflight_is_not_treated_as_successful_drain(self): self.assertTrue(self.gate.enter()) restored = MODULE.Gate(self.temp.name) self.assertTrue(restored.control("status")["uncertain"]) self.assertIn("error", restored.control("resume")) self.gate.leave(True) def test_pause_marker_survives_restart(self): self.gate.control("pause") restored = MODULE.Gate(self.temp.name) self.assertEqual(core_state(restored.control("status")), {"paused": True, "inflight": 0, "uncertain": False}) restored.control("resume") self.assertFalse(MODULE.Gate(self.temp.name).control("status")["paused"]) def test_chunked_body_is_decoded_and_non_rpc_path_rejected(self): self.assertEqual(self.request(UPDATE, b'{"state":"running"}', chunked=True)[0], 200) self.assertEqual(self.upstream.requests[-1], (UPDATE, b'{"state":"running"}')) self.assertEqual(self.request("/api/v1/repos")[0], 404) self.assertEqual(self.request("/api/actions/../v1/repos")[0], 404) def test_upstream_redirect_is_not_followed(self): self.assertEqual(self.request("/api/actions/runner.v1.RunnerService/Redirect")[0], 302) self.assertFalse(self.upstream.entered.is_set()) self.assertEqual(len(self.upstream.requests), 1) def test_connection_refused_before_rpc_does_not_latch_uncertainty(self): with socket.socket() as unused: unused.bind(("127.0.0.1", 0)) port = unused.getsockname()[1] self.proxy.upstream = MODULE.urllib.parse.urlsplit(f"http://127.0.0.1:{port}") self.assertEqual(self.request(FETCH)[0], 502) self.assertEqual(core_state(self.gate.control("status")), {"paused": False, "inflight": 0, "uncertain": False}) @unittest.skipUnless(hasattr(socket, "AF_UNIX"), "Unix control socket required") def test_control_socket_uses_newline_json(self): path = str(Path(self.temp.name) / "gate.sock") control = MODULE.ControlServer(path, MODULE.Control) control.gate = self.gate self.servers.append(control) threading.Thread(target=control.serve_forever, daemon=True).start() with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: client.connect(path) client.sendall(b'{"action":"pause"}\n') payload = client.makefile("rb").readline() self.assertEqual(core_state(json.loads(payload)), {"paused": True, "inflight": 0, "uncertain": False}) def test_paused_fetch_records_live_route_and_restart_discards_proof(self): self.gate.control("pause") before = time.time() self.assertEqual(self.request(FETCH)[0], 503) state = self.gate.control("status") self.assertEqual(state["last_fetch_peer"], "127.0.0.1") self.assertGreaterEqual(state["last_fetch_at"], before) self.assertEqual(self.upstream.requests, []) self.assertEqual(self.request(UPDATE)[0], 200) self.assertEqual(self.gate.control("status")["last_fetch_at"], state["last_fetch_at"]) restarted = MODULE.Gate(self.temp.name).control("status") self.assertIsNone(restarted["last_fetch_peer"]) self.assertIsNone(restarted["last_fetch_at"]) if __name__ == "__main__": unittest.main()