Files
Genarrative/scripts/test_gitea_cache_export.py
T
lhk229 705bb1e6b3
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
CI缓存自动维护与清理 (#462)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/462
2026-09-22 17:19:11 +08:00

206 lines
10 KiB
Python

"""master 产物的增量归档与 Gitea v4 上传协议回归。"""
import hashlib
import base64
from contextlib import redirect_stdout
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import importlib.util
import io
import json
import os
from pathlib import Path
import tarfile
import tempfile
import threading
import unittest
from unittest.mock import patch
import urllib.parse
import zipfile
from gitea_cache_upload_cleanup import decode_owner
spec = importlib.util.spec_from_file_location("cache_export", Path(__file__).with_name("export-gitea-rust-cache.py"))
exporter = importlib.util.module_from_spec(spec)
spec.loader.exec_module(exporter)
class ExportTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / "objects").mkdir()
def object(self, key, value, mtime):
path = self.root / "objects" / key[0] / key[1] / key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(value)
os.utime(path, ns=(mtime * 10**9, mtime * 10**9))
return path
def unpack(self, baseline, limit=1024):
destination = self.root / "snapshot.zip"
result = exporter.pack_snapshot(self.root, destination, {"job": "fixture"}, baseline, limit)
with zipfile.ZipFile(destination) as bundle:
self.assertEqual(bundle.namelist(), ["snapshot.tar"])
with tarfile.open(fileobj=io.BytesIO(bundle.read("snapshot.tar"))) as archive:
files = {member.name: archive.extractfile(member).read() for member in archive}
return result, json.loads(files.pop("manifest.json")), files
def test_delta_omits_hit_bytes_preserves_recency_and_exports_new_keys(self):
inherited = self.object("a" * 64, b"old", 10)
baseline_path = self.root / "baseline.json"
exporter.save_baseline(self.root, baseline_path)
baseline = json.loads(baseline_path.read_text())
# sccache 对命中对象更新 mtime,不能因此重复上传已有对象。
os.utime(inherited, ns=(20 * 10**9, 20 * 10**9))
self.object("b" * 64, b"new", 30)
result, manifest, files = self.unpack(baseline)
key = "objects/b/b/" + "b" * 64
self.assertEqual(result, (1, 3))
self.assertEqual(files, {key: b"new"})
self.assertEqual(manifest["mode"], "delta")
self.assertEqual(manifest["touched"], [{"path": "objects/a/a/" + "a" * 64, "mtime_ns": 20 * 10**9}])
self.assertEqual(manifest["objects"][0]["sha256"], hashlib.sha256(b"new").hexdigest())
def test_empty_delta_is_valid_and_changed_size_is_exported(self):
path = self.object("a" * 64, b"old", 10)
baseline = {"a/a/" + "a" * 64: {"size": 3, "mtime_ns": 10 * 10**9}}
self.assertEqual(self.unpack(baseline)[0], (0, 0))
path.write_bytes(b"replacement")
self.assertEqual(self.unpack(baseline)[0], (1, 11))
def test_capacity_prefers_newest_objects_and_excludes_unrelated_files(self):
self.object("a" * 64, b"old", 10)
self.object("b" * 64, b"new", 20)
(self.root / "objects" / "credentials").write_bytes(b"secret")
(self.root / "sccache").write_bytes(b"binary")
result, _, files = self.unpack({}, limit=3)
self.assertEqual(result, (1, 3))
self.assertEqual(list(files.values()), [b"new"])
@unittest.skipUnless(os.name == "posix", "symlink fixture requires POSIX")
def test_symlink_objects_and_subdirectories_are_not_exported(self):
outside = self.root / "secret"
outside.write_bytes(b"secret")
linked = self.object("a" * 64, b"old", 10)
linked.unlink()
linked.symlink_to(outside)
(self.root / "objects" / "b").symlink_to(self.root, target_is_directory=True)
self.assertEqual(self.unpack({})[0], (0, 0))
def test_non_master_and_unclean_shutdown_do_not_scan_or_upload(self):
with patch.object(exporter, "scan_objects", side_effect=AssertionError("must not scan")), \
patch.object(exporter, "upload_snapshot", side_effect=AssertionError("must not upload")):
exporter.export({"GITHUB_EVENT_NAME": "pull_request", "GITHUB_REF": "refs/heads/master"})
exporter.export({"GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/feature"})
exporter.export({"GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/master"})
def test_internal_repository_route_bypasses_rpc_only_gateway(self):
env = {
"GITHUB_REPOSITORY": "owner/repo",
"GITHUB_SERVER_URL": "http://fetch-gate:8080",
"GENARRATIVE_GITEA_REPOSITORY_URL": "http://gitea:3000/sub/owner/repo.git",
}
self.assertEqual(exporter.artifact_base_url(env), "http://gitea:3000/sub")
env["GENARRATIVE_GITEA_REPOSITORY_URL"] = "http://token@gitea:3000/owner/repo.git"
with self.assertRaises(ValueError):
exporter.artifact_base_url(env)
def test_native_v4_chunks_finalize_with_verified_size_and_hash(self):
calls = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
calls.append((self.command, self.path, dict(self.headers), body))
payload = {"ok": True}
if self.path.endswith("CreateArtifact"):
payload["signedUploadUrl"] = "https://external.invalid/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=private&taskID=4&artifactID=8"
else:
payload["artifactId"] = "8"
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(payload).encode())
def do_PUT(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
calls.append((self.command, self.path, dict(self.headers), body))
self.send_response(201)
self.end_headers()
self.wfile.write(b'"created"')
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
path = self.root / "test.zip"
path.write_bytes(b"123456789")
with patch.object(exporter, "CHUNK_BYTES", 4):
exporter.upload_snapshot(path, "rust-cache-v1-backend-tests-attempt-1", 123, f"http://127.0.0.1:{server.server_port}", "ephemeral")
finally:
server.shutdown()
server.server_close()
thread.join()
self.assertEqual(len(calls), 6)
self.assertEqual(calls[0][3]["workflowRunBackendId"], "123")
remaining = datetime.fromisoformat(calls[0][3]["expiresAt"]) - datetime.now(timezone.utc)
self.assertGreater(remaining.total_seconds(), 7 * 24 * 3600)
self.assertLessEqual(remaining.total_seconds(), 8 * 24 * 3600)
blocks = calls[1:4]
self.assertEqual(b"".join(call[3] for call in blocks), b"123456789")
self.assertEqual(len({urllib.parse.parse_qs(urllib.parse.urlsplit(call[1]).query)["blockid"][0] for call in blocks}), 3)
for call in blocks:
block_id = urllib.parse.parse_qs(urllib.parse.urlsplit(call[1]).query)["blockid"][0]
gitea_filename_suffix = base64.urlsafe_b64encode(block_id.encode()).decode()
self.assertEqual(decode_owner(gitea_filename_suffix), ("backend-tests", 1))
self.assertTrue(all("Authorization" not in call[2] for call in calls[1:5]))
self.assertEqual(calls[-1][2]["Authorization"], "Bearer ephemeral")
self.assertEqual(calls[-1][3]["size"], "9")
self.assertEqual(calls[-1][3]["hash"], "sha256:" + hashlib.sha256(b"123456789").hexdigest())
def test_upload_failure_never_finalizes_an_incomplete_artifact(self):
path = self.root / "test.zip"
path.write_bytes(b"data")
with patch.object(exporter, "request", side_effect=[
{"ok": True, "signedUploadUrl": "http://gitea/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=x"},
RuntimeError("upload failed"),
]) as request:
with self.assertRaisesRegex(RuntimeError, "upload failed"):
exporter.upload_snapshot(path, "rust-cache-v1-backend-tests-attempt-1", 123, "http://gitea", "ephemeral")
self.assertEqual(request.call_count, 2)
def test_failed_optional_export_never_emits_host_completion_marker(self):
(self.root / "export-baseline.json").write_text("{}")
(self.root / "rustc.txt").write_text("rustc fixture\n")
(self.root / "workspace.txt").write_text(str(Path.cwd().resolve()))
(self.root / "source-commit.txt").write_text("a" * 40)
self.object("b" * 64, b"new", 20)
env = {
"GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/master",
"GENARRATIVE_CI_RUST_CACHE_EXPORT_READY": "1",
"GITHUB_JOB": "backend-tests", "GITHUB_SHA": "b" * 40,
"GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "1",
"GENARRATIVE_GITEA_TOKEN": "ephemeral",
"GITHUB_REPOSITORY": "owner/repo",
"GENARRATIVE_GITEA_REPOSITORY_URL": "http://gitea/owner/repo.git",
"GENARRATIVE_CI_RUST_CACHE_ROOT": str(self.root),
}
output = io.StringIO()
with patch.object(exporter.subprocess, "check_output", side_effect=["rustc fixture\n", "sccache 0.18.0\n"]), \
patch.object(exporter, "upload_snapshot", side_effect=RuntimeError("upload failed")), \
redirect_stdout(output):
with self.assertRaisesRegex(RuntimeError, "upload failed"):
exporter.export(env)
self.assertNotIn("complete=true", output.getvalue())
self.assertFalse((self.root / "export-baseline.json").exists())
if __name__ == "__main__":
unittest.main()