CI缓存自动维护与清理 (#462)
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
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
This commit was merged in pull request #462.
This commit is contained in:
@@ -5,6 +5,15 @@ set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
base_ref="${1:?usage: build-gitea-rust-cache.sh <verified-base-image> <candidate-tag>}"
|
||||
candidate_tag="${2:?candidate image tag is required}"
|
||||
requested_source_commit="${3:-}"
|
||||
if [[ "$#" -gt 3 ]]; then
|
||||
echo 'usage: build-gitea-rust-cache.sh <verified-base-image> <candidate-tag> [master-commit-sha]' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "${requested_source_commit}" && ! "${requested_source_commit}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo 'master-commit-sha must be a complete 40-character SHA.' >&2
|
||||
exit 2
|
||||
fi
|
||||
# 与实际 Gitea checkout 路径一致;Rust 对象 key 包含编译 cwd,不能随意换临时根。
|
||||
workspace=/workspace/GenarrativeAI/Genarrative
|
||||
[[ "${CI:-}" != true ]] || { echo 'Run on the trusted image builder, outside CI jobs.' >&2; exit 1; }
|
||||
@@ -22,7 +31,17 @@ bash "${repo_root}/scripts/gitea-ci-job-image.sh" verify "${base_id}"
|
||||
|
||||
# 只归档远端 master 的确定提交;不复制当前工作区或本地凭据。
|
||||
git -C "${repo_root}" fetch --no-tags origin refs/heads/master
|
||||
source_commit="$(git -C "${repo_root}" rev-parse FETCH_HEAD^{commit})"
|
||||
master_commit="$(git -C "${repo_root}" rev-parse FETCH_HEAD^{commit})"
|
||||
if [[ -n "${requested_source_commit}" ]]; then
|
||||
git -C "${repo_root}" cat-file -e "${requested_source_commit}^{commit}"
|
||||
if ! git -C "${repo_root}" merge-base --is-ancestor "${requested_source_commit}" "${master_commit}"; then
|
||||
echo "master-commit-sha is not contained in fetched master: ${requested_source_commit}" >&2
|
||||
exit 1
|
||||
fi
|
||||
source_commit="$(git -C "${repo_root}" rev-parse "${requested_source_commit}^{commit}")"
|
||||
else
|
||||
source_commit="${master_commit}"
|
||||
fi
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/gitea-rust-cache.XXXXXX")"
|
||||
container_id=''
|
||||
cleanup() {
|
||||
@@ -50,7 +69,7 @@ container_id="$(docker run --detach --cpus=4 --memory=12g --pids-limit=1024 \
|
||||
docker exec "${container_id}" mkdir -p "${workspace}" /opt/genarrative-ci/rust-cache/objects
|
||||
git -C "${repo_root}" archive "${source_commit}" | docker cp - "${container_id}:${workspace}"
|
||||
docker cp "${work_dir}/snapshot/." "${container_id}:/opt/genarrative-ci/rust-cache/"
|
||||
docker cp "${repo_root}/scripts/ci-rust-cache.sh" "${container_id}:/tmp/ci-rust-cache.sh"
|
||||
docker exec "${container_id}" cp "${workspace}/scripts/ci-rust-cache.sh" /tmp/ci-rust-cache.sh
|
||||
docker exec --interactive --workdir "${workspace}" "${container_id}" bash -s <<'WARM'
|
||||
set -euo pipefail
|
||||
rustc -vV > /opt/genarrative-ci/rust-cache/rustc.txt
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 仅消费镜像内的可信快照;所有写入留在当前容器的可写层。
|
||||
# 消费镜像内可信快照;写入留在任务容器,master push 可在任务结束后导出。
|
||||
set -euo pipefail
|
||||
|
||||
cache_root="${GENARRATIVE_CI_RUST_CACHE_ROOT:-/opt/genarrative-ci/rust-cache}"
|
||||
@@ -22,7 +22,7 @@ configure_local_cache() {
|
||||
case "${1:-}" in
|
||||
prepare)
|
||||
: "${GITHUB_ENV:?GITHUB_ENV is required}"
|
||||
printf 'RUSTC_WRAPPER=\nCARGO_BUILD_RUSTC_WRAPPER=\nGENARRATIVE_CI_RUST_CACHE_STATE=\n' >> "${GITHUB_ENV}"
|
||||
printf 'RUSTC_WRAPPER=\nCARGO_BUILD_RUSTC_WRAPPER=\nGENARRATIVE_CI_RUST_CACHE_STATE=\nGENARRATIVE_CI_RUST_CACHE_EXPORT_READY=\n' >> "${GITHUB_ENV}"
|
||||
fallback() { printf '[rust-cache] mode=direct reason=%s\n' "$1"; exit 0; }
|
||||
[[ -x "${cache_binary}" && -d "${cache_root}/objects" && -f "${cache_root}/rustc.txt" && -f "${cache_root}/source-commit.txt" && -f "${cache_root}/workspace.txt" ]] \
|
||||
|| fallback snapshot-unavailable
|
||||
@@ -44,6 +44,10 @@ case "${1:-}" in
|
||||
fallback wrapper-probe-failed
|
||||
fi
|
||||
script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/$(basename "${BASH_SOURCE[0]}")"
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == push && "${GITHUB_REF:-}" == refs/heads/master ]]; then
|
||||
# 只记 key/大小/时间,不读取对象正文;PR 没有扫描与打包开销。
|
||||
python3 "$(dirname "${script_path}")/export-gitea-rust-cache.py" baseline "${state}/baseline.json"
|
||||
fi
|
||||
# wrapper 路径也参与 Rust cache key;固定容器内路径,隔离由 job 容器保证。
|
||||
wrapper_path="${cache_root}/rustc-wrapper"
|
||||
printf '#!/usr/bin/env bash\nexec bash %q "$@"\n' "${script_path}" > "${wrapper_path}"
|
||||
@@ -59,7 +63,13 @@ case "${1:-}" in
|
||||
if [[ -n "${state}" && -d "${state}" ]]; then
|
||||
configure_local_cache
|
||||
timeout --kill-after=2 5 "${cache_binary}" --show-stats || true
|
||||
timeout --kill-after=2 5 "${cache_binary}" --stop-server >/dev/null 2>&1 || true
|
||||
# 只有成功停服后才允许读取对象;失败不能把仍在写入的目录发布成完整快照。
|
||||
if timeout --kill-after=2 15 "${cache_binary}" --stop-server >/dev/null 2>&1; then
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == push && "${GITHUB_REF:-}" == refs/heads/master && ! -f "${state}/disabled" && -f "${state}/baseline.json" ]]; then
|
||||
mv -- "${state}/baseline.json" "${cache_root}/export-baseline.json"
|
||||
printf 'GENARRATIVE_CI_RUST_CACHE_EXPORT_READY=1\n' >> "${GITHUB_ENV}"
|
||||
fi
|
||||
fi
|
||||
rm -rf -- "${state}"
|
||||
else
|
||||
printf '[rust-cache] mode=direct\n'
|
||||
|
||||
@@ -35,7 +35,8 @@ function fixture(t) {
|
||||
`#!/bin/bash
|
||||
set -eu
|
||||
case "$1" in
|
||||
--stop-server|--show-stats) exit 0 ;;
|
||||
--stop-server) exit "\${STOP_FAILURE:-0}" ;;
|
||||
--show-stats) exit 0 ;;
|
||||
esac
|
||||
if [[ "$*" == *-vV ]]; then
|
||||
[[ "\${PROBE_FAILURE:-}" != 1 ]] || exit 1
|
||||
@@ -215,3 +216,25 @@ linuxTest('lost local cache state still invokes the real compiler', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run(['/bin/bash', '-c', 'exit 43']).status, 43);
|
||||
});
|
||||
|
||||
linuxTest('only a cleanly stopped master cache can export objects', (t) => {
|
||||
const master = {
|
||||
GITHUB_EVENT_NAME: 'push',
|
||||
GITHUB_REF: 'refs/heads/master',
|
||||
};
|
||||
for (const stopFailure of ['0', '1']) {
|
||||
const f = fixture(t);
|
||||
const prepared = f.run(['prepare'], master);
|
||||
assert.equal(prepared.status, 0, prepared.stderr);
|
||||
const result = f.run(['report'], {
|
||||
...f.preparedEnv(),
|
||||
...master,
|
||||
STOP_FAILURE: stopFailure,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
f.preparedEnv().GENARRATIVE_CI_RUST_CACHE_EXPORT_READY,
|
||||
stopFailure === '0' ? '1' : '',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""只把 master push 的任务内 sccache 对象导出为 Gitea Actions 产物。"""
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
|
||||
MAX_OBJECT_BYTES = 4 * 1024**3
|
||||
CHUNK_BYTES = 8 * 1024**2
|
||||
JOBS = {
|
||||
"ai-game-creator-shell-rust-lane-1",
|
||||
"ai-game-creator-shell-rust-lane-2",
|
||||
"ai-game-creator-shell-rust-smoke",
|
||||
"ai-game-creator-shell-rust-crates",
|
||||
"backend-tests",
|
||||
"native-shell-tests",
|
||||
}
|
||||
|
||||
|
||||
def object_path(name):
|
||||
parts = name.split("/")
|
||||
return (
|
||||
len(parts) == 3
|
||||
and re.fullmatch(r"[a-f0-9]{64}", parts[2]) is not None
|
||||
and parts[0] == parts[2][0]
|
||||
and parts[1] == parts[2][1]
|
||||
)
|
||||
|
||||
|
||||
class HashingReader:
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
self.digest = hashlib.sha256()
|
||||
|
||||
def read(self, size=-1):
|
||||
data = self.stream.read(size)
|
||||
self.digest.update(data)
|
||||
return data
|
||||
|
||||
|
||||
def scan_objects(root):
|
||||
objects = root / "objects"
|
||||
if objects.is_symlink() or not objects.is_dir():
|
||||
raise ValueError("cache object directory is unavailable")
|
||||
candidates = []
|
||||
for directory, directories, files in os.walk(objects, followlinks=False):
|
||||
directories[:] = [
|
||||
name for name in directories if not (Path(directory) / name).is_symlink()
|
||||
]
|
||||
for filename in files:
|
||||
path = Path(directory) / filename
|
||||
relative = path.relative_to(objects).as_posix()
|
||||
info = path.lstat()
|
||||
if object_path(relative) and stat.S_ISREG(info.st_mode):
|
||||
candidates.append((path, relative, info))
|
||||
return candidates
|
||||
|
||||
|
||||
def save_baseline(root, destination):
|
||||
# sccache 命中会更新 mtime,不能把时间变化当成内容变化,否则又变成全量上传。
|
||||
baseline = {
|
||||
relative: {"size": info.st_size, "mtime_ns": info.st_mtime_ns}
|
||||
for _, relative, info in scan_objects(root)
|
||||
}
|
||||
destination.write_text(json.dumps(baseline), encoding="utf-8")
|
||||
|
||||
|
||||
def pack_snapshot(root, destination, metadata, baseline, limit=MAX_OBJECT_BYTES):
|
||||
"""仅归档新 key;命中的已有对象只传递新近使用时间,不传输对象内容。"""
|
||||
candidates = scan_objects(root)
|
||||
candidates.sort(key=lambda item: (-item[2].st_mtime_ns, item[1]))
|
||||
entries = []
|
||||
touched = []
|
||||
total = 0
|
||||
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_STORED) as bundle, \
|
||||
bundle.open("snapshot.tar", "w", force_zip64=True) as tar_stream, \
|
||||
tarfile.open(fileobj=tar_stream, mode="w|", format=tarfile.USTAR_FORMAT) as archive:
|
||||
for path, relative, info in candidates:
|
||||
previous = baseline.get(relative)
|
||||
if previous is not None and previous["size"] == info.st_size:
|
||||
if previous["mtime_ns"] != info.st_mtime_ns:
|
||||
touched.append({"path": "objects/" + relative, "mtime_ns": info.st_mtime_ns})
|
||||
continue
|
||||
if info.st_size <= 0 or total + info.st_size > limit:
|
||||
continue
|
||||
# report 已成功停止 daemon;打开后再核实对象,避免导出变化中的文件。
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
actual = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(actual.st_mode) or (
|
||||
actual.st_size, actual.st_mtime_ns, actual.st_ino
|
||||
) != (info.st_size, info.st_mtime_ns, info.st_ino):
|
||||
raise ValueError("cache object changed during export")
|
||||
member = tarfile.TarInfo("objects/" + relative)
|
||||
member.size = info.st_size
|
||||
member.mode = 0o644
|
||||
member.mtime = int(info.st_mtime)
|
||||
reader = HashingReader(stream)
|
||||
archive.addfile(member, reader)
|
||||
entries.append({
|
||||
"path": member.name,
|
||||
"size": info.st_size,
|
||||
"sha256": reader.digest.hexdigest(),
|
||||
"mtime_ns": info.st_mtime_ns,
|
||||
})
|
||||
total += info.st_size
|
||||
manifest = dict(metadata, schema=1, mode="delta", objects=entries, touched=touched)
|
||||
data = json.dumps(manifest, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
member = tarfile.TarInfo("manifest.json")
|
||||
member.size = len(data)
|
||||
member.mode = 0o644
|
||||
archive.addfile(member, io.BytesIO(data))
|
||||
return len(entries), total
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
# runtime token 只允许发往明确配置的 Gitea 地址。
|
||||
return None
|
||||
|
||||
|
||||
def request(method, url, token, data=None, headers=None, attempts=3):
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
for attempt in range(attempts):
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
**({"Authorization": "Bearer " + token} if token else {}),
|
||||
"Content-Type": "application/json",
|
||||
**(headers or {}),
|
||||
})
|
||||
try:
|
||||
with opener.open(req, timeout=120) as response:
|
||||
body = response.read(1024 * 1024)
|
||||
return json.loads(body) if body else {}
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code not in (408, 429, 500, 502, 503, 504) or attempt == attempts - 1:
|
||||
raise RuntimeError(f"artifact {method} failed: HTTP {error.code}") from None
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError):
|
||||
if attempt == attempts - 1:
|
||||
raise RuntimeError(f"artifact {method} connection failed") from None
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
|
||||
def artifact_base_url(env):
|
||||
# .runner.address 可指向仅支持 RPC 的领取网关,不能用 GITHUB_SERVER_URL。
|
||||
repository = env["GITHUB_REPOSITORY"]
|
||||
clone_url = env["GENARRATIVE_GITEA_REPOSITORY_URL"]
|
||||
suffix = "/" + repository + ".git"
|
||||
parsed = urllib.parse.urlsplit(clone_url)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or parsed.username or parsed.password or parsed.query or parsed.fragment
|
||||
or not parsed.path.endswith(suffix)
|
||||
):
|
||||
raise ValueError("a credential-free HTTP Gitea repository URL is required")
|
||||
return urllib.parse.urlunsplit((
|
||||
parsed.scheme, parsed.netloc, parsed.path[:-len(suffix)], "", ""
|
||||
))
|
||||
|
||||
|
||||
def upload_snapshot(path, name, run_id, base_url, token):
|
||||
"""Gitea 1.26.4 原生 v4:只有完成全部块并校验 SHA256 后才发布。"""
|
||||
producer_match = re.fullmatch(r"rust-cache-v1-([a-z0-9-]+)-attempt-(0|[1-9][0-9]*)", name)
|
||||
if not producer_match or producer_match[1] not in JOBS:
|
||||
raise ValueError("unexpected cache artifact name")
|
||||
producer = f"{producer_match[1]}:{producer_match[2]}"
|
||||
endpoint = f"{base_url}/twirp/github.actions.results.api.v1.ArtifactService"
|
||||
# Gitea 从任务凭据确定 job,只要求请求中的 run ID 与任务所属 run 一致。
|
||||
identity = {"workflowRunBackendId": str(run_id), "name": name}
|
||||
created = request("POST", endpoint + "/CreateArtifact", token, json.dumps({
|
||||
**identity, "version": 4,
|
||||
# Gitea 将剩余小时向下取整成天;多给一天,服务端才实际保留至少七天。
|
||||
"expiresAt": (datetime.now(timezone.utc) + timedelta(days=8)).isoformat(),
|
||||
}).encode())
|
||||
received = urllib.parse.urlsplit(created.get("signedUploadUrl", ""))
|
||||
expected_path = urllib.parse.urlsplit(endpoint).path + "/UploadArtifact"
|
||||
if not created.get("ok") or received.path != expected_path or not received.query:
|
||||
raise ValueError("unexpected artifact upload path")
|
||||
# 服务可能返回外部 AppURL;保留签名,但只连接配置的内部 Gitea 地址。
|
||||
upload_url = endpoint + "/UploadArtifact?" + received.query
|
||||
size = path.stat().st_size
|
||||
digest = hashlib.sha256()
|
||||
blocks = []
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(CHUNK_BYTES):
|
||||
# 双层编码中的内层保留所属任务;宿主只回收带此前缀的过期未完成块。
|
||||
block = base64.b64encode(
|
||||
f"genarrative-rust-cache-v1:{producer}:{len(blocks):08d}".encode()
|
||||
).decode()
|
||||
blocks.append(block)
|
||||
digest.update(chunk)
|
||||
request("PUT", upload_url + "&" + urllib.parse.urlencode({
|
||||
"comp": "block", "blockid": block,
|
||||
}), None, chunk, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
})
|
||||
blocklist = "<BlockList>" + "".join(f"<Latest>{block}</Latest>" for block in blocks) + "</BlockList>"
|
||||
request("PUT", upload_url + "&comp=blocklist", None, blocklist.encode(), {
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
# Finalize 会消耗服务端块列表;响应不确定时不盲目重试、也不宣告成功。
|
||||
finalized = request("POST", endpoint + "/FinalizeArtifact", token, json.dumps({
|
||||
**identity, "size": str(size), "hash": "sha256:" + digest.hexdigest(),
|
||||
}).encode(), attempts=1)
|
||||
if not finalized.get("ok"):
|
||||
raise ValueError("artifact was not finalized")
|
||||
|
||||
|
||||
def export(env):
|
||||
# 双重限定:手工调用、PR 和其它分支不会扫描、打包或上传对象。
|
||||
if env.get("GITHUB_EVENT_NAME") != "push" or env.get("GITHUB_REF") != "refs/heads/master":
|
||||
return
|
||||
if env.get("GENARRATIVE_CI_RUST_CACHE_EXPORT_READY") != "1":
|
||||
print("[rust-cache] export skipped: cache daemon did not stop cleanly")
|
||||
return
|
||||
job = env.get("GITHUB_JOB")
|
||||
if job not in JOBS:
|
||||
raise ValueError("unexpected Rust cache job")
|
||||
sha = env["GITHUB_SHA"]
|
||||
if not re.fullmatch(r"[a-f0-9]{40}", sha):
|
||||
raise ValueError("a complete source SHA is required")
|
||||
run_id, attempt = int(env["GITHUB_RUN_ID"]), int(env["GITHUB_RUN_ATTEMPT"])
|
||||
if run_id <= 0 or attempt < 0:
|
||||
raise ValueError("invalid run identity")
|
||||
token = env.get("ACTIONS_RUNTIME_TOKEN") or env["GENARRATIVE_GITEA_TOKEN"]
|
||||
base_url = artifact_base_url(env)
|
||||
root = Path(env.get("GENARRATIVE_CI_RUST_CACHE_ROOT", "/opt/genarrative-ci/rust-cache"))
|
||||
baseline_path = root / "export-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
rustc = subprocess.check_output(["rustc", "-vV"], text=True)
|
||||
workspace = str(Path.cwd().resolve())
|
||||
if rustc != (root / "rustc.txt").read_text() or workspace != (root / "workspace.txt").read_text().strip():
|
||||
raise ValueError("cache provenance changed after prepare")
|
||||
metadata = {
|
||||
"repository": env["GITHUB_REPOSITORY"],
|
||||
"run_id": run_id, "run_attempt": attempt, "job": job, "source_sha": sha,
|
||||
"rustc": rustc, "workspace": workspace,
|
||||
"inherited_source_sha": (root / "source-commit.txt").read_text().strip(),
|
||||
"sccache_version": subprocess.check_output([str(root / "sccache"), "--version"], text=True).strip(),
|
||||
}
|
||||
if (root / "base-image.txt").is_file():
|
||||
metadata["base_image"] = (root / "base-image.txt").read_text().strip()
|
||||
name = f"rust-cache-v1-{job}-attempt-{attempt}"
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="ci-rust-cache-export-") as directory:
|
||||
path = Path(directory) / "snapshot.zip"
|
||||
count, size = pack_snapshot(root, path, metadata, baseline)
|
||||
upload_snapshot(path, name, run_id, base_url, token)
|
||||
finally:
|
||||
baseline_path.unlink(missing_ok=True)
|
||||
print(f"[rust-cache] artifact={name} objects={count} bytes={size} complete=true")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "baseline":
|
||||
save_baseline(Path(os.environ.get("GENARRATIVE_CI_RUST_CACHE_ROOT", "/opt/genarrative-ci/rust-cache")), Path(sys.argv[2]))
|
||||
else:
|
||||
export(os.environ)
|
||||
@@ -39,6 +39,7 @@ usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
bash scripts/gitea-ci-job-image.sh build
|
||||
bash scripts/gitea-ci-job-image.sh revision
|
||||
bash scripts/gitea-ci-job-image.sh verify [镜像引用]
|
||||
bash scripts/gitea-ci-job-image.sh load-runner [镜像引用]
|
||||
bash scripts/gitea-ci-job-image.sh export <归档路径> [镜像引用]
|
||||
@@ -65,40 +66,15 @@ verify_image() {
|
||||
|
||||
command_name="${1:-}"
|
||||
case "${command_name}" in
|
||||
build)
|
||||
image_revision="$(
|
||||
revision)
|
||||
# 与 build 的 IMAGE_REVISION 使用同一份输入顺序,用于维护器判断基础镜像是否过期。
|
||||
(
|
||||
cd "${repo_root}"
|
||||
{
|
||||
sha256sum \
|
||||
deploy/container/gitea-ci-job.Dockerfile \
|
||||
deploy/container/gitea-ci-job.Dockerfile.dockerignore \
|
||||
deploy/container/gitea-ci-checkout.sh \
|
||||
package.json \
|
||||
package-lock.json \
|
||||
apps/admin-web/package.json \
|
||||
apps/ai-game-creator-shell/package.json \
|
||||
apps/desktop-shell/package.json \
|
||||
apps/mobile-shell/package.json \
|
||||
apps/preview-deployer-web/package.json \
|
||||
packages/image-canvas-core/package.json \
|
||||
packages/image-canvas-react/package.json \
|
||||
packages/shared/package.json \
|
||||
tools/spine-json-export-validator/package.json \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.toml \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.lock \
|
||||
server-rs/Cargo.toml \
|
||||
server-rs/Cargo.lock \
|
||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||
apps/desktop-shell/src-tauri/Cargo.lock
|
||||
find server-rs/crates plugins/agc-*-editor/native/*-editor-bridge \
|
||||
\( -name Cargo.toml -o -path 'plugins/agc-*-editor/native/*-editor-bridge/*' \) \
|
||||
-type f -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 -r sha256sum
|
||||
} \
|
||||
| sha256sum \
|
||||
| awk '{ print $1 }'
|
||||
)"
|
||||
write_build_context_file_list | xargs -0 -r sha256sum | sha256sum | awk '{ print $1 }'
|
||||
)
|
||||
;;
|
||||
build)
|
||||
image_revision="$(bash "${BASH_SOURCE[0]}" revision)"
|
||||
npm_lock_sha256="$(sha256sum "${repo_root}/package-lock.json")"
|
||||
npm_lock_sha256="${npm_lock_sha256%% *}"
|
||||
server_rust_lock_sha256="$(sha256sum "${repo_root}/server-rs/Cargo.lock")"
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gitea Runner 领取屏障;控制 socket 仅供宿主缓存维护任务使用。"""
|
||||
|
||||
import http.client
|
||||
import http.server
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import zlib
|
||||
|
||||
MAX_BODY = 32 * 1024 * 1024
|
||||
HOP_HEADERS = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailer", "transfer-encoding", "upgrade", "host", "content-length",
|
||||
}
|
||||
RPC_PREFIX = "/api/actions/runner.v1.RunnerService/"
|
||||
|
||||
|
||||
def protobuf_fields(data):
|
||||
"""只解码已核对的 actions-proto-go v0.4.1 字段,不引入 protobuf 运行时。"""
|
||||
fields = {}
|
||||
offset = 0
|
||||
|
||||
def varint():
|
||||
nonlocal offset
|
||||
value = 0
|
||||
for shift in range(0, 70, 7):
|
||||
if offset >= len(data):
|
||||
raise ValueError("truncated protobuf")
|
||||
byte = data[offset]
|
||||
offset += 1
|
||||
value |= (byte & 127) << shift
|
||||
if byte < 128:
|
||||
return value
|
||||
raise ValueError("invalid protobuf varint")
|
||||
|
||||
while offset < len(data):
|
||||
tag = varint()
|
||||
number, wire = tag >> 3, tag & 7
|
||||
if not number:
|
||||
raise ValueError("invalid protobuf tag")
|
||||
if wire == 0:
|
||||
value = varint()
|
||||
elif wire in (1, 2, 5):
|
||||
length = varint() if wire == 2 else (8 if wire == 1 else 4)
|
||||
if offset + length > len(data):
|
||||
raise ValueError("truncated protobuf field")
|
||||
value = data[offset:offset + length]
|
||||
offset += length
|
||||
else:
|
||||
raise ValueError("unsupported protobuf wire type")
|
||||
fields.setdefault(number, []).append(value)
|
||||
return fields
|
||||
|
||||
|
||||
def single(fields, number, default=None):
|
||||
values = fields.get(number, [])
|
||||
if len(values) > 1:
|
||||
raise ValueError("ambiguous tracking field")
|
||||
return values[0] if values else default
|
||||
|
||||
|
||||
def rpc_fields(body, headers):
|
||||
encoding = headers.get("Content-Encoding", "identity").lower()
|
||||
if encoding == "gzip":
|
||||
with gzip.GzipFile(fileobj=io.BytesIO(body)) as stream:
|
||||
body = stream.read(MAX_BODY + 1)
|
||||
elif encoding != "identity":
|
||||
raise ValueError("unsupported RPC encoding")
|
||||
if len(body) > MAX_BODY:
|
||||
raise ValueError("decoded RPC too large")
|
||||
if headers.get("Content-Type", "").split(";", 1)[0] != "application/proto":
|
||||
raise ValueError("task tracking requires Connect protobuf")
|
||||
return protobuf_fields(body)
|
||||
|
||||
|
||||
def task_state(fields):
|
||||
nested = single(fields, 1)
|
||||
if not isinstance(nested, bytes):
|
||||
raise ValueError("missing task state")
|
||||
state = protobuf_fields(nested)
|
||||
task_id = positive_id(single(state, 1))
|
||||
result = single(state, 2, 0)
|
||||
if type(result) is not int or result not in range(5):
|
||||
raise ValueError("unknown task result")
|
||||
return task_id, result
|
||||
|
||||
|
||||
def positive_id(value):
|
||||
if type(value) is not int or not 0 < value < 2 ** 63:
|
||||
raise ValueError("invalid task ID")
|
||||
return str(value)
|
||||
|
||||
|
||||
class Gate:
|
||||
def __init__(self, directory):
|
||||
self.directory = Path(directory)
|
||||
self.directory.mkdir(parents=True, exist_ok=True)
|
||||
self.lock = threading.Lock()
|
||||
self.inflight = 0
|
||||
self.last_fetch_peer = None
|
||||
self.last_fetch_at = None
|
||||
self.tasks = {}
|
||||
self.uncertain = (self.directory / "uncertain").exists()
|
||||
try:
|
||||
if (self.directory / "tasks.json").exists():
|
||||
tasks = json.loads((self.directory / "tasks.json").read_text())
|
||||
if (not isinstance(tasks, dict) or any(
|
||||
positive_id(int(key)) != key or type(value) is not bool
|
||||
for key, value in tasks.items())):
|
||||
raise ValueError("invalid task ledger")
|
||||
self.tasks = tasks
|
||||
except (ValueError, TypeError, OSError):
|
||||
self.uncertain = True
|
||||
self.mark("uncertain")
|
||||
if (self.directory / "inflight").exists():
|
||||
self.uncertain = True
|
||||
self.mark("uncertain")
|
||||
self.paused = (self.directory / "paused").exists() or self.uncertain
|
||||
|
||||
def mark(self, name):
|
||||
with (self.directory / name).open("w", encoding="ascii") as stream:
|
||||
stream.write("1\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
self.sync_directory()
|
||||
|
||||
def sync_directory(self):
|
||||
descriptor = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
def snapshot(self):
|
||||
return {"paused": self.paused, "inflight": self.inflight,
|
||||
"active_tasks": len(self.tasks), "task_ids": sorted(self.tasks),
|
||||
"uncertain": self.uncertain, "last_fetch_peer": self.last_fetch_peer,
|
||||
"last_fetch_at": self.last_fetch_at}
|
||||
|
||||
def save_tasks(self):
|
||||
temporary = self.directory / "tasks.json.tmp"
|
||||
with temporary.open("w", encoding="ascii") as stream:
|
||||
json.dump(self.tasks, stream, sort_keys=True)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, self.directory / "tasks.json")
|
||||
self.sync_directory()
|
||||
|
||||
def fail_closed(self):
|
||||
with self.lock:
|
||||
self.uncertain = self.paused = True
|
||||
self.mark("uncertain")
|
||||
|
||||
def assigned(self, fields):
|
||||
task = single(fields, 1)
|
||||
if task is None:
|
||||
return
|
||||
if not isinstance(task, bytes):
|
||||
raise ValueError("invalid fetched task")
|
||||
task_id = positive_id(single(protobuf_fields(task), 1))
|
||||
with self.lock:
|
||||
self.tasks[task_id] = False
|
||||
# 必须先持久化,再把领取结果交给 runner;仅保存 ID,不保存 secrets。
|
||||
self.save_tasks()
|
||||
|
||||
def reported(self, method, request, response):
|
||||
if method == "UpdateLog":
|
||||
task_id = positive_id(single(request, 1))
|
||||
index = single(request, 2, 0)
|
||||
ack = single(response, 1, 0)
|
||||
no_more = single(request, 4, 0)
|
||||
if (type(index) is not int or type(ack) is not int
|
||||
or index < 0 or ack < 0 or no_more not in (0, 1)):
|
||||
raise ValueError("invalid log acknowledgement")
|
||||
finalized = no_more == 1 and ack == index + len(request.get(3, []))
|
||||
else:
|
||||
task_id, result = task_state(request)
|
||||
response_id, response_result = task_state(response)
|
||||
if response_id != task_id:
|
||||
raise ValueError("mismatched task acknowledgement")
|
||||
# 取消响应可能出现在任务执行途中,必须等 runner 自己报告终态。
|
||||
finalized = result != 0 and response_result != 0
|
||||
output_keys = {single(protobuf_fields(entry), 1, b"")
|
||||
for entry in request.get(2, [])}
|
||||
finalized = finalized and output_keys.issubset(set(response.get(2, [])))
|
||||
with self.lock:
|
||||
if task_id not in self.tasks:
|
||||
# 客户端可能未读到已成功发出的终态响应而重试;v2.0.0 仅在
|
||||
# executor 清理后的 Close 中发送终态,不为幂等重报增加墓碑账本。
|
||||
if method == "UpdateTask" and finalized:
|
||||
return
|
||||
raise ValueError("report for untracked task; idle bootstrap required")
|
||||
if method == "UpdateLog" and finalized:
|
||||
self.tasks[task_id] = True
|
||||
self.save_tasks()
|
||||
elif method == "UpdateTask" and finalized and self.tasks[task_id]:
|
||||
# act_runner Reporter.Close 在 executor 清理之后先封存日志再报终态。
|
||||
del self.tasks[task_id]
|
||||
self.save_tasks()
|
||||
|
||||
def record_fetch(self, peer):
|
||||
with self.lock:
|
||||
self.last_fetch_peer = peer
|
||||
self.last_fetch_at = time.time()
|
||||
|
||||
def control(self, action):
|
||||
with self.lock:
|
||||
if action == "pause":
|
||||
self.mark("paused")
|
||||
self.paused = True
|
||||
elif action == "resume":
|
||||
if self.uncertain:
|
||||
return {**self.snapshot(), "error": "upstream completion uncertain; operator recovery required"}
|
||||
(self.directory / "paused").unlink(missing_ok=True)
|
||||
self.sync_directory()
|
||||
self.paused = False
|
||||
elif action != "status":
|
||||
return {**self.snapshot(), "error": "unknown action"}
|
||||
return self.snapshot()
|
||||
|
||||
def enter(self):
|
||||
with self.lock:
|
||||
if self.paused or self.uncertain:
|
||||
return False
|
||||
# 必须先落盘再转发;崩溃后不能把遗留的领取请求误认为已完成。
|
||||
self.mark("inflight")
|
||||
self.inflight += 1
|
||||
return True
|
||||
|
||||
def leave(self, completed):
|
||||
with self.lock:
|
||||
self.inflight -= 1
|
||||
if not completed:
|
||||
self.uncertain = self.paused = True
|
||||
self.mark("uncertain")
|
||||
if self.inflight == 0 and not self.uncertain:
|
||||
(self.directory / "inflight").unlink(missing_ok=True)
|
||||
self.sync_directory()
|
||||
|
||||
|
||||
def read_body(stream, headers):
|
||||
"""解码 HTTP 请求;拒绝含糊 framing,不依赖下游连接关闭。"""
|
||||
encodings = headers.get_all("Transfer-Encoding", [])
|
||||
lengths = headers.get_all("Content-Length", [])
|
||||
if encodings and lengths:
|
||||
raise ValueError("ambiguous request framing")
|
||||
if len(lengths) > 1 or len(encodings) > 1:
|
||||
raise ValueError("duplicate request framing")
|
||||
|
||||
def exact(length):
|
||||
data = stream.read(length)
|
||||
if len(data) != length:
|
||||
raise ValueError("incomplete request body")
|
||||
return data
|
||||
|
||||
if not encodings:
|
||||
length = int(lengths[0]) if lengths else 0
|
||||
if not 0 <= length <= MAX_BODY:
|
||||
raise ValueError("request too large")
|
||||
return exact(length)
|
||||
if encodings[0].strip().lower() != "chunked":
|
||||
raise ValueError("unsupported transfer encoding")
|
||||
body = bytearray()
|
||||
while True:
|
||||
line = stream.readline(8193)
|
||||
if len(line) > 8192 or not line.endswith(b"\r\n"):
|
||||
raise ValueError("invalid chunk header")
|
||||
length = int(line.split(b";", 1)[0].strip(), 16)
|
||||
if length < 0 or len(body) + length > MAX_BODY:
|
||||
raise ValueError("request too large")
|
||||
if length == 0:
|
||||
trailer_size = 0
|
||||
while True:
|
||||
line = stream.readline(8193)
|
||||
trailer_size += len(line)
|
||||
if trailer_size > 8192 or not line.endswith(b"\r\n"):
|
||||
raise ValueError("invalid request trailers")
|
||||
if line == b"\r\n":
|
||||
return bytes(body)
|
||||
body.extend(exact(length))
|
||||
if exact(2) != b"\r\n":
|
||||
raise ValueError("invalid chunk terminator")
|
||||
|
||||
|
||||
class Proxy(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass # 不输出 RPC 认证头、请求内容或带认证信息的 URL。
|
||||
|
||||
def reply(self, status, body, headers=()):
|
||||
delivered = False
|
||||
try:
|
||||
self.send_response(status)
|
||||
excluded = HOP_HEADERS | {
|
||||
item.strip().lower() for key, value in headers if key.lower() == "connection"
|
||||
for item in value.split(",")
|
||||
}
|
||||
for key, value in headers:
|
||||
if key.lower() not in excluded:
|
||||
self.send_header(key, value)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
self.wfile.flush()
|
||||
delivered = True
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
self.close_connection = True
|
||||
return delivered
|
||||
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlsplit(self.path)
|
||||
if (path.scheme or path.netloc or not path.path.startswith("/api/actions/")
|
||||
or "%" in path.path or any(p in (".", "..") for p in path.path.split("/"))):
|
||||
self.reply(404, b"runner RPC only\n")
|
||||
return
|
||||
try:
|
||||
body = read_body(self.rfile, self.headers)
|
||||
except (ValueError, OSError):
|
||||
self.reply(400, b"invalid request body\n")
|
||||
return
|
||||
method = path.path.removeprefix(RPC_PREFIX) if path.path.startswith(RPC_PREFIX) else ""
|
||||
is_fetch = method == "FetchTask"
|
||||
if is_fetch:
|
||||
# 包括暂停时被拒绝的请求;只记录网络来源与时间以证明 daemon 路由。
|
||||
self.server.gate.record_fetch(self.client_address[0])
|
||||
if not self.server.gate.enter():
|
||||
self.reply(503, b"runner maintenance\n")
|
||||
return
|
||||
completed = False
|
||||
connection = None
|
||||
try:
|
||||
upstream = self.server.upstream
|
||||
cls = http.client.HTTPSConnection if upstream.scheme == "https" else http.client.HTTPConnection
|
||||
connection = cls(upstream.hostname, upstream.port, timeout=None)
|
||||
try:
|
||||
connection.connect()
|
||||
except OSError:
|
||||
# TCP/TLS 连接阶段尚未发送 RPC,不存在服务端分配事务。
|
||||
completed = True
|
||||
raise
|
||||
excluded = HOP_HEADERS | {
|
||||
name.strip().lower() for name in self.headers.get("Connection", "").split(",")
|
||||
}
|
||||
headers = {key: value for key, value in self.headers.items() if key.lower() not in excluded}
|
||||
headers["Content-Length"] = str(len(body))
|
||||
connection.request("POST", self.path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
result = bytearray()
|
||||
oversized = False
|
||||
# 客户端即使断开,也继续读取上游完整响应,再释放领取屏障。
|
||||
while block := response.read(65536):
|
||||
if len(result) + len(block) <= MAX_BODY and not oversized:
|
||||
result.extend(block)
|
||||
else:
|
||||
oversized = True
|
||||
result.clear()
|
||||
if response.length not in (None, 0):
|
||||
raise http.client.IncompleteRead(bytes(result), response.length)
|
||||
completed = True
|
||||
if is_fetch:
|
||||
try:
|
||||
if oversized or response.status != 200:
|
||||
raise ValueError("unknown FetchTask result")
|
||||
self.server.gate.assigned(rpc_fields(bytes(result), response.headers))
|
||||
except (ValueError, TypeError, OSError, EOFError, zlib.error):
|
||||
self.server.gate.fail_closed()
|
||||
self.server.gate.leave(True)
|
||||
is_fetch = False
|
||||
if oversized:
|
||||
self.reply(502, b"upstream response too large\n")
|
||||
else:
|
||||
# 日志封存先记账再返回,避免 runner 立即发终态时抢先读到旧账本。
|
||||
if method == "UpdateLog" and response.status == 200:
|
||||
self.observe_report(method, body, bytes(result), response.headers)
|
||||
delivered = self.reply(response.status, bytes(result), response.getheaders())
|
||||
if method == "UpdateTask" and response.status == 200 and delivered:
|
||||
self.observe_report(method, body, bytes(result), response.headers)
|
||||
except (OSError, http.client.HTTPException, ValueError):
|
||||
self.reply(502, b"runner upstream unavailable\n")
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
if is_fetch:
|
||||
self.server.gate.leave(completed)
|
||||
|
||||
def observe_report(self, method, request, response, headers):
|
||||
try:
|
||||
self.server.gate.reported(method, rpc_fields(request, self.headers),
|
||||
rpc_fields(response, headers))
|
||||
except (ValueError, TypeError, OSError, EOFError, zlib.error):
|
||||
self.server.gate.fail_closed()
|
||||
|
||||
|
||||
class Control(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
line = self.rfile.readline(4097)
|
||||
if len(line) > 4096 or not line.endswith(b"\n"):
|
||||
raise ValueError("invalid control request")
|
||||
request = json.loads(line)
|
||||
result = self.server.gate.control(request["action"])
|
||||
except (ValueError, KeyError, TypeError, OSError):
|
||||
result = {"error": "invalid control request"}
|
||||
self.wfile.write(json.dumps(result).encode("utf-8") + b"\n")
|
||||
|
||||
|
||||
class ControlServer(socketserver.ThreadingUnixStreamServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
def create_proxy(address, upstream, gate):
|
||||
parsed = urllib.parse.urlsplit(upstream)
|
||||
if (parsed.scheme not in ("http", "https") or not parsed.hostname
|
||||
or parsed.username or parsed.password or parsed.path not in ("", "/")
|
||||
or parsed.query or parsed.fragment):
|
||||
raise ValueError("upstream must be an HTTP(S) origin")
|
||||
server = http.server.ThreadingHTTPServer(address, Proxy)
|
||||
server.upstream = parsed
|
||||
server.gate = gate
|
||||
return server
|
||||
|
||||
|
||||
def main():
|
||||
gate = Gate(os.environ.get("GITEA_GATE_CONTROL_DIR", "/control"))
|
||||
socket_path = gate.directory / "gate.sock"
|
||||
socket_path.unlink(missing_ok=True)
|
||||
control = ControlServer(str(socket_path), Control)
|
||||
control.gate = gate
|
||||
os.chmod(socket_path, 0o600)
|
||||
proxy = create_proxy(("0.0.0.0", 8080), os.environ.get(
|
||||
"GITEA_RUNNER_UPSTREAM", "http://gitea:3000"), gate)
|
||||
threading.Thread(target=control.serve_forever, daemon=True).start()
|
||||
proxy.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
"""定向回收 Gitea 1.26.4 不会自动过期的本仓库缓存上传块。"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
|
||||
|
||||
JOBS = {
|
||||
"ai-game-creator-shell-rust-lane-1",
|
||||
"ai-game-creator-shell-rust-lane-2",
|
||||
"ai-game-creator-shell-rust-smoke",
|
||||
"ai-game-creator-shell-rust-crates",
|
||||
"backend-tests",
|
||||
"native-shell-tests",
|
||||
}
|
||||
CHUNK = re.compile(r"block-([1-9][0-9]*)-([1-9][0-9]*)-(0|[1-9][0-9]*)-([A-Za-z0-9_-]+={0,2})")
|
||||
BLOCKLIST = re.compile(r"([1-9][0-9]*)-([1-9][0-9]*)-blocklist")
|
||||
MARKER = re.compile(r"genarrative-rust-cache-v1:([a-z0-9-]+):(0|[1-9][0-9]*):([0-9]{8})")
|
||||
|
||||
|
||||
def decode_owner(encoded):
|
||||
"""Gitea 文件名编码为 URL-base64(生产者上传的标准 base64 blockid)。"""
|
||||
try:
|
||||
block_id = base64.b64decode(encoded, altchars=b"-_", validate=True)
|
||||
if base64.urlsafe_b64encode(block_id).decode() != encoded:
|
||||
return None
|
||||
marker = base64.b64decode(block_id, validate=True)
|
||||
if base64.b64encode(marker) != block_id:
|
||||
return None
|
||||
match = MARKER.fullmatch(marker.decode("ascii"))
|
||||
if match and match[1] in JOBS:
|
||||
return match[1], int(match[2])
|
||||
except (binascii.Error, UnicodeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _unchanged_old_regular(path, previous, cutoff):
|
||||
try:
|
||||
current = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return (
|
||||
stat.S_ISREG(current.st_mode)
|
||||
and current.st_mtime < cutoff
|
||||
and (current.st_ino, current.st_size, current.st_mtime_ns)
|
||||
== (previous.st_ino, previous.st_size, previous.st_mtime_ns)
|
||||
)
|
||||
|
||||
|
||||
def cleanup_upload_chunks(storage_root: Path, eligible_runs: set[int], cutoff_timestamp: float) -> int:
|
||||
"""调用方先用仓库 API 确认这些 master run 已结束超过保留期限。"""
|
||||
storage_root = Path(storage_root)
|
||||
if not storage_root.is_absolute() or storage_root.resolve(strict=True) != storage_root:
|
||||
raise ValueError("artifact storage root must be an existing absolute real directory")
|
||||
if not stat.S_ISDIR(storage_root.lstat().st_mode):
|
||||
raise ValueError("artifact storage root must be a directory")
|
||||
if not math.isfinite(cutoff_timestamp) or cutoff_timestamp <= 0:
|
||||
raise ValueError("invalid artifact cleanup cutoff")
|
||||
if any(type(run_id) is not int or run_id <= 0 for run_id in eligible_runs):
|
||||
raise ValueError("invalid eligible run ID")
|
||||
temporary = storage_root / "tmp-upload"
|
||||
if not temporary.exists() and not temporary.is_symlink():
|
||||
return 0
|
||||
if temporary.is_symlink() or not temporary.is_dir():
|
||||
raise ValueError("artifact temporary path must be a real directory")
|
||||
|
||||
deleted = 0
|
||||
for run_id in sorted(eligible_runs):
|
||||
directory = temporary / f"run-{run_id}-v4"
|
||||
if directory.is_symlink() or not directory.is_dir():
|
||||
continue
|
||||
groups = {}
|
||||
unknown = False
|
||||
for path in directory.iterdir():
|
||||
chunk = CHUNK.fullmatch(path.name)
|
||||
blocklist = BLOCKLIST.fullmatch(path.name)
|
||||
match = chunk or blocklist
|
||||
if match and int(match[1]) == run_id:
|
||||
artifact_id = int(match[2])
|
||||
owner = decode_owner(chunk[4]) if chunk else None
|
||||
owned = owner is not None if chunk else True
|
||||
else:
|
||||
# 若仍能识别 artifact ID,仅保护该组;无法识别时保护整个 run。
|
||||
prefix = re.match(rf"(?:block-)?{run_id}-([1-9][0-9]*)-", path.name)
|
||||
if not prefix:
|
||||
unknown = True
|
||||
break
|
||||
artifact_id, owner, owned = int(prefix[1]), None, False
|
||||
group = groups.setdefault(artifact_id, {"files": [], "owners": set(), "safe": True})
|
||||
try:
|
||||
info = path.lstat()
|
||||
except FileNotFoundError:
|
||||
group["safe"] = False
|
||||
continue
|
||||
group["safe"] &= owned and stat.S_ISREG(info.st_mode) and info.st_mtime < cutoff_timestamp
|
||||
if owner:
|
||||
group["owners"].add(owner)
|
||||
group["files"].append((path, info))
|
||||
if unknown:
|
||||
continue
|
||||
for group in groups.values():
|
||||
if not group["safe"] or len(group["owners"]) != 1:
|
||||
continue
|
||||
files = group["files"]
|
||||
if not all(_unchanged_old_regular(path, info, cutoff_timestamp) for path, info in files):
|
||||
continue
|
||||
# 保留目录,不使用递归删除;没有所属块证明的孤立 blocklist 也不会删除。
|
||||
for path, _ in files:
|
||||
path.unlink()
|
||||
deleted += 1
|
||||
return deleted
|
||||
File diff suppressed because it is too large
Load Diff
@@ -142,6 +142,38 @@ function backendStepIndex(stepName: string) {
|
||||
}
|
||||
|
||||
describe('project CI workflow', () => {
|
||||
it('publishes cache deltas only after Rust reporting on non-cancelled master pushes', () => {
|
||||
const producers = [
|
||||
'ai-game-creator-shell-rust-lane-1',
|
||||
'ai-game-creator-shell-rust-lane-2',
|
||||
'ai-game-creator-shell-rust-smoke',
|
||||
'ai-game-creator-shell-rust-crates',
|
||||
'backend-tests',
|
||||
'native-shell-tests',
|
||||
];
|
||||
for (const job of jobNames) {
|
||||
if (!producers.includes(job)) {
|
||||
expect(jobSection(job)).not.toContain('export-gitea-rust-cache.py');
|
||||
continue;
|
||||
}
|
||||
const publish = stepSection(job, 'Publish master Rust cache artifact');
|
||||
expect(publish).toContain(
|
||||
"if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}",
|
||||
);
|
||||
expect(publish).toContain('GENARRATIVE_GITEA_TOKEN: ${{ github.token }}');
|
||||
expect(publish).toContain('continue-on-error: true');
|
||||
expect(publish).toContain(
|
||||
'run: python3 scripts/export-gitea-rust-cache.py',
|
||||
);
|
||||
const section = jobSection(job);
|
||||
expect(
|
||||
section.indexOf('Publish master Rust cache artifact'),
|
||||
).toBeGreaterThan(
|
||||
section.indexOf('run: bash scripts/ci-rust-cache.sh report'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses isolated compilation caching for every Rust test job', () => {
|
||||
const firstRustSteps = {
|
||||
'ai-game-creator-shell-rust-lane-1':
|
||||
@@ -331,7 +363,8 @@ describe('project CI workflow', () => {
|
||||
const rustLock = 'apps/ai-game-creator-shell/src-tauri/Cargo.lock';
|
||||
|
||||
for (const workspaceManifest of workspaceManifests) {
|
||||
expect(imageBuildScript.split(workspaceManifest)).toHaveLength(3);
|
||||
// 构建上下文与 revision 共用一份清单,不重复枚举 manifest。
|
||||
expect(imageBuildScript.split(workspaceManifest)).toHaveLength(2);
|
||||
expect(imageDockerignore).toContain(`!${workspaceManifest}`);
|
||||
expect(imageDockerfile).toContain(
|
||||
`COPY ${workspaceManifest} /usr/local/share/genarrative-ci/npm/${workspaceManifest}`,
|
||||
@@ -339,8 +372,8 @@ describe('project CI workflow', () => {
|
||||
}
|
||||
|
||||
for (const [path, expectedCount] of [
|
||||
[rustManifest, 2],
|
||||
[rustLock, 3],
|
||||
[rustManifest, 1],
|
||||
[rustLock, 2],
|
||||
] as const) {
|
||||
expect(imageBuildScript.split(path)).toHaveLength(expectedCount + 1);
|
||||
expect(imageDockerignore).toContain(`!${path}`);
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Targeted regression tests for the trusted Gitea Rust-cache image builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parent.parent
|
||||
BUILDER_NAME = "build-gitea-rust-cache.sh"
|
||||
FIXED_SHA = "a" * 40
|
||||
MASTER_SHA = "b" * 40
|
||||
|
||||
|
||||
class GiteaRustCacheBuilderTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.root = Path(self.temporary_directory.name)
|
||||
self.repo = self.root / "repo"
|
||||
scripts = self.repo / "scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
shutil.copy2(REPOSITORY_ROOT / "scripts" / BUILDER_NAME, scripts / BUILDER_NAME)
|
||||
(scripts / "gitea-ci-job-image.sh").write_text("#!/usr/bin/env bash\nexit 0\n")
|
||||
(scripts / "ci-rust-cache.sh").write_text("#!/usr/bin/env bash\nexit 0\n")
|
||||
self.trace = self.root / "trace.log"
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.write_fake_tools()
|
||||
self.execution_bin = self.wsl_path(self.bin)
|
||||
self.wsl_fake_root: str | None = None
|
||||
if os.name == "nt":
|
||||
self.wsl_fake_root = f"/tmp/gitea-cache-builder-{self.root.name}"
|
||||
subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"rm -rf {root}; mkdir -p {root}/bin; cp {source}/* {root}/bin/; chmod +x {root}/bin/*".format(
|
||||
root=shlex.quote(self.wsl_fake_root),
|
||||
source=shlex.quote(self.wsl_path(self.bin)),
|
||||
),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
self.execution_bin = f"{self.wsl_fake_root}/bin"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if self.wsl_fake_root is not None:
|
||||
subprocess.run(
|
||||
["bash", "-c", f"rm -rf {shlex.quote(self.wsl_fake_root)}"],
|
||||
check=False,
|
||||
)
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def write_tool(self, name: str, source: str) -> None:
|
||||
target = self.bin / name
|
||||
target.write_bytes(textwrap.dedent(source).encode("utf-8"))
|
||||
target.chmod(target.stat().st_mode | stat.S_IXUSR)
|
||||
|
||||
def write_fake_tools(self) -> None:
|
||||
self.write_tool(
|
||||
"git",
|
||||
"""#!/usr/bin/env bash
|
||||
set -eu
|
||||
printf 'git:%s\\n' "$*" >> "$TRACE"
|
||||
if [[ "$1" == '-C' ]]; then shift 2; fi
|
||||
case "$1" in
|
||||
fetch|cat-file) exit 0 ;;
|
||||
merge-base)
|
||||
[[ "${FAKE_NON_ANCESTOR:-}" != 1 ]]
|
||||
exit
|
||||
;;
|
||||
rev-parse)
|
||||
if [[ "$2" == 'FETCH_HEAD^{commit}' ]]; then
|
||||
printf '%s\\n' "$FAKE_MASTER_SHA"
|
||||
else
|
||||
printf '%s\\n' "$FAKE_FIXED_SHA"
|
||||
fi
|
||||
;;
|
||||
archive) printf 'archived %s\\n' "$2" ;;
|
||||
*) echo "unexpected git command: $*" >&2; exit 9 ;;
|
||||
esac
|
||||
""",
|
||||
)
|
||||
self.write_tool(
|
||||
"docker",
|
||||
"""#!/usr/bin/env bash
|
||||
set -eu
|
||||
# Drain the archive pipeline before logging to avoid concurrent
|
||||
# appends to the shared trace on Windows/WSL filesystems.
|
||||
if [[ "$1" == cp && "$2" == '-' ]]; then
|
||||
cat >/dev/null
|
||||
fi
|
||||
printf 'docker:%s\\n' "$*" >> "$TRACE"
|
||||
if [[ "$1" == image && "$2" == inspect ]]; then
|
||||
printf 'sha256:%064d\\n' 0
|
||||
elif [[ "$1" == run && " $* " == *' --detach '* ]]; then
|
||||
printf 'fake-container\\n'
|
||||
elif [[ "$1" == exec && " $* " == *' bash -s '* ]]; then
|
||||
cat >/dev/null
|
||||
fi
|
||||
""",
|
||||
)
|
||||
self.write_tool(
|
||||
"curl",
|
||||
"""#!/usr/bin/env bash
|
||||
set -eu
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
if [[ "$1" == --output ]]; then touch "$2"; exit 0; fi
|
||||
shift
|
||||
done
|
||||
exit 9
|
||||
""",
|
||||
)
|
||||
self.write_tool("sha256sum", "#!/usr/bin/env bash\ncat >/dev/null\n")
|
||||
self.write_tool(
|
||||
"tar",
|
||||
"""#!/usr/bin/env bash
|
||||
set -eu
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
if [[ "$1" == --directory ]]; then
|
||||
mkdir -p "$2/sccache-v0.18.0-x86_64-unknown-linux-musl"
|
||||
printf '#!/usr/bin/env bash\\nexit 0\\n' > "$2/sccache-v0.18.0-x86_64-unknown-linux-musl/sccache"
|
||||
chmod +x "$2/sccache-v0.18.0-x86_64-unknown-linux-musl/sccache"
|
||||
exit 0
|
||||
fi
|
||||
shift
|
||||
done
|
||||
exit 9
|
||||
""",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def wsl_path(path: Path) -> str:
|
||||
value = path.resolve().as_posix()
|
||||
if len(value) >= 3 and value[1] == ":":
|
||||
return f"/mnt/{value[0].lower()}{value[2:]}"
|
||||
return value
|
||||
|
||||
def run_builder(self, *arguments: str, non_ancestor: bool = False) -> subprocess.CompletedProcess[str]:
|
||||
script_path = self.wsl_path(self.repo / "scripts" / BUILDER_NAME)
|
||||
assignments = {
|
||||
"TRACE": self.wsl_path(self.trace),
|
||||
"FAKE_FIXED_SHA": FIXED_SHA,
|
||||
"FAKE_MASTER_SHA": MASTER_SHA,
|
||||
"FAKE_NON_ANCESTOR": "1" if non_ancestor else "",
|
||||
}
|
||||
exports = "; ".join(
|
||||
f"export {name}={shlex.quote(value)}" for name, value in assignments.items()
|
||||
)
|
||||
command = (
|
||||
f"unset CI; {exports}; export PATH={shlex.quote(self.execution_bin)}:\"$PATH\"; cd /; "
|
||||
f"exec bash {shlex.quote(script_path)} "
|
||||
+ " ".join(shlex.quote(argument) for argument in arguments)
|
||||
)
|
||||
return subprocess.run(
|
||||
["bash", "-c", command],
|
||||
env=os.environ,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def trace_text(self) -> str:
|
||||
return self.trace.read_bytes().decode("utf-8", errors="replace")
|
||||
|
||||
def test_archives_requested_master_ancestor_and_uses_its_helper(self) -> None:
|
||||
result = self.run_builder("trusted-base", "candidate", FIXED_SHA)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(f"snapshot_source={FIXED_SHA}", result.stdout)
|
||||
trace = self.trace_text()
|
||||
self.assertIn(f"archive {FIXED_SHA}", trace)
|
||||
self.assertIn(
|
||||
"docker:exec fake-container cp "
|
||||
"/workspace/GenarrativeAI/Genarrative/scripts/ci-rust-cache.sh "
|
||||
"/tmp/ci-rust-cache.sh",
|
||||
trace,
|
||||
)
|
||||
|
||||
def test_rejects_commit_outside_fetched_master(self) -> None:
|
||||
result = self.run_builder("trusted-base", "candidate", FIXED_SHA, non_ancestor=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("not contained in fetched master", result.stderr)
|
||||
self.assertNotIn(" archive ", self.trace_text())
|
||||
|
||||
def test_rejects_non_full_sha_before_calling_external_tools(self) -> None:
|
||||
result = self.run_builder("trusted-base", "candidate", "abc123")
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("complete 40-character SHA", result.stderr)
|
||||
self.assertFalse(self.trace.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,205 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,347 @@
|
||||
"""使用真实 HTTP/socket 验证领取屏障,不连接线上 Gitea。"""
|
||||
|
||||
import http.client
|
||||
import http.server
|
||||
import gzip
|
||||
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"
|
||||
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):
|
||||
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 = 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)
|
||||
|
||||
|
||||
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.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]
|
||||
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,
|
||||
headers={"Content-Type": "application/proto"})
|
||||
else:
|
||||
connection.request("POST", path, body=body,
|
||||
headers={"Content-Type": "application/proto"})
|
||||
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(PING)[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(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)
|
||||
|
||||
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(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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for streamed Gitea sccache snapshot validation and merging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("gitea_cache_snapshot.py")
|
||||
SPEC = importlib.util.spec_from_file_location("gitea_cache_snapshot", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
snapshot = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = snapshot
|
||||
SPEC.loader.exec_module(snapshot)
|
||||
|
||||
REPOSITORY = "team/project"
|
||||
SOURCE_SHA = "a" * 40
|
||||
INHERITED_SOURCE_SHA = "c" * 40
|
||||
BASE_IMAGE = "sha256:" + "b" * 64
|
||||
RUSTC = "rustc 1.90.0\nbinary: rustc\n"
|
||||
WORKSPACE = "/workspace/GenarrativeAI/Genarrative"
|
||||
SCCACHE_VERSION = "0.18.0"
|
||||
|
||||
|
||||
def object_path(character: str) -> str:
|
||||
key = character * 64
|
||||
return f"objects/{key[0]}/{key[1]}/{key}"
|
||||
|
||||
|
||||
class SnapshotMergeTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.root = Path(self.temporary_directory.name).resolve()
|
||||
self.base = self.root / "base-objects"
|
||||
self.base.mkdir()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def identity(self, job: str, run_id: int) -> object:
|
||||
return snapshot.ArtifactIdentity(REPOSITORY, run_id, 1, job, SOURCE_SHA)
|
||||
|
||||
def archive(
|
||||
self,
|
||||
name: str,
|
||||
job: str,
|
||||
run_id: int,
|
||||
objects: list[tuple[str, bytes, int]],
|
||||
*,
|
||||
prefix: bool = True,
|
||||
manifest_update=None,
|
||||
extra_member: tuple[str, bytes, str] | None = None,
|
||||
touched: list[tuple[str, int]] | None = None,
|
||||
) -> object:
|
||||
rows = [
|
||||
{
|
||||
"path": path,
|
||||
"size": len(contents),
|
||||
"sha256": hashlib.sha256(contents).hexdigest(),
|
||||
"mtime_ns": mtime_ns,
|
||||
}
|
||||
for path, contents, mtime_ns in objects
|
||||
]
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"repository": REPOSITORY,
|
||||
"run_id": run_id,
|
||||
"run_attempt": 1,
|
||||
"job": job,
|
||||
"source_sha": SOURCE_SHA,
|
||||
"base_image": BASE_IMAGE,
|
||||
"rustc": RUSTC,
|
||||
"workspace": WORKSPACE,
|
||||
"sccache_version": SCCACHE_VERSION,
|
||||
"mode": "delta",
|
||||
"inherited_source_sha": INHERITED_SOURCE_SHA,
|
||||
"objects": rows,
|
||||
"touched": [
|
||||
{"path": path, "mtime_ns": mtime_ns}
|
||||
for path, mtime_ns in (touched or [])
|
||||
],
|
||||
}
|
||||
if manifest_update is not None:
|
||||
manifest_update(manifest)
|
||||
|
||||
tar_bytes = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_bytes, mode="w") as bundle:
|
||||
for path, contents, _ in objects:
|
||||
info = tarfile.TarInfo(path)
|
||||
info.size = len(contents)
|
||||
bundle.addfile(info, io.BytesIO(contents))
|
||||
if extra_member is not None:
|
||||
path, contents, kind = extra_member
|
||||
info = tarfile.TarInfo(path)
|
||||
if kind == "symlink":
|
||||
info.type = tarfile.SYMTYPE
|
||||
info.linkname = "manifest.json"
|
||||
bundle.addfile(info)
|
||||
else:
|
||||
info.size = len(contents)
|
||||
bundle.addfile(info, io.BytesIO(contents))
|
||||
manifest_bytes = json.dumps(manifest, separators=(",", ":")).encode()
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size = len(manifest_bytes)
|
||||
bundle.addfile(info, io.BytesIO(manifest_bytes))
|
||||
|
||||
archive = self.root / f"{name}.zip"
|
||||
member = f"{name}/snapshot.tar" if prefix else "snapshot.tar"
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as bundle:
|
||||
bundle.writestr(member, tar_bytes.getvalue())
|
||||
return snapshot.ArtifactInput(archive, self.identity(job, run_id))
|
||||
|
||||
def base_object(self, path: str, contents: bytes, mtime_ns: int) -> None:
|
||||
target = self.base.joinpath(*Path(path).parts[1:])
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(contents)
|
||||
os.utime(target, ns=(mtime_ns, mtime_ns))
|
||||
|
||||
def merge(self, inputs, output=None, maximum=1024 ** 3):
|
||||
return snapshot.merge_snapshots(
|
||||
inputs,
|
||||
output or self.root / "merged",
|
||||
base_objects=self.base,
|
||||
expected_inherited_source_sha=INHERITED_SOURCE_SHA,
|
||||
max_combined_bytes=maximum,
|
||||
)
|
||||
|
||||
def test_merges_groups_deduplicates_newest_mtime_and_prunes_to_bound(self) -> None:
|
||||
first_path = object_path("a")
|
||||
second_path = object_path("b")
|
||||
third_path = object_path("c")
|
||||
inherited_path = object_path("d")
|
||||
self.base_object(inherited_path, b"dd", 50)
|
||||
inputs = [
|
||||
self.archive("first", "Backend tests", 41, [
|
||||
(first_path, b"aaaa", 100),
|
||||
(second_path, b"bbbb", 200),
|
||||
]),
|
||||
self.archive("second", "Native shell tests", 41, [
|
||||
(first_path, b"aaaa", 300),
|
||||
(third_path, b"ccc", 250),
|
||||
], prefix=False, touched=[(inherited_path, 400)]),
|
||||
]
|
||||
|
||||
output = self.root / "merged"
|
||||
result = self.merge(inputs, output, maximum=9)
|
||||
|
||||
self.assertEqual((result.object_count, result.total_bytes), (3, 9))
|
||||
self.assertEqual((output / inherited_path).read_bytes(), b"dd")
|
||||
self.assertEqual((output / first_path).read_bytes(), b"aaaa")
|
||||
self.assertEqual((output / third_path).read_bytes(), b"ccc")
|
||||
self.assertFalse((output / second_path).exists())
|
||||
self.assertEqual((output / "rustc.txt").read_text(), RUSTC)
|
||||
self.assertEqual((output / "workspace.txt").read_text(), WORKSPACE + "\n")
|
||||
self.assertEqual((output / "source-commit.txt").read_text(), SOURCE_SHA + "\n")
|
||||
self.assertEqual((output / "base-image.txt").read_text(), BASE_IMAGE + "\n")
|
||||
self.assertFalse((output / "sccache").exists())
|
||||
|
||||
def test_rejects_traversal_member_without_creating_output(self) -> None:
|
||||
item = self.archive(
|
||||
"traversal", "Backend tests", 42, [],
|
||||
extra_member=("../outside", b"bad", "file"),
|
||||
)
|
||||
output = self.root / "merged"
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "invalid sccache object path"):
|
||||
self.merge([item], output)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertFalse((self.root / "outside").exists())
|
||||
|
||||
def test_rejects_checksum_mismatch(self) -> None:
|
||||
path = object_path("d")
|
||||
|
||||
def corrupt(manifest):
|
||||
manifest["objects"][0]["sha256"] = "0" * 64
|
||||
|
||||
item = self.archive(
|
||||
"checksum", "Backend tests", 43, [(path, b"content", 1)],
|
||||
manifest_update=corrupt,
|
||||
)
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "checksums do not match"):
|
||||
self.merge([item])
|
||||
|
||||
def test_rejects_manifest_identity_mismatch(self) -> None:
|
||||
item = self.archive(
|
||||
"identity", "Backend tests", 44, [],
|
||||
manifest_update=lambda manifest: manifest.update(run_id=999),
|
||||
)
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "run_id does not match"):
|
||||
self.merge([item])
|
||||
|
||||
def test_rejects_conflicting_duplicate_key(self) -> None:
|
||||
path = object_path("e")
|
||||
inputs = [
|
||||
self.archive("conflict-one", "Backend tests", 45, [(path, b"one", 1)]),
|
||||
self.archive("conflict-two", "Native shell tests", 45, [(path, b"two", 2)]),
|
||||
]
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "conflicting content"):
|
||||
self.merge(inputs)
|
||||
|
||||
def test_rejects_delta_that_conflicts_with_inherited_key(self) -> None:
|
||||
path = object_path("f")
|
||||
self.base_object(path, b"base", 1)
|
||||
item = self.archive("base-conflict", "Backend tests", 46, [(path, b"evil", 2)])
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "conflicting content"):
|
||||
self.merge([item])
|
||||
|
||||
def test_accepts_empty_delta(self) -> None:
|
||||
item = self.archive("empty", "Backend tests", 47, [])
|
||||
output = self.root / "merged"
|
||||
result = self.merge([item], output)
|
||||
self.assertEqual((result.object_count, result.total_bytes), (0, 0))
|
||||
self.assertEqual(list((output / "objects").iterdir()), [])
|
||||
|
||||
def test_rejects_noncanonical_base_entry(self) -> None:
|
||||
(self.base / "unexpected").write_bytes(b"not an object")
|
||||
item = self.archive("base-path", "Backend tests", 48, [])
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "noncanonical entry"):
|
||||
self.merge([item])
|
||||
|
||||
def test_rejects_tar_links(self) -> None:
|
||||
item = self.archive(
|
||||
"link", "Backend tests", 49, [],
|
||||
extra_member=(object_path("9"), b"", "symlink"),
|
||||
)
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "regular file"):
|
||||
self.merge([item])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""缓存上传残块清理只触碰已确认结束的过期自有文件。"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from gitea_cache_upload_cleanup import cleanup_upload_chunks, decode_owner
|
||||
|
||||
|
||||
class UploadCleanupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name).resolve()
|
||||
|
||||
def file(self, name, run=12, age=10):
|
||||
directory = self.root / "tmp-upload" / f"run-{run}-v4"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / name
|
||||
path.write_bytes(b"block data")
|
||||
os.utime(path, (age, age))
|
||||
return path
|
||||
|
||||
def block(self, artifact=34, run=12, age=10, marker="genarrative-rust-cache-v1:backend-tests:1:00000000"):
|
||||
encoded = base64.urlsafe_b64encode(base64.b64encode(marker.encode())).decode()
|
||||
return self.file(f"block-{run}-{artifact}-10-{encoded}", run, age)
|
||||
|
||||
def test_old_owned_blocks_and_associated_blocklist_are_deleted(self):
|
||||
block = self.block()
|
||||
blocklist = self.file("12-34-blocklist")
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12}, 20), 2)
|
||||
self.assertFalse(block.exists())
|
||||
self.assertFalse(blocklist.exists())
|
||||
self.assertTrue(block.parent.is_dir())
|
||||
|
||||
def test_other_runs_artifacts_and_unproven_blocklists_remain(self):
|
||||
own = self.block()
|
||||
other_run = self.block(run=13)
|
||||
other_artifact = self.block(artifact=35, marker="unrelated-upload")
|
||||
unproven = self.file("12-36-blocklist")
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12}, 20), 1)
|
||||
self.assertFalse(own.exists())
|
||||
self.assertTrue(all(path.exists() for path in (other_run, other_artifact, unproven)))
|
||||
|
||||
def test_young_or_unknown_member_protects_entire_artifact_group(self):
|
||||
own = self.block()
|
||||
young = self.file("12-34-blocklist", age=20)
|
||||
other = self.block(artifact=35)
|
||||
unknown = self.file("block-12-35-invalid-size-unknown")
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12}, 20), 0)
|
||||
self.assertTrue(all(path.exists() for path in (own, young, other, unknown)))
|
||||
|
||||
def test_completely_unknown_file_protects_entire_run(self):
|
||||
block = self.block()
|
||||
self.file("unknown")
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12}, 20), 0)
|
||||
self.assertTrue(block.exists())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "symlink fixture requires POSIX")
|
||||
def test_symlinks_never_traversed_or_deleted(self):
|
||||
block = self.block()
|
||||
outside = self.root / "secret"
|
||||
outside.write_text("keep")
|
||||
(block.parent / "12-34-blocklist").symlink_to(outside)
|
||||
(self.root / "tmp-upload" / "run-13-v4").symlink_to(block.parent, target_is_directory=True)
|
||||
self.assertEqual(cleanup_upload_chunks(self.root, {12, 13}, 20), 0)
|
||||
self.assertTrue(block.exists())
|
||||
self.assertEqual(outside.read_text(), "keep")
|
||||
linked = self.root / "root-link"
|
||||
linked.symlink_to(self.root, target_is_directory=True)
|
||||
with self.assertRaises(ValueError):
|
||||
cleanup_upload_chunks(linked, {12}, 20)
|
||||
|
||||
def test_root_missing_or_relative_is_an_error(self):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
cleanup_upload_chunks(self.root / "missing", {12}, 20)
|
||||
with self.assertRaises(ValueError):
|
||||
cleanup_upload_chunks(Path("relative"), {12}, 20)
|
||||
|
||||
def test_noncanonical_encoding_and_unknown_job_are_not_owned(self):
|
||||
for marker in ("genarrative-rust-cache-v1:other-job:1:00000000", "genarrative-rust-cache-v1:backend-tests:01:00000000"):
|
||||
encoded = base64.urlsafe_b64encode(base64.b64encode(marker.encode())).decode()
|
||||
self.assertIsNone(decode_owner(encoded))
|
||||
self.assertIsNone(decode_owner("not-base64"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user