Merge branch 'master' into feat/agc-run-preview-tip
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m59s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled

This commit is contained in:
2026-09-22 17:28:39 +08:00
178 changed files with 27007 additions and 97 deletions
+21 -2
View File
@@ -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
File diff suppressed because it is too large Load Diff
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
/**
* 游戏发行来源配置门禁。
*
* 逐条校验 `deploy/nginx/genarrative-release-origin.conf`
* 1) 每游戏独立 origin 的按主机映射(命名捕获 `game_id` + 发行网关前缀);
* 2) 只暴露发行网关,不代理平台 API / 后台 / SPA
* 3) 发行来源不使用 Cookie(边缘 403 + 转发前清空);
* 4) 响应头策略仍由 api-server 发行网关负责(源码级交叉检查)。
* 只要本机存在 nginx 与 openssl,还会用自签通配证书渲染一份临时配置执行
* `nginx -t`,把语法与指令上下文一起验证掉。
*/
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDir, '..');
const templatePath = join(
repoRoot,
'deploy/nginx/genarrative-release-origin.conf',
);
const gatewayPath = join(
repoRoot,
'server-rs/crates/api-server/src/modules/game_distribution.rs',
);
const failures = [];
const notes = [];
function fail(message) {
failures.push(message);
}
function normalize(source) {
return source.replace(/\s+/gu, ' ');
}
function requireSnippet(source, snippet, message) {
if (!normalize(source).includes(normalize(snippet))) {
fail(message);
}
}
function main() {
if (!existsSync(templatePath)) {
fail(`缺少发行来源模板:${templatePath}`);
return;
}
const template = readFileSync(templatePath, 'utf8');
requireSnippet(
template,
'server_name ~^(?<game_id>[a-z0-9_]+)\\.games\\.example\\.com$;',
'发行来源必须用命名捕获 game_id 的子域匹配(每游戏独立 origin)',
);
requireSnippet(
template,
'ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem;',
'发行来源必须使用通配 TLS 证书',
);
requireSnippet(
template,
'if ($http_cookie) { return 403; }',
'发行来源必须拒绝携带平台 Cookie 的请求',
);
requireSnippet(
template,
'proxy_set_header Cookie "";',
'发行来源转发前必须清空 Cookie',
);
requireSnippet(
template,
'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri;',
'发行来源必须按 game_id 映射到发行网关前缀',
);
requireSnippet(
template,
'location /.well-known/acme-challenge/',
'发行来源必须保留 ACME challenge 路径',
);
requireSnippet(
template,
'location = / {',
'发行来源必须显式把子域根路径映射为该游戏的 index.html',
);
requireSnippet(
template,
'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html;',
'子域根路径必须映射到该游戏的 index.html',
);
const proxyPassCount = (template.match(/proxy_pass\s/gu) ?? []).length;
if (proxyPassCount !== 2) {
fail(
`发行来源只应存在两条 proxy_pass(子域根路径与发行网关前缀),实际 ${proxyPassCount}`,
);
}
const cookieStripCount = (
template.match(/proxy_set_header Cookie "";/gu) ?? []
).length;
if (cookieStripCount !== 2) {
fail(`每条发行来源代理都必须清空 Cookie,实际 ${cookieStripCount}`);
}
const gatewayPrefixCount = (
template.match(/api\/game-distribution\/releases\/\$game_id/gu) ?? []
).length;
if (gatewayPrefixCount !== 2) {
fail(`发行来源代理必须都映射到发行网关前缀,实际 ${gatewayPrefixCount}`);
}
for (const forbidden of [
'/api/auth',
'/api/profile',
'/admin/api',
'/api/game-distribution/games',
'/api/game-distribution/versions',
]) {
if (template.includes(forbidden)) {
fail(`发行来源不得代理平台命名空间:${forbidden}`);
}
}
if (!existsSync(gatewayPath)) {
fail(`缺少发行网关源码:${gatewayPath}`);
} else {
const gateway = readFileSync(gatewayPath, 'utf8');
for (const [snippet, message] of [
[
'header::X_CONTENT_TYPE_OPTIONS',
'发行网关必须继续设置 X-Content-Type-Options',
],
[
'HeaderName::from_static("cross-origin-resource-policy")',
'发行网关必须继续设置 CORP',
],
[
'HeaderValue::from_static("cross-origin")',
'CORP 必须是 cross-originopaque sandbox 才能加载自有脚本)',
],
[
'header::ACCESS_CONTROL_ALLOW_ORIGIN',
'发行网关必须继续设置无凭据 CORS',
],
['header::CONTENT_SECURITY_POLICY', '发行网关必须继续为 HTML 设置 CSP'],
['StatusCode::FORBIDDEN', '发行网关必须继续拒绝携带 Cookie 的请求'],
]) {
if (!gateway.includes(snippet)) {
fail(message);
}
}
}
validateWithNginx(template);
if (failures.length > 0) {
console.error('[check:release-origin-config] FAILED');
for (const message of failures) {
console.error(`- ${message}`);
}
process.exit(1);
}
for (const note of notes) {
console.log(`[check:release-origin-config] ${note}`);
}
console.log(
'[check:release-origin-config] OK(发行来源模板、网关响应头策略与 nginx 语法一致)',
);
}
function binaryExists(binary) {
try {
execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function validateWithNginx(template) {
if (!binaryExists('nginx')) {
notes.push('未找到 nginx,跳过渲染后的 nginx -t');
return;
}
const workDir = mkdtempSync(join(tmpdir(), 'genarrative-release-origin-'));
try {
const certPath = join(workDir, 'wildcard.crt');
const keyPath = join(workDir, 'wildcard.key');
if (binaryExists('openssl')) {
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-days',
'1',
'-subj',
'/CN=games.example.com',
'-addext',
'subjectAltName=DNS:*.games.example.com,DNS:games.example.com',
'-keyout',
keyPath,
'-out',
certPath,
],
{ stdio: 'ignore' },
);
} else {
notes.push('未找到 openssl,跳过渲染后的 nginx -t');
return;
}
const rendered = template
.replace(
'/etc/letsencrypt/live/games.example.com/fullchain.pem',
certPath,
)
.replace('/etc/letsencrypt/live/games.example.com/privkey.pem', keyPath)
.replace(
/\/var\/log\/nginx\/(genarrative-release\.[a-z]+\.log)/gu,
join(workDir, '$1'),
)
// 非 root 环境无法绑定 80/443;语法检查用高位端口,不改生产模板本身。
.replace('listen 80;', 'listen 18080;')
.replace('listen 443 ssl http2;', 'listen 18443 ssl http2;');
const renderedPath = join(workDir, 'release-origin.conf');
writeFileSync(renderedPath, rendered);
const wrapperPath = join(workDir, 'nginx.conf');
writeFileSync(
wrapperPath,
[
`pid ${join(workDir, 'nginx.pid')};`,
`error_log ${join(workDir, 'error.log')} warn;`,
'events { worker_connections 64; }',
'http {',
' access_log off;',
' client_body_temp_path ' + join(workDir, 'client-body') + ';',
' proxy_temp_path ' + join(workDir, 'proxy') + ';',
' fastcgi_temp_path ' + join(workDir, 'fastcgi') + ';',
' uwsgi_temp_path ' + join(workDir, 'uwsgi') + ';',
' scgi_temp_path ' + join(workDir, 'scgi') + ';',
` include ${renderedPath};`,
'}',
'',
].join('\n'),
);
try {
execFileSync('nginx', ['-t', '-c', wrapperPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
notes.push('渲染后的发行来源配置通过 nginx -t');
} catch (error) {
const stderr = error.stderr ? String(error.stderr) : '';
fail(
`渲染后的发行来源配置未通过 nginx -t:${stderr.trim() || error.message}`,
);
}
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}
main();
+13 -3
View File
@@ -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'
+24 -1
View File
@@ -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' : '',
);
}
});
+274
View File
@@ -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)
+9 -33
View File
@@ -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")"
+447
View File
@@ -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
+115
View File
@@ -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
+36 -3
View File
@@ -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}`);
+201
View File
@@ -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()
+205
View File
@@ -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()
+347
View File
@@ -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
+240
View File
@@ -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()