Files
Genarrative/scripts/maintain-gitea-rust-cache.py
T
lhk229 5c2f85c089
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (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 crates (push) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m50s
Project CI / Backend tests (push) Successful in 3m40s
Project CI / Frontend tests (push) Successful in 2m0s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m13s
Project CI / Native shell tests (push) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m34s
Project CI / AI game creator shell web tests (push) Successful in 1m32s
Project CI / Repository checks (push) Successful in 1m55s
修复 CI 缓存镜像组装的基础镜像引用
使用临时本地标签和宿主默认 builder 组装缓存镜像
组装成功或失败后清理临时标签,保持基础镜像清理归属
补充基础镜像复用、重建及失败清理测试
同步部署文档和项目共享记忆
2026-09-22 15:03:05 +00:00

986 lines
51 KiB
Python

#!/usr/bin/env python3
"""宿主专用的 Rust 缓存维护器;仅使用 Python 标准库,不在 CI job 中运行。"""
import argparse
import contextlib
import datetime
import hashlib
import http.client
import json
import os
from pathlib import Path
import re
import socket
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from gitea_cache_snapshot import (
ArtifactIdentity, ArtifactInput, SnapshotError, merge_snapshots, validate_artifact_zip,
)
from gitea_cache_upload_cleanup import cleanup_upload_chunks
IMAGE = re.compile(r"sha256:[0-9a-f]{64}\Z")
SHA = re.compile(r"[0-9a-f]{40}\Z")
LABEL = re.compile(r'(?m)^(\s*-\s*[\"\x27]?)genarrative-ci:docker://(sha256:[0-9a-f]{64})([\"\x27]?\s*)$')
JOBS = {
"AI game creator shell Rust lane 1/2",
"AI game creator shell Rust lane 2/2",
"AI game creator shell Rust smoke",
"AI game creator shell Rust crates",
"Backend tests", "Native shell tests", "Frontend tests",
"Repository checks", "AI game creator shell web tests",
}
RUST_JOB_IDS = {
"AI game creator shell Rust lane 1/2": "ai-game-creator-shell-rust-lane-1",
"AI game creator shell Rust lane 2/2": "ai-game-creator-shell-rust-lane-2",
"AI game creator shell Rust smoke": "ai-game-creator-shell-rust-smoke",
"AI game creator shell Rust crates": "ai-game-creator-shell-rust-crates",
"Backend tests": "backend-tests",
"Native shell tests": "native-shell-tests",
}
RUST_JOBS = set(RUST_JOB_IDS)
ARTIFACT_PREFIX = "rust-cache-v1-"
MAX_DOWNLOAD = 4 * 1024 ** 3 + 129 * 1024 ** 2
EXPORT_STEP = "Publish master Rust cache artifact"
DOWNLOAD_ATTEMPTS = 3
def now():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def timestamp(value):
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
def log(message):
print(f"[cache-maintenance] {message}", flush=True)
@contextlib.contextmanager
def operation(name, *, build_log=None):
"""Record long maintenance stages without exposing command arguments or API data."""
started = time.monotonic()
location = f"; build log={build_log}" if build_log is not None else ""
log(f"{name}: started{location}")
try:
yield
except Exception:
elapsed = time.monotonic() - started
log(f"{name}: failed after {elapsed:.1f}s{location}")
raise
else:
elapsed = time.monotonic() - started
log(f"{name}: completed in {elapsed:.1f}s")
def command(*args, cwd=None, data=None, env=None, output=None, timeout=120, combined=False):
result = subprocess.run(
args, cwd=cwd, input=data, text=True, env=env, timeout=timeout,
stdout=output or subprocess.PIPE,
stderr=output or (subprocess.STDOUT if combined else subprocess.PIPE),
)
if result.returncode:
# 参数、stderr 或 HTTP body 可能含凭据,只把详细构建输出写到私有日志。
raise RuntimeError(f"{Path(args[0]).name} failed (exit {result.returncode})")
return result.stdout or ""
def atomic_json(path, value):
temporary = path.with_suffix(".tmp")
with temporary.open("w", encoding="utf-8") as out:
json.dump(value, out, ensure_ascii=False, indent=2)
out.write("\n")
out.flush()
os.fsync(out.fileno())
temporary.replace(path)
if os.name == "posix":
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
def cache_inputs(tree):
"""只排除已知不参与构建的说明文档;docs/openapi 与内嵌 skill 必须参与。"""
rows = []
for row in (tree.split("\0") if "\0" in tree else tree.splitlines()):
if not row:
continue
path = row.split("\t", 1)[1]
if path.startswith("docs/") and not path.startswith("docs/openapi/"):
continue
if path in {"AGENTS.md", "README.md", "deploy/container/README.md"}:
continue
rows.append(row)
return hashlib.sha256("\n".join(rows).encode()).hexdigest()
def configured_image(config):
labels = list(LABEL.finditer(config))
if len(labels) != 1:
raise RuntimeError("expected exactly one genarrative-ci Image ID label")
return labels[0].group(2)
def replace_image(config, old, new):
if not IMAGE.fullmatch(new) or configured_image(config) != old:
raise RuntimeError("runner image changed outside maintenance; refusing overwrite")
return LABEL.sub(lambda match: match[1] + "genarrative-ci:docker://" + new + match[3], config)
def fully_passed(run, jobs):
return (
run.get("status") == "completed" and run.get("conclusion") == "success"
and len(jobs) == len(JOBS) and {job["name"] for job in jobs} == JOBS
and all(job.get("conclusion") == "success" for job in jobs)
)
def verifies_image(job, contents, image, require_cache=True):
if not re.search(r"(?m)^\S+ image: " + re.escape(image) + r"\s*$", contents):
return False
if not require_cache or job["name"] not in RUST_JOBS:
return True
# 旧分支使用了镜像却未 prepare,不算完成缓存上线验收。
hits = re.search(r"Cache hits\s+(\d+)\s*$", contents, re.M)
errors = [re.search(rf"{name}\s+(\d+)\s*$", contents, re.M)
for name in ("Cache errors", "Cache read errors", "Cache write errors")]
return ("[rust-cache] mode=sccache" in contents and hits is not None
and int(hits[1]) > 0 and all(item and int(item[1]) == 0 for item in errors))
class Api:
def __init__(self, url, token_file):
self.url = url.rstrip("/")
if not self.url.startswith("https://"):
raise ValueError("api_url must use HTTPS")
self.token_file = Path(token_file)
def request(self, path, *, method, body=None, raw=False):
token = self.token_file.read_text().strip()
if not token or "\n" in token:
raise ValueError("invalid token file")
request = urllib.request.Request(
self.url + "/" + path.lstrip("/"),
data=None if body is None else json.dumps(body).encode(),
method=method,
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
)
try:
# 不允许带 Authorization 的请求跟随跨站重定向。
opener = urllib.request.build_opener(NoRedirect())
with opener.open(request, timeout=30) as response:
content = response.read().decode()
except urllib.error.HTTPError as error:
error.close()
if method == "DELETE" and error.code == 404:
return None
raise RuntimeError(f"Gitea API HTTP {error.code}") from None
return content if raw else (json.loads(content) if content else None)
def download(self, path, destination, expected_size):
"""Fetch one artifact archive, retrying incomplete signed-URL transfers."""
if (isinstance(expected_size, bool) or not isinstance(expected_size, int)
or expected_size <= 0):
raise RuntimeError("artifact has invalid size metadata")
if expected_size > MAX_DOWNLOAD:
raise RuntimeError("artifact exceeds per-job size limit")
for attempt in range(DOWNLOAD_ATTEMPTS):
try:
self._download_once(path, destination, expected_size)
return
except RetryableArtifactDownload as error:
destination.unlink(missing_ok=True)
log(f"artifact download attempt {attempt + 1}/{DOWNLOAD_ATTEMPTS} failed: {error}")
if attempt + 1 == DOWNLOAD_ATTEMPTS:
raise RuntimeError("artifact download remained incomplete after retries") from error
time.sleep(1 << attempt)
except Exception:
destination.unlink(missing_ok=True)
raise
def _download_once(self, path, destination, expected_size):
"""Obtain a fresh signed URL and validate its complete ZIP response."""
token = self.token_file.read_text().strip()
url = self.url + "/" + path.lstrip("/")
opener = urllib.request.build_opener(NoRedirect())
request = urllib.request.Request(url, headers={"Authorization": "token " + token})
target = None
try:
try:
response = opener.open(request, timeout=60)
except urllib.error.HTTPError as error:
error.close()
if error.code not in (301, 302, 303, 307, 308):
if error.code >= 500:
raise RetryableArtifactDownload(f"artifact download HTTP {error.code}") from None
raise RuntimeError(f"artifact download HTTP {error.code}") from None
target = urllib.parse.urljoin(url, error.headers.get("Location", ""))
parsed, origin = urllib.parse.urlsplit(target), urllib.parse.urlsplit(self.url)
if (parsed.scheme != "https" or parsed.netloc != origin.netloc
or parsed.username or parsed.password or target == url):
raise RuntimeError("artifact redirect must stay on configured HTTPS Gitea origin") from None
if target is not None:
try:
response = opener.open(target, timeout=60)
except urllib.error.HTTPError as error:
error.close()
if error.code >= 500:
raise RetryableArtifactDownload(f"artifact download HTTP {error.code}") from None
raise RuntimeError(f"artifact download HTTP {error.code}") from None
with response:
content_length = getattr(response, "headers", {}).get("Content-Length")
if content_length is not None:
try:
content_length = int(content_length)
except (TypeError, ValueError):
raise RetryableArtifactDownload("artifact response has invalid Content-Length") from None
if content_length > MAX_DOWNLOAD:
raise RuntimeError("artifact exceeds per-job size limit")
if content_length != expected_size:
raise RetryableArtifactDownload(
f"artifact response size differs: expected {expected_size} bytes, "
f"Content-Length is {content_length}")
with destination.open("wb") as out:
total = 0
while chunk := response.read(1024 * 1024):
total += len(chunk)
if total > MAX_DOWNLOAD:
raise RuntimeError("artifact exceeds per-job size limit")
if total > expected_size:
raise RetryableArtifactDownload(
f"artifact response exceeds metadata: expected {expected_size} bytes, "
f"received at least {total}")
out.write(chunk)
if total != expected_size:
raise RetryableArtifactDownload(
f"artifact response is truncated: expected {expected_size} bytes, received {total}")
try:
validate_artifact_zip(destination)
except (SnapshotError, zipfile.BadZipFile):
raise RetryableArtifactDownload("artifact response is not a valid ZIP") from None
except (http.client.HTTPException, urllib.error.URLError, OSError) as error:
raise RetryableArtifactDownload("artifact transfer failed") from error
def pages(self, path, key):
separator = "&" if "?" in path else "?"
page = 1
while True:
response = self.request(f"{path}{separator}limit=50&page={page}", method="GET")
items = response[key]
yield from items
if len(items) < 50:
return
page += 1
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class RetryableArtifactDownload(RuntimeError):
"""A signed archive transfer may be retried with a newly issued URL."""
class Maintenance:
def __init__(self, config):
self.config = config
if not Path(config["state_dir"]).is_absolute():
raise ValueError("state_dir must be a dedicated absolute directory")
self.root = Path(config["state_dir"]).resolve()
if self.root == Path("/"):
raise ValueError("state_dir must be a dedicated absolute directory")
self.repo = self.root / "source"
self.runner = config.get("runner_container", "gitea-runner")
self.api = Api(config["api_url"], config["token_file"])
self.repo_api = "repos/" + config["repository"]
self.state_path = self.root / "state.json"
self.state = json.loads(self.state_path.read_text()) if self.state_path.exists() else {
"versions": [], "current": None, "rollback": None, "candidate": None,
}
def save(self):
atomic_json(self.state_path, self.state)
def docker(self, *args, inner=False, **kwargs):
prefix = ("docker", "exec", self.runner, "docker") if inner else ("docker",)
return command(*prefix, *args, **kwargs)
def image_info(self, image, inner=False):
return json.loads(self.docker("image", "inspect", image, inner=inner))[0]
def read_config(self):
return self.docker("exec", self.runner, "cat", "/data/config.yaml")
def write_config(self, content):
self.docker("exec", "--user", "0", "-i", self.runner, "sh", "-c",
"cat > /data/config.yaml", data=content)
def version(self, image):
return next(row for row in self.state["versions"] if row["image"] == image)
def adopt_current(self):
actual = configured_image(self.read_config())
if self.state["current"]:
if actual != self.state["current"]:
raise RuntimeError("runner configuration differs from recorded current image")
return
info = self.image_info(actual)
labels = info["Config"].get("Labels") or {}
source = labels.get("world.genarrative.ci.rust-cache-source")
base = labels.get("world.genarrative.ci.rust-cache-base", actual)
if source is not None and not SHA.fullmatch(source):
raise RuntimeError("invalid image source SHA")
self.state["versions"].append({
"image": actual, "source": source, "base": base, "owned": False,
"activated": "1970-01-01T00:00:00+00:00", "verified_run": None,
})
self.state["current"] = actual
self.save()
def fetch_source(self):
if self.repo.resolve() != self.repo:
raise RuntimeError("dedicated clone must not be a symlink")
marker = self.repo / ".git" / "genarrative-cache-maintenance"
if not (self.repo / ".git").exists():
if self.repo.exists():
raise RuntimeError("source directory already exists without .git")
command("git", "clone", "--no-checkout", self.config["clone_url"], str(self.repo), timeout=900)
marker.write_text("owned source checkout\n")
if not marker.is_file():
raise RuntimeError("refusing to clean a checkout not created by this maintainer")
if command("git", "remote", "get-url", "origin", cwd=self.repo).strip() != self.config["clone_url"]:
raise RuntimeError("dedicated clone origin differs from configured repository")
command("git", "fetch", "--no-tags", "origin", "refs/heads/master", cwd=self.repo, timeout=900)
sha = command("git", "rev-parse", "FETCH_HEAD^{commit}", cwd=self.repo).strip()
if not SHA.fullmatch(sha):
raise RuntimeError("invalid master SHA")
# 这是维护器专属 clone,不修改开发者工作区;编译期间不再 checkout 新提交。
command("git", "checkout", "--detach", "--force", sha, cwd=self.repo)
command("git", "clean", "-ffdx", cwd=self.repo)
tree = command("git", "ls-tree", "-rz", sha, cwd=self.repo)
return sha, cache_inputs(tree)
def build_command(self, log_file, script, *args, env=None, description=None):
with operation(description or f"run {script}", build_log=log_file):
with log_file.open("a") as out:
command("bash", str(self.repo / "scripts" / script), *args, cwd=self.repo,
env=env, output=out, timeout=7200)
def master_run(self, run):
return (run.get("path") == "project-ci.yml@refs/heads/master"
and run.get("event") == "push" and run.get("head_branch") == "master"
and SHA.fullmatch(run.get("head_sha", "")) is not None)
def source_run(self):
"""Only complete exports from the latest eligible master run; never mix runs."""
current = self.version(self.state["current"])
current_inputs = (cache_inputs(command("git", "ls-tree", "-rz", current["source"], cwd=self.repo))
if current.get("source") else None)
for run in self.api.pages(self.repo_api + "/actions/runs?branch=master&event=push", "workflow_runs"):
if (not self.master_run(run) or run.get("status") != "completed"
or run.get("conclusion") not in {"success", "failure"}
or run["id"] <= current.get("run_id", 0)):
continue
sha = run["head_sha"]
if sha == current.get("source"):
continue
ancestry = subprocess.run(["git", "merge-base", "--is-ancestor", sha, "FETCH_HEAD"],
cwd=self.repo, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if ancestry.returncode:
continue
if current.get("source"):
ancestry = subprocess.run(["git", "merge-base", "--is-ancestor", current["source"], sha],
cwd=self.repo, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if ancestry.returncode:
continue
inputs = cache_inputs(command("git", "ls-tree", "-rz", sha, cwd=self.repo))
if inputs == current_inputs:
continue
jobs = list(self.api.pages(self.repo_api + f'/actions/runs/{run["id"]}/jobs', "jobs"))
rust_jobs = [job for job in jobs if job["name"] in RUST_JOB_IDS]
if len(rust_jobs) != len(RUST_JOB_IDS) or {job["name"] for job in rust_jobs} != RUST_JOBS:
continue
if any(job.get("status") != "completed" or job.get("conclusion") not in {"success", "failure"}
or job.get("head_sha") != sha
or not any(step.get("name") == EXPORT_STEP and step.get("conclusion") == "success"
for step in job.get("steps", [])) for job in rust_jobs):
continue
artifacts = list(self.api.pages(self.repo_api + f'/actions/runs/{run["id"]}/artifacts', "artifacts"))
selected = []
images = set()
for job in rust_jobs:
name = f'{ARTIFACT_PREFIX}{RUST_JOB_IDS[job["name"]]}-attempt-{job["run_attempt"]}'
matches = [item for item in artifacts if item["name"] == name and not item.get("expired")
and item.get("workflow_run", {}).get("id") == run["id"]]
if len(matches) != 1:
break
content = self.api.request(self.repo_api + f'/actions/jobs/{job["id"]}/logs', method="GET", raw=True)
if not re.search(re.escape(f"[rust-cache] artifact={name}")
+ r" objects=\d+ bytes=\d+ complete=true", content):
break
used = set(re.findall(r"(?m)^\S+ image: (sha256:[0-9a-f]{64})\s*$", content))
if len(used) != 1:
break
images.update(used)
size = matches[0].get("size_in_bytes")
if isinstance(size, bool) or not isinstance(size, int) or size <= 0 or size > MAX_DOWNLOAD:
break
selected.append({"id": matches[0]["id"], "name": name, "size_in_bytes": size,
"job": RUST_JOB_IDS[job["name"]], "attempt": job["run_attempt"]})
if len(selected) != len(RUST_JOB_IDS) or len(images) != 1:
continue
source_image = images.pop()
if source_image not in {row["image"] for row in self.state["versions"]}:
continue
return {"run_id": run["id"], "source": sha, "inputs": inputs, "source_image": source_image,
"exports": selected}
return None
def build(self, source):
"""Assemble existing CI objects; no cargo warm-up or test execution."""
sha = source["source"]
command("git", "checkout", "--detach", "--force", sha, cwd=self.repo)
command("git", "clean", "-ffdx", cwd=self.repo)
artifact = self.root / "artifacts" / sha
artifact.mkdir(parents=True, exist_ok=True)
build_log = artifact / "build.log"
tag = "genarrative/gitea-project-ci:rust-cache-auto-" + sha
base_tag = "genarrative/gitea-project-ci:base-auto-" + sha
attempt = {**source, "tag": tag, "base_tag": base_tag, "artifact": str(artifact)}
self.state.setdefault("attempts", []).append(attempt)
self.save()
env = {**os.environ, "GENARRATIVE_GITEA_RUNNER_CONTAINER": self.runner}
env.pop("CI", None)
labels = self.image_info(source["source_image"])["Config"].get("Labels") or {}
inherited_source = labels.get("world.genarrative.ci.rust-cache-source")
base = labels.get("world.genarrative.ci.rust-cache-base")
if not IMAGE.fullmatch(base or "") or not SHA.fullmatch(inherited_source or ""):
raise RuntimeError("source image must contain a trusted cache snapshot")
revision = command("bash", "scripts/gitea-ci-job-image.sh", "revision", cwd=self.repo).strip()
base_labels = self.image_info(base)["Config"].get("Labels") or {}
if base_labels.get("com.genarrative.ci.definition-sha256") != revision:
env["GENARRATIVE_GITEA_CI_IMAGE_TAG"] = base_tag
self.build_command(build_log, "gitea-ci-job-image.sh", "build", env=env,
description="rebuild cache base image")
base = self.image_info(base_tag)["Id"]
self.state.setdefault("bases", {})[base] = base_tag
self.save()
else:
log("reuse compatible cache base image")
# 与旧缓存镜像分离;绝不把 Docker 可写层、源码或 target commit 成镜像。
with operation("validate cache base image", build_log=build_log):
self.docker("run", "--rm", "--network", "none", "--read-only", "--cap-drop=ALL",
"--entrypoint", "bash", base, "-c", "test ! -e /opt/genarrative-ci/rust-cache")
with tempfile.TemporaryDirectory(prefix="assemble-", dir=artifact) as temporary:
work = Path(temporary)
inherited = work / "inherited"
inherited.mkdir()
container = self.docker("create", source["source_image"]).strip()
try:
self.docker("cp", container + ":/opt/genarrative-ci/rust-cache/.", str(inherited), timeout=600)
finally:
self.docker("rm", "--volumes", container)
inputs = []
for export in source["exports"]:
archive = work / (str(export["id"]) + ".zip")
with operation(f"download cache artifact job={export['job']} attempt={export['attempt']}",
build_log=build_log):
self.api.download(self.repo_api + f'/actions/artifacts/{export["id"]}/zip', archive,
export["size_in_bytes"])
inputs.append(ArtifactInput(archive, ArtifactIdentity(
self.config["repository"], source["run_id"], export["attempt"], export["job"], sha)))
snapshot = work / "snapshot"
with operation(f"merge {len(inputs)} cache artifacts", build_log=build_log):
merged = merge_snapshots(inputs, snapshot, base_objects=inherited / "objects",
expected_inherited_source_sha=inherited_source)
if merged.sccache_version != "sccache 0.18.0":
raise RuntimeError("unsupported sccache version")
if merged.base_image is not None and merged.base_image != labels["world.genarrative.ci.rust-cache-base"]:
raise RuntimeError("artifact base differs from its actual source image")
with operation("validate merged snapshot against target image", build_log=build_log):
rustc = self.docker("run", "--rm", "--network", "none", "--read-only", base, "rustc", "-vV")
if rustc.strip() != merged.rustc.strip():
raise RuntimeError("artifact toolchain differs from target base image")
if merged.workspace != "/workspace/" + self.config["repository"]:
raise RuntimeError("artifact workspace differs from CI checkout")
shutil.copyfile(inherited / "sccache", snapshot / "sccache")
(snapshot / "sccache").chmod(0o755)
(snapshot / "base-image.txt").write_text(base + "\n")
# BuildKit 的 FROM 不接受裸 Image ID;维护锁内使用一个临时本地别名。
# 别名不登记为 owned base,避免把接管前的基础镜像纳入自动清理。
assembly_base = "genarrative/gitea-project-ci:assembly-base"
(work / "Dockerfile").write_text(
f"FROM {assembly_base}\nCOPY snapshot/ /opt/genarrative-ci/rust-cache/\n"
f'LABEL world.genarrative.ci.rust-cache-source="{sha}"\n'
f'LABEL world.genarrative.ci.rust-cache-base="{base}"\n')
(work / ".dockerignore").write_text("**\n!Dockerfile\n!snapshot/\n!snapshot/**\n")
with operation("assemble cache candidate image", build_log=build_log):
self.docker("image", "tag", base, assembly_base)
try:
with build_log.open("a") as out:
self.docker("build", "--builder", "default", "--pull=false", "--tag", tag,
str(work), output=out, timeout=1800)
finally:
self.docker("image", "rm", assembly_base)
self.build_command(build_log, "gitea-ci-job-image.sh", "verify", tag, env=env,
description="verify cache candidate image")
image = self.image_info(tag)["Id"]
self.state["versions"].append({**attempt, "image": image, "base": base,
"owned": True, "verified_run": None})
self.state["candidate"] = image
self.state["attempts"] = [row for row in self.state["attempts"] if row["source"] != sha]
self.save()
log(f"candidate assembled run={source['run_id']} source={sha} image={image}")
def stage_candidate(self):
candidate = self.version(self.state["candidate"])
command("git", "checkout", "--detach", "--force", candidate["source"], cwd=self.repo)
command("git", "clean", "-ffdx", cwd=self.repo)
artifact = Path(candidate["artifact"])
archive = artifact / "image.tar.zst"
sidecar = archive.with_suffix(".zst.sha256")
if (candidate.get("staged") and archive.is_file() and sidecar.is_file()
and candidate["image"] in self.docker("image", "ls", "--all", "--no-trunc", "--quiet", inner=True).split()):
log("reuse exported and loaded candidate image")
return
env = {**os.environ, "GENARRATIVE_GITEA_RUNNER_CONTAINER": self.runner}
build_log = artifact / "build.log"
if not sidecar.exists():
# 只删除登记目录中的未完成导出文件,不覆盖已验证归档。
archive.unlink(missing_ok=True)
self.build_command(build_log, "gitea-ci-job-image.sh", "export", str(archive), candidate["image"], env=env,
description="export cache candidate image")
with operation("validate exported cache candidate image", build_log=build_log):
command("sha256sum", "--check", sidecar.name, cwd=artifact, timeout=600)
self.build_command(build_log, "gitea-ci-job-image.sh", "load-runner", candidate["image"], env=env,
description="load cache candidate image into runner")
with operation("verify loaded cache candidate image", build_log=build_log):
if self.image_info(candidate["image"], inner=True)["Id"] != candidate["image"]:
raise RuntimeError("inner runner image mismatch")
candidate["staged"] = True
self.save()
def idle(self):
gate = self.gate("status")
if type(gate.get("active_tasks")) is not int or gate["active_tasks"] < 0:
raise RuntimeError("gate must support durable task tracking before automatic switching")
# 已领取但尚未建容器、正在收尾上报的任务都由入口跟踪,不查管理员 API。
return (gate.get("uncertain") is False and gate.get("active_tasks") == 0
and not self.docker("ps", "-q", "--filter", "status=running",
"--filter", "status=created", "--filter", "status=restarting",
"--filter", "status=paused", inner=True).strip())
def wait_for_idle(self, purpose):
with operation(f"wait for idle runner before {purpose}"):
ready = self.idle()
if not ready:
log(f"runner is busy; defer {purpose}")
return ready
def verify_current(self):
current = self.version(self.state["current"])
if current.get("verified_run"):
return True
for run in self.api.pages(self.repo_api + "/actions/runs?status=success&branch=master&event=push", "workflow_runs"):
if run.get("path") != "project-ci.yml@refs/heads/master" or run.get("event") != "push":
continue
sha = run.get("head_sha", "")
if not SHA.fullmatch(sha):
continue
ancestry = subprocess.run(["git", "merge-base", "--is-ancestor", sha, "FETCH_HEAD"],
cwd=self.repo, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if ancestry.returncode != 0:
continue
if timestamp(run["started_at"]) < timestamp(current["activated"]):
continue
jobs = list(self.api.pages(self.repo_api + f'/actions/runs/{run["id"]}/jobs', "jobs"))
if not fully_passed(run, jobs):
continue
if all(verifies_image(job, self.api.request(
self.repo_api + f'/actions/jobs/{job["id"]}/logs', method="GET", raw=True), current["image"],
require_cache=current.get("source") is not None,
) for job in jobs):
current["verified_run"] = run["id"]
current["verified_sha"] = sha
self.save()
log(f'current image verified by run={run["id"]}')
return True
log("waiting for a complete successful CI run using current image and cache")
return False
def protected_images(self):
protected = {self.state[key] for key in ("current", "rollback", "candidate") if self.state.get(key)}
for image in list(protected):
protected.add(self.version(image)["base"])
# 保护包括已停止容器在内的全部引用;不强制删除被 Docker 引用的镜像。
for inner in (False, True):
ids = self.docker("ps", "-aq", inner=inner).split()
for container in ids:
protected.add(self.docker("inspect", "--format", "{{.Image}}", container, inner=inner).strip())
protected.add(configured_image(self.read_config()))
return protected
def remove_owned_image(self, image, tag, protected):
if image in protected or not IMAGE.fullmatch(image):
return False
for inner in (True, False):
available = self.docker("image", "ls", "--all", "--no-trunc", "--quiet", inner=inner).split()
if image not in available:
continue
info = self.image_info(image, inner=inner)
if not set(info.get("RepoTags") or []).issubset({tag}):
log(f"retain image with additional tags: {image}")
return False
# 不使用 --force;并发创建的容器也由 Docker 的引用检查保护。
self.docker("image", "rm", image, inner=inner)
if image in self.docker("image", "ls", "--all", "--no-trunc", "--quiet", inner=inner).split():
raise RuntimeError("managed image is still present after removal")
return True
def cleanup(self):
if not self.version(self.state["current"]).get("verified_run"):
return
protected = self.protected_images()
for record in list(self.state["versions"]):
if not record.get("owned") or record["image"] in protected:
continue
expected = self.root / "artifacts" / record["source"]
if (not SHA.fullmatch(record["source"]) or Path(record["artifact"]).resolve() != expected
or expected.is_symlink() or expected.parent.is_symlink()
or record["tag"] != "genarrative/gitea-project-ci:rust-cache-auto-" + record["source"]):
raise RuntimeError("archive path outside managed source directory")
if not self.remove_owned_image(record["image"], record["tag"], protected):
continue
# 不递归删除目录,只删除本维护器创建的已知文件。
for name in ("image.tar.zst", "image.tar.zst.sha256", "build.log"):
(expected / name).unlink(missing_ok=True)
if expected.exists() and not any(expected.iterdir()):
expected.rmdir()
self.state["versions"].remove(record)
self.save()
log(f'removed old managed snapshot {record["image"]}')
for image, tag in list(self.state.get("bases", {}).items()):
used_bases = {row["base"] for row in self.state["versions"]}
if image not in used_bases and self.remove_owned_image(image, tag, protected):
del self.state["bases"][image]
self.save()
def cleanup_exports(self):
"""Only our named artifacts; keep logs/runs and every unrelated artifact."""
protected_runs = {row["run_id"] for row in self.state.get("attempts", []) if row.get("run_id")}
for row in self.state["versions"]:
if row.get("run_id") and not row.get("staged"):
protected_runs.add(row["run_id"])
if not row.get("staged"):
continue
for item in row.get("exports", []):
if item.get("deleted"):
continue
self.api.request(self.repo_api + f'/actions/artifacts/{item["id"]}', method="DELETE")
item["deleted"] = True
self.save()
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7)
# 先收集再删除,避免按页删除让下一页位置前移、漏掉旧产物。
artifacts = list(self.api.pages(self.repo_api + "/actions/artifacts", "artifacts"))
pattern = re.compile(re.escape(ARTIFACT_PREFIX) + "(" + "|".join(RUST_JOB_IDS.values())
+ r")-attempt-\d+\Z")
runs = {}
for item in artifacts:
run_id = (item.get("workflow_run") or {}).get("id")
if (not pattern.fullmatch(item["name"]) or not run_id or run_id in protected_runs
or timestamp(item["created_at"]) >= cutoff):
continue
# Artifact.workflow_run 在 Gitea 1.26.4 中只有 id/repository_id/head_sha。
if run_id not in runs:
runs[run_id] = self.api.request(self.repo_api + f"/actions/runs/{run_id}", method="GET")
run = runs[run_id]
if not self.master_run(run) or run.get("status") != "completed":
continue
self.api.request(self.repo_api + f'/actions/artifacts/{item["id"]}', method="DELETE")
def cleanup_pending_uploads(self):
# Gitea 1.26.4 的过期/DELETE API 不会清理未 finalized 的 V4 分块。
# 只处理本上传器命名的块,且 run 和文件本身均已过保留期限。
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7)
eligible = set()
protected = {row["run_id"] for row in self.state.get("attempts", []) if row.get("run_id")}
for run in self.api.pages(self.repo_api + "/actions/runs?branch=master&event=push", "workflow_runs"):
if (self.master_run(run) and run.get("status") == "completed"
and run["id"] not in protected and run.get("completed_at")
and timestamp(run["completed_at"]) < cutoff):
eligible.add(run["id"])
removed = cleanup_upload_chunks(Path(self.config["artifact_storage_dir"]), eligible, cutoff.timestamp())
if removed:
log(f"removed {removed} expired owned upload fragments")
def tick(self, retry=False):
if not self.recover_switch():
return
self.adopt_current()
self.fetch_source()
self.recover_builds()
self.cleanup_exports()
self.cleanup_pending_uploads()
if not self.verify_current():
return
self.cleanup()
if self.state.get("candidate"):
self.stage_candidate()
self.activate()
return
source = self.source_run()
if source is None:
log("waiting for a complete set of master CI cache exports")
return
log(f"selected cache source run={source['run_id']} source={source['source']}")
if not retry and self.state.get("failed_run") == source["run_id"]:
log(f'previous assembly failed at run={source["run_id"]}; waiting for new run or --retry')
return
# 下载、合并和镜像装载也消耗宿主 IO;繁忙时留给 CI,下轮再收集。
if not self.wait_for_idle("cache assembly"):
return
try:
self.build(source)
except Exception:
self.state["failed_run"] = source["run_id"]
self.save()
raise
self.state.pop("failed_run", None)
self.save()
self.stage_candidate()
self.cleanup_exports()
self.activate()
def recover_builds(self):
# 宕机可能发生在 docker build 完成之后、登记 Image ID 之前。
# 只接管预先登记的确定性 tag;恢复的候选仍须经过 stage 的 verify/load。
for attempt in list(self.state.get("attempts", [])):
artifact = self.root / "artifacts" / attempt["source"]
if (not SHA.fullmatch(attempt["source"]) or Path(attempt["artifact"]).resolve() != artifact
or artifact.is_symlink() or artifact.parent.is_symlink()):
raise RuntimeError("interrupted assembly directory is outside managed artifacts")
# 持有维护锁,只有此前中断的组装可能遗留这些私有工作目录。
for directory in artifact.glob("assemble-*"):
if directory.is_symlink() or not directory.is_dir():
raise RuntimeError("unexpected interrupted assembly entry")
shutil.rmtree(directory)
for kind in ("base_tag", "tag"):
ids = set(self.docker("image", "ls", "--no-trunc", "--quiet", attempt[kind]).split())
if not ids:
continue
if len(ids) != 1:
raise RuntimeError("ambiguous managed build tag")
image = ids.pop()
if kind == "base_tag":
self.state.setdefault("bases", {})[image] = attempt[kind]
elif not any(row["image"] == image for row in self.state["versions"]):
labels = self.image_info(image)["Config"].get("Labels") or {}
if labels.get("world.genarrative.ci.rust-cache-source") != attempt["source"]:
raise RuntimeError("interrupted build tag has unexpected source")
self.state["versions"].append({
**attempt, "image": image, "owned": True, "verified_run": None,
"base": labels["world.genarrative.ci.rust-cache-base"],
})
if self.state.get("candidate") not in (None, image):
raise RuntimeError("interrupted build conflicts with an existing candidate")
self.state["candidate"] = image
self.state["attempts"].remove(attempt)
self.save()
def recover_switch(self):
if self.state.get("switch"):
# 不能仅凭配置文件已经替换,就认定内存中的 runner 已加载新映射。
log("recovering interrupted image switch")
return self.activate()
if self.state.get("pause_owned"):
self.resume()
return True
def resume(self):
if self.state.get("pause_owned"):
if self.gate("resume").get("paused") is not False:
raise RuntimeError("runner resume could not be confirmed")
self.state.pop("pause_owned", None)
self.save()
def gate(self, action):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(10)
client.connect(self.config["gate_socket"])
client.sendall((json.dumps({"action": action}) + "\n").encode())
with client.makefile("rb") as stream:
response = json.loads(stream.readline(4096))
if "error" in response:
raise RuntimeError("runner gate refused command")
return response
def check_gate_route(self):
checkout = re.search(r"(?m)^\s+GENARRATIVE_GITEA_REPOSITORY_URL:\s*(.+?)\s*$", self.read_config())
if not checkout or checkout[1].strip("\"'") != self.config["repository_url"]:
raise RuntimeError("runner.envs must pin GENARRATIVE_GITEA_REPOSITORY_URL independently of the RPC gate")
# .runner 含认证材料,只在进程内取 address,不回显原文。
registration = json.loads(self.docker("exec", self.runner, "cat", "/data/.runner"))
if registration.get("address", "").rstrip("/") != self.config["gate_url"].rstrip("/"):
raise RuntimeError("runner is not configured to use the FetchTask gate")
info = json.loads(self.docker("inspect", self.runner))[0]
started = timestamp(info["State"]["StartedAt"]).timestamp()
addresses = {network["IPAddress"] for network in info["NetworkSettings"]["Networks"].values()}
gate = self.gate("status")
# .runner 会因 label 更新而回写;mtime 不能证明内存中的地址。
# 要求入口实际见到本次容器启动后、来自它的 FetchTask。
if gate.get("last_fetch_peer") not in addresses or (gate.get("last_fetch_at") or 0) < started:
raise RuntimeError("gate has not observed FetchTask from this runner startup")
if type(gate.get("active_tasks")) is not int or gate["active_tasks"] < 0:
raise RuntimeError("gate must support durable task tracking before automatic switching")
def activate(self):
self.check_gate_route()
candidate = self.version(self.state["candidate"])
config = self.read_config()
gate = self.gate("status")
if gate.get("paused") is not False and not self.state.get("pause_owned"):
log("runner gate paused by operator; defer switch")
return False
if not self.state.get("switch") and not self.wait_for_idle("runner switch"):
log("CI active; candidate stays staged")
return False
# 先持久化恢复意图;控制请求超时也可能已生效,ExecStopPost/下次 tick 会恢复。
self.state["pause_owned"] = True
self.save()
try:
if self.gate("pause").get("paused") is not True:
raise RuntimeError("runner pause could not be confirmed")
# 不用 FetchTask 客户端超时猜测服务端事务是否已经结束。
# 入口必须完整读完已转发的响应;不确定时拒绝自动重启。
with operation("wait for FetchTask completion before runner switch"):
for _ in range(30):
gate = self.gate("status")
if gate.get("uncertain"):
raise RuntimeError("in-flight FetchTask completion is uncertain; manual gate inspection required")
if gate.get("paused") is not True:
raise RuntimeError("runner gate unexpectedly resumed")
if gate.get("inflight") == 0:
break
time.sleep(1)
else:
log("FetchTask still in flight; defer switch")
return False
if not self.wait_for_idle("runner restart"):
log("in-flight task appeared; defer switch without stopping runner")
return False
latest_config = self.read_config()
if latest_config != config:
raise RuntimeError("runner config changed while pausing")
pending = self.state.get("switch")
if pending is None:
old = self.state["current"]
updated = replace_image(config, old, candidate["image"])
backup = self.root / "backups" / (candidate["source"] + ".yaml")
backup.parent.mkdir(parents=True, exist_ok=True)
backup.write_text(config)
pending = {"old": old, "new": candidate["image"], "backup": str(backup)}
self.state["switch"] = pending
self.save()
else:
actual = configured_image(config)
if actual not in {pending["old"], pending["new"]}:
raise RuntimeError("interrupted switch conflicts with operator configuration")
updated = replace_image(config, actual, pending["new"])
self.write_config(updated)
if self.read_config() != updated or not self.idle():
# 配置尚未重启加载,恢复旧文件即可;不干扰意外出现的 job。
self.write_config(Path(pending["backup"]).read_text())
self.state.pop("switch", None)
self.save()
log("idle check changed before restart; restored configuration")
return False
with operation("restart runner with cache candidate image"):
self.docker("restart", "--timeout", "660", self.runner, timeout=720)
started = self.docker("inspect", "--format", "{{.State.StartedAt}}", self.runner).strip()
ready = False
with operation("wait for runner image registration"):
for _ in range(30):
try:
info = self.docker("inspect", "--format", "{{.State.Status}}", self.runner).strip()
recent = self.docker("logs", "--since", started, self.runner, combined=True)
ready = (info == "running" and "declare successfully" in recent
and self.image_info(candidate["image"], inner=True)["Id"] == candidate["image"])
except RuntimeError:
ready = False
if ready:
break
time.sleep(2)
if not ready:
raise RuntimeError("runner registration not confirmed; pending switch retained for recovery")
candidate["activated"] = now()
self.state["rollback"] = pending["old"]
self.state["current"] = pending["new"]
self.state["candidate"] = None
self.state.pop("switch", None)
self.save()
log(f'activated {candidate["image"]}; awaiting real master CI validation')
return True
finally:
# 持久化 switch 保证崩溃后不会错误宣称新镜像已验证。
self.resume()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True)
parser.add_argument("--apply", action="store_true", help="执行维护;默认只检查连接和配置")
parser.add_argument("--retry", action="store_true", help="重试同一 master run 上失败的缓存组装")
parser.add_argument("--resume", action="store_true", help="仅恢复本维护器暂停的领取;用于 ExecStopPost")
args = parser.parse_args()
if os.environ.get("CI") == "true":
parser.error("run on the trusted host, outside CI jobs")
os.umask(0o077)
config = json.loads(Path(args.config).read_text())
maintenance = Maintenance(config)
if not args.apply and not args.resume:
maintenance.api.request(maintenance.repo_api + "/actions/artifacts?limit=1", method="GET")
image = configured_image(maintenance.read_config())
head = command("git", "ls-remote", config["clone_url"], "refs/heads/master").split()[0]
maintenance.check_gate_route()
cleanup_upload_chunks(Path(config["artifact_storage_dir"]), set(), time.time())
gate = maintenance.gate("status")
if gate.get("uncertain"):
raise RuntimeError("runner gate needs manual inspection")
log(f'check master={head} image={image} active_tasks={gate.get("active_tasks")}')
return
import fcntl # Linux 宿主;纯逻辑测试仍可在 Windows 上导入。
maintenance.root.mkdir(parents=True, exist_ok=True)
with (maintenance.root / "maintenance.lock").open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log("another maintenance process holds the lock")
return
# 加锁后重新读取状态,避免另一个进程完成后覆盖旧快照。
maintenance = Maintenance(config)
if args.resume:
maintenance.resume()
else:
maintenance.tick(retry=args.retry)
if __name__ == "__main__":
try:
main()
except Exception as error:
log(f"maintenance failed: {type(error).__name__}: {error}")
sys.exit(1)