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
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/462
206 lines
10 KiB
Python
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()
|