Split ctf_status.py into a tracker package, config from .env only

ctf_status.py is now just the command line; the work lives in tracker/.
The rules that decide whether a challenge is believed are in
tracker/checks.py, one function per rule, listed in README_CHECKS and
REPO_CHECKS. Output is unchanged.

Read the config from .env only (ctf_status.env.example -> .env.example),
let single_run_lock() hold its own lock files, and rewrite the README as
a short overview of what the repo does and how to run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 19:40:24 +02:00
co-authored by Claude Opus 5.5
parent 4c6cb21cb8
commit e00f40eaf4
15 changed files with 1098 additions and 984 deletions
+121
View File
@@ -0,0 +1,121 @@
"""GitLab REST access: the challenge repos we read, and the one file we write."""
from __future__ import annotations
import base64
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from urllib.parse import quote, urlsplit
import requests
@dataclass
class Repo:
path: str
web_url: str
ssh_url: str
last_activity: str # YYYY-MM-DD
readme: str | None # None: the repo has no README at all
id: int = 0
branch: str | None = None
# only fetched for challenges whose README claims Testing Ready or beyond
files: set[str] | None = None # every file and dir in the repo
desc: str | None = None # description.yml, None if there is none
class GitLab:
"""Access to one group, plus the one file we write elsewhere. The token needs
Project: Read (to list the group) and Repository: Read (to download files);
--challs additionally needs read + write on the deployment repo."""
def __init__(self, group_url: str, token: str, timeout: int = 20):
parts = urlsplit(group_url)
self.api = f"{parts.scheme}://{parts.netloc}/api/v4"
self.group = parts.path.strip("/")
self.timeout = timeout
self.s = requests.Session()
self.s.headers["PRIVATE-TOKEN"] = token
def _get(self, url: str, **params) -> requests.Response:
return self._req("GET", url, params=params)
def _req(self, method: str, url: str, ok: tuple = (), **kw) -> requests.Response:
r = self.s.request(method, url, timeout=self.timeout, **kw)
if r.status_code in ok:
return r
if r.status_code == 403:
# fine-grained tokens say exactly which permission is missing
try:
why = r.json().get("error_description") or r.text
except ValueError:
why = r.text
raise RuntimeError(f"gitlab refused {r.url}: {why}")
r.raise_for_status()
return r
def projects(self) -> list[dict]:
out, page = [], "1"
while page:
r = self._get(f"{self.api}/groups/{quote(self.group, safe='')}/projects",
include_subgroups="true", per_page="100", page=page,
order_by="path", sort="asc")
out += r.json()
page = r.headers.get("X-Next-Page", "")
# repos GitLab is about to delete are renamed "...-deletion_scheduled-<id>"
return [p for p in out if not p.get("archived") and not p.get("marked_for_deletion_on")]
def readme(self, p: dict) -> str | None:
branch, url = p.get("default_branch"), p.get("readme_url")
if not branch or not url or f"/-/blob/{branch}/" not in url:
return None
return self._raw(p["id"], url.split(f"/-/blob/{branch}/", 1)[1], branch)
def _raw(self, project_id: int, path: str, branch: str) -> str:
r = self._get(f"{self.api}/projects/{project_id}/repository/files/"
f"{quote(path, safe='')}/raw", ref=branch)
return r.content.decode("utf-8", "replace")
def repos(self) -> list[Repo]:
projects = self.projects()
def fetch(p: dict) -> Repo:
return Repo(path=p["path"], web_url=p["web_url"],
ssh_url=p["ssh_url_to_repo"],
last_activity=(p.get("last_activity_at") or "")[:10],
readme=self.readme(p), id=p["id"], branch=p.get("default_branch"))
with ThreadPoolExecutor(max_workers=8) as pool:
return list(pool.map(fetch, projects))
def load_files(self, repo: Repo) -> None:
"""Fill in repo.files and repo.desc, for checks.repo_blockers()."""
repo.files = set()
if not repo.branch:
return
page = "1"
while page:
r = self._get(f"{self.api}/projects/{repo.id}/repository/tree", ref=repo.branch,
recursive="true", per_page="100", page=page)
repo.files |= {t["path"] for t in r.json()}
page = r.headers.get("X-Next-Page", "")
if "description.yml" in repo.files:
repo.desc = self._raw(repo.id, "description.yml", repo.branch)
def write_file(self, repo_url: str, path: str, content: str, branch: str | None,
message) -> bool:
"""Commit `content` to `path` in the repo at `repo_url` (a web URL), creating
the file if needed; `message(old_content)` makes the commit message.
False if the file already said exactly this."""
proj = quote(urlsplit(repo_url).path.strip("/").split("/-/", 1)[0], safe="")
base = f"{self.api}/projects/{proj}"
if not branch:
branch = self._get(base).json()["default_branch"]
url = f"{base}/repository/files/{quote(path, safe='')}"
r = self._req("GET", url, ok=(404,), params={"ref": branch})
exists = r.status_code != 404
old = base64.b64decode(r.json()["content"]).decode("utf-8", "replace") if exists else ""
if exists and old == content:
return False
self._req("PUT" if exists else "POST", url,
json={"branch": branch, "content": content, "commit_message": message(old)})
return True