Files

236 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CI 等待(scripts/ci_wait.py):临时 Git 仓库 + 进程内 Gitea 合并状态桩 + 注入的时钟与 sleep。
全部离线运行:不联网、不读取真实 ~/.secrets(HOME 指向临时目录),token 为合成值。
"""
import contextlib
import http.server
import io
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "ci_wait.py"
sys.path.insert(0, str(SOURCE / "scripts"))
import ci_wait # noqa: E402
TOKEN = "synthetic-gitea-token-not-real"
def status(context, state, url=None):
return {"context": context, "status": state, "target_url": url or f"https://ci.invalid/{context}"}
class FakeGitea:
"""Gitea 合并状态桩(127.0.0.1):校验 token;每次 GET 依次返回脚本中的下一份 statuses(末项重复)。"""
def __init__(self, token=TOKEN):
self.token = token
self.script = []
self.requests = []
stub = self
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.headers.get("Authorization") != f"token {stub.token}":
return self._send(401, {"message": "unauthorized"})
parts = self.path.split("?", 1)[0].split("/")
if parts[1:4] != ["api", "v1", "repos"] or len(parts) != 9 or parts[6] != "commits" \
or parts[8] != "status":
return self._send(404, {"message": "not found"})
stub.requests.append("/".join(parts[4:6]) + "@" + parts[7])
statuses = stub.script.pop(0) if len(stub.script) > 1 else (stub.script or [[]])[0]
return self._send(200, {"sha": parts[7], "statuses": statuses})
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
def closed_port_url():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
return f"http://127.0.0.1:{port}/api/v1"
class FakeClock:
def __init__(self):
self.now = 0.0
self.sleeps = []
def clock(self):
return self.now
def sleep(self, seconds):
self.sleeps.append(seconds)
self.now += seconds
class CiWaitTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="ci-wait-")
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.repo = root / "repo"
env = {k: v for k, v in os.environ.items()
if not k.startswith("GIT_") and k not in {"GITEA_API_URL", "GITEA_REPO", "GITEA_TOKEN"}}
env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "CI Wait Test", "GIT_AUTHOR_EMAIL": "ci-wait@example.invalid",
"GIT_COMMITTER_NAME": "CI Wait Test", "GIT_COMMITTER_EMAIL": "ci-wait@example.invalid",
"HOME": str(root / "no-home"),
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost",
"GITEA_TOKEN": TOKEN})
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
env["GITEA_API_URL"] = self.gitea.url
self.env = env
env_patch = mock.patch.dict(os.environ, env, clear=True)
env_patch.start()
self.addCleanup(env_patch.stop)
self.git(root, "init", "--initial-branch=main", str(self.repo))
(self.repo / "README").write_text("fixture\n")
self.git(self.repo, "add", "README")
self.git(self.repo, "commit", "-m", "Create fixture")
self.git(self.repo, "remote", "add", "origin", "ssh://git@git-ssh.example.invalid:31411/fixture/repo.git")
self.sha = self.git(self.repo, "rev-parse", "HEAD")
cwd = os.getcwd()
os.chdir(self.repo)
self.addCleanup(os.chdir, cwd)
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True).stdout.strip()
def run_wait(self, *args, env=None):
clock = FakeClock()
out = io.StringIO()
with mock.patch.dict(os.environ, env or {}), contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(out):
code = ci_wait.main(list(args), sleep=clock.sleep, clock=clock.clock)
text = out.getvalue()
self.assertNotIn(TOKEN, text)
return code, text, clock
def test_success_requires_two_consecutive_all_green_polls(self):
self.gitea.script = [[status("ci/test", "pending")], [status("ci/test", "pending")],
[status("ci/test", "success")], [status("ci/test", "success")]]
code, out, clock = self.run_wait("--timeout", "300")
self.assertEqual(code, 0, out)
self.assertIn(f"[verdict] CI_SUCCESS sha={self.sha}", out)
self.assertEqual(len(self.gitea.requests), 4, out)
self.assertEqual(self.gitea.requests[0], f"fixture/repo@{self.sha}")
self.assertEqual(clock.sleeps, [15.0, 15.0, 15.0], out)
# 状态行只在变化时打印:pending 与 success 各一行。
lines = [line for line in out.splitlines() if line.startswith("[ci-wait] sha=")]
self.assertEqual(len(lines), 2, out)
def test_late_registering_job_does_not_succeed_early(self):
self.gitea.script = [[status("ci/test", "success")],
[status("ci/test", "success"), status("ci/build", "pending")],
[status("ci/test", "success"), status("ci/build", "success")],
[status("ci/test", "success"), status("ci/build", "success")]]
code, out, _ = self.run_wait("--timeout", "300")
self.assertEqual(code, 0, out)
self.assertEqual(len(self.gitea.requests), 4, out)
self.assertIn("ci/build=pending", out)
def test_failure_exits_fast_with_target_url(self):
url = "https://ci.invalid/runs/42"
self.gitea.script = [[status("ci/test", "pending"), status("ci/build", "success")],
[status("ci/test", "failure", url), status("ci/build", "success")]]
code, out, _ = self.run_wait("--sha", self.sha, "--timeout", "300")
self.assertEqual(code, 1, out)
self.assertEqual(len(self.gitea.requests), 2, out)
self.assertIn(f"[ci-wait] FAIL ci/test {url}", out)
self.assertNotIn("FAIL ci/build", out)
self.assertIn(f"[verdict] CI_FAILED sha={self.sha}", out)
def test_missing_required_context_waits_until_timeout(self):
self.gitea.script = [[status("ci/test", "success")]]
code, out, clock = self.run_wait("--contexts", "ci/test,e2e", "--interval", "5", "--timeout", "30")
self.assertEqual(code, 2, out)
self.assertEqual(clock.now, 30.0)
self.assertIn("last observed: ci/test=success", out)
self.assertIn("missing contexts: e2e", out)
self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out)
def test_skipped_and_warning_contexts_count_as_done_not_failed(self):
self.gitea.script = [[status("ci/test", "pending"), status("ci/test-pg", "skipped")],
[status("ci/test", "success"), status("ci/test-pg", "skipped"),
status("ci/svc-realcombo", "warning")]]
code, out, _ = self.run_wait("--contexts", "test-pg", "--timeout", "300")
self.assertEqual(code, 0, out)
self.assertEqual(len(self.gitea.requests), 3, out)
self.assertNotIn("FAIL", out)
self.assertIn(f"[verdict] CI_SUCCESS sha={self.sha}", out)
def test_skipped_does_not_mask_a_pending_context(self):
self.gitea.script = [[status("ci/test", "pending"), status("ci/test-pg", "skipped")]]
code, out, _ = self.run_wait("--interval", "5", "--timeout", "30")
self.assertEqual(code, 2, out)
self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out)
def test_context_names_match_as_substrings(self):
self.gitea.script = [[status("CI / test (pull_request)", "success")]]
code, out, _ = self.run_wait("--contexts", "test", "--timeout", "60")
self.assertEqual(code, 0, out)
def test_unreachable_api_exits_6_after_retrying_until_deadline(self):
code, out, clock = self.run_wait("--timeout", "60", env={"GITEA_API_URL": closed_port_url()})
self.assertEqual(code, 6, out)
self.assertEqual(clock.now, 60.0)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
def test_unauthorized_or_missing_token_exits_6_immediately(self):
code, out, clock = self.run_wait("--timeout", "60", env={"GITEA_TOKEN": "wrong-token"})
self.assertEqual(code, 6, out)
self.assertEqual(clock.sleeps, [], out)
self.assertIn("HTTP 401", out)
with mock.patch.dict(os.environ):
del os.environ["GITEA_TOKEN"]
code, out, _ = self.run_wait("--timeout", "60")
self.assertEqual(code, 6, out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
def test_sha_must_resolve_to_full_sha(self):
code, out, _ = self.run_wait("--sha", "not-a-ref")
self.assertEqual(code, 2, out)
self.assertIn("full 40-hex SHA", out)
self.assertEqual(self.gitea.requests, [])
def test_cli_entry_point_reports_remote_unverified(self):
env = self.env | {"GITEA_API_URL": closed_port_url()}
result = subprocess.run([sys.executable, str(SCRIPT), "--interval", "5", "--timeout", "0.2"],
cwd=self.repo, env=env, capture_output=True, text=True, timeout=60)
out = result.stdout + result.stderr
self.assertEqual(result.returncode, 6, out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
self.assertNotIn(TOKEN, out)
if __name__ == "__main__":
unittest.main()