实现 Gitea CI 编译缓存自动刷新与定向清理
基于固定 master 提交构建全新缓存镜像,并在成功验证后保留当前版和回滚版。 增加任务领取网关,在不打断运行中 CI 的前提下切换镜像。 补充宿主维护服务、部署说明、共享记忆和定向行为测试。 修复构建测试在 Windows 与 WSL 下并发写入日志的竞态。
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
"""使用真实 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()
|
||||
Reference in New Issue
Block a user