复用 master CI 产物自动更新编译缓存
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
仅由 master CI 导出新增缓存对象,宿主合并去重并组装限额快照,避免重复预热编译。 跟踪任务领取与最终上报,仅在安全空闲时切换镜像,移除管理员 API 依赖。 排除取消及不完整产物,定向清理过期镜像、归档和中断上传残块。 同步部署文档、共享记忆及缓存发布、合并和清理的定向测试。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import http.client
|
||||
import http.server
|
||||
import gzip
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -21,6 +22,29 @@ 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"
|
||||
LOG = "/api/actions/runner.v1.RunnerService/UpdateLog"
|
||||
PING = "/api/actions/ping.v1.PingService/Ping"
|
||||
|
||||
|
||||
def varint(value):
|
||||
data = bytearray()
|
||||
while value > 127:
|
||||
data.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
data.append(value)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def field(number, value):
|
||||
# actions-proto-go v0.4.1: task/state ID=1; result=2; log task_id=1,
|
||||
# index=2, rows=3, no_more=4; log response ack_index=1.
|
||||
if isinstance(value, bytes):
|
||||
return varint(number * 8 + 2) + varint(len(value)) + value
|
||||
return varint(number * 8) + varint(value)
|
||||
|
||||
|
||||
def state(task_id, result=0):
|
||||
return field(1, field(1, task_id) + field(2, result))
|
||||
|
||||
|
||||
def core_state(state):
|
||||
@@ -50,8 +74,17 @@ class Upstream(http.server.BaseHTTPRequestHandler):
|
||||
self.wfile.write(b"incomplete")
|
||||
self.close_connection = True
|
||||
return
|
||||
body = b'{"task":null}'
|
||||
self.send_response(200)
|
||||
body = self.server.responses.get(self.path, b"")
|
||||
if self.path == UPDATE and self.path not in self.server.responses:
|
||||
body = state(*map(int, MODULE.task_state(MODULE.protobuf_fields(data))))
|
||||
if self.path == LOG and self.path not in self.server.responses:
|
||||
fields = MODULE.protobuf_fields(data)
|
||||
body = field(1, MODULE.single(fields, 2, 0) + len(fields.get(3, [])))
|
||||
self.send_response(self.server.statuses.get(self.path, 200))
|
||||
self.send_header("Content-Type", self.server.content_type)
|
||||
if self.server.compressed:
|
||||
body = gzip.compress(body)
|
||||
self.send_header("Content-Encoding", "gzip")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
@@ -66,6 +99,10 @@ class GateTests(unittest.TestCase):
|
||||
self.upstream.entered = threading.Event()
|
||||
self.upstream.release = threading.Event()
|
||||
self.upstream.truncated = False
|
||||
self.upstream.responses = {}
|
||||
self.upstream.statuses = {}
|
||||
self.upstream.content_type = "application/proto"
|
||||
self.upstream.compressed = 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]
|
||||
@@ -79,13 +116,15 @@ class GateTests(unittest.TestCase):
|
||||
server.server_close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def request(self, path, body=b"{}", chunked=False):
|
||||
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)
|
||||
connection.request("POST", path, body=[body], encode_chunked=True,
|
||||
headers={"Content-Type": "application/proto"})
|
||||
else:
|
||||
connection.request("POST", path, body=body)
|
||||
connection.request("POST", path, body=body,
|
||||
headers={"Content-Type": "application/proto"})
|
||||
response = connection.getresponse()
|
||||
result = response.status, response.read()
|
||||
return result
|
||||
@@ -112,7 +151,7 @@ class GateTests(unittest.TestCase):
|
||||
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(self.request(PING)[0], 200)
|
||||
self.assertEqual(sum(path == FETCH for path, _ in self.upstream.requests), 1)
|
||||
self.upstream.release.set()
|
||||
thread.join(3)
|
||||
@@ -159,8 +198,8 @@ class GateTests(unittest.TestCase):
|
||||
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(PING, b"ping", chunked=True)[0], 200)
|
||||
self.assertEqual(self.upstream.requests[-1], (PING, b"ping"))
|
||||
self.assertEqual(self.request("/api/v1/repos")[0], 404)
|
||||
self.assertEqual(self.request("/api/actions/../v1/repos")[0], 404)
|
||||
|
||||
@@ -200,12 +239,109 @@ class GateTests(unittest.TestCase):
|
||||
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.request(PING)[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"])
|
||||
|
||||
def fetch_task(self, task_id=42):
|
||||
self.upstream.responses[FETCH] = field(1, field(1, task_id)) + field(2, 10)
|
||||
self.upstream.release.set()
|
||||
self.assertEqual(self.request(FETCH)[0], 200)
|
||||
self.assertEqual(self.gate.control("status")["task_ids"], [str(task_id)])
|
||||
|
||||
def finalize_log(self, task_id=42):
|
||||
self.assertEqual(self.request(LOG, field(1, task_id) + field(2, 3)
|
||||
+ field(3, b"log row") + field(4, 1))[0], 200)
|
||||
self.wait_for(lambda: self.gate.tasks.get(str(task_id)) is True)
|
||||
|
||||
def test_assigned_task_survives_pause_until_runner_finished_reporting(self):
|
||||
self.fetch_task()
|
||||
self.assertEqual(self.gate.control("pause")["active_tasks"], 1)
|
||||
self.assertEqual(self.request(FETCH)[0], 503)
|
||||
self.assertEqual(self.request(UPDATE, state(42, 1))[0], 200)
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.finalize_log()
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.assertEqual(self.request(UPDATE, state(42, 1))[0], 200)
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
self.assertFalse(self.gate.control("status")["uncertain"])
|
||||
# 终态响应在客户端超时后重试,不应再次阻断后续任务领取。
|
||||
self.request(UPDATE, state(42, 1))
|
||||
self.assertFalse(self.gate.control("status")["uncertain"])
|
||||
|
||||
def test_compressed_fetch_and_task_ledger_survive_restart(self):
|
||||
self.upstream.compressed = True
|
||||
self.fetch_task()
|
||||
restored = MODULE.Gate(self.temp.name)
|
||||
self.assertEqual(restored.control("status")["task_ids"], ["42"])
|
||||
self.assertFalse(restored.control("status")["uncertain"])
|
||||
self.proxy.gate = self.gate = restored
|
||||
self.finalize_log()
|
||||
self.request(UPDATE, state(42, 2))
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
self.assertEqual(MODULE.Gate(self.temp.name).control("status")["active_tasks"], 0)
|
||||
|
||||
def test_server_cancellation_does_not_retire_executing_task(self):
|
||||
self.fetch_task()
|
||||
self.upstream.responses[UPDATE] = state(42, 3)
|
||||
self.request(UPDATE, state(42))
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.finalize_log()
|
||||
self.request(UPDATE, state(42))
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.request(UPDATE, state(42, 3))
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
|
||||
def test_partial_final_log_acknowledgement_keeps_task_active(self):
|
||||
self.fetch_task()
|
||||
self.upstream.responses[LOG] = field(1, 3)
|
||||
self.request(LOG, field(1, 42) + field(2, 3) + field(3, b"row") + field(4, 1))
|
||||
self.request(UPDATE, state(42, 1))
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
del self.upstream.responses[LOG]
|
||||
self.finalize_log()
|
||||
self.request(UPDATE, state(42, 1))
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
|
||||
def test_final_state_waits_for_success_and_output_acknowledgement(self):
|
||||
self.fetch_task()
|
||||
self.finalize_log()
|
||||
self.upstream.statuses[UPDATE] = 500
|
||||
self.request(UPDATE, state(42, 1))
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.upstream.statuses[UPDATE] = 200
|
||||
output = field(2, field(1, b"key") + field(2, b"value"))
|
||||
self.request(UPDATE, state(42, 1) + output)
|
||||
self.assertEqual(self.gate.control("status")["active_tasks"], 1)
|
||||
self.upstream.responses[UPDATE] = state(42, 1) + field(2, b"key")
|
||||
self.request(UPDATE, state(42, 1) + output)
|
||||
self.wait_for(lambda: self.gate.control("status")["active_tasks"] == 0)
|
||||
|
||||
def test_unknown_or_invalid_fetch_result_prevents_idle(self):
|
||||
self.upstream.release.set()
|
||||
self.upstream.responses[FETCH] = b"not protobuf"
|
||||
self.assertEqual(self.request(FETCH)[0], 200)
|
||||
self.assertTrue(self.gate.control("status")["uncertain"])
|
||||
self.assertTrue(MODULE.Gate(self.temp.name).control("status")["uncertain"])
|
||||
|
||||
def test_task_report_without_observed_assignment_requires_idle_bootstrap(self):
|
||||
self.assertEqual(self.request(UPDATE, state(42))[0], 200)
|
||||
self.wait_for(lambda: self.gate.control("status")["uncertain"])
|
||||
self.assertIn("error", self.gate.control("resume"))
|
||||
|
||||
def test_disconnected_fetch_client_still_leaves_assigned_task_busy(self):
|
||||
self.upstream.responses[FETCH] = field(1, field(1, 42))
|
||||
client = socket.create_connection(self.proxy.server_address, timeout=3)
|
||||
client.sendall(f"POST {FETCH} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n".encode())
|
||||
self.assertTrue(self.upstream.entered.wait(3))
|
||||
client.close()
|
||||
self.gate.control("pause")
|
||||
self.upstream.release.set()
|
||||
self.wait_for(lambda: self.gate.control("status")["inflight"] == 0)
|
||||
self.assertEqual(self.gate.control("status")["task_ids"], ["42"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user