Files
awoooi/ops/runner/read-public-gitea-actions-queue.py
2026-06-29 09:05:28 +08:00

256 lines
8.7 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_ACTIONS_URL = "https://gitea.wooo.work/wooo/awoooi/actions"
DEFAULT_ACTIONS_LIST_API_URL = (
"https://gitea.wooo.work/api/v1/repos/wooo/awoooi/actions/runs?limit=10"
)
DEFAULT_CD_RUN_JOBS_API_URL = (
"https://gitea.wooo.work/api/v1/repos/wooo/awoooi/actions/runs/3853/jobs"
)
SCHEMA_VERSION = "awoooi_public_gitea_actions_queue_readback_v1"
_RUN_ROW_RE = re.compile(
r'<span data-tooltip-content="([^"]+)">.*?'
r"<span><b>([^<]+)</b>:</span>([^<]+)</div>",
re.S,
)
_RUN_NAME_RE = re.compile(r"^(?P<workflow>.+)\s+#(?P<run_id>\d+)$")
_NO_MATCHING_LABEL_RE = re.compile(
r"No matching online runner with label:\s*(?P<label>[A-Za-z0-9_.:-]+)"
)
@dataclass(frozen=True)
class HttpRead:
http_status: int
text: str
def fetch_public_url(url: str, timeout_seconds: float) -> HttpRead:
request = urllib.request.Request(
url,
headers={"User-Agent": "awoooi-public-gitea-actions-readback/1.0"},
)
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
raw = response.read()
status = int(getattr(response, "status", 200))
except urllib.error.HTTPError as exc:
raw = exc.read()
status = int(exc.code)
return HttpRead(
http_status=status,
text=raw.decode("utf-8", errors="replace"),
)
def parse_visible_runs(actions_html: str) -> list[dict[str, str]]:
visible_runs: list[dict[str, str]] = []
for raw_status, raw_name, raw_kind in _RUN_ROW_RE.findall(actions_html):
name = html.unescape(raw_name).strip()
kind = html.unescape(raw_kind).strip()
status = html.unescape(raw_status).strip()
match = _RUN_NAME_RE.match(name)
workflow = match.group("workflow") if match else name
run_id = match.group("run_id") if match else ""
label_match = _NO_MATCHING_LABEL_RE.search(status)
visible_runs.append(
{
"run_id": run_id,
"workflow": workflow,
"kind": kind,
"status": status,
"no_matching_runner_label": (
label_match.group("label") if label_match else ""
),
}
)
return visible_runs
def build_readback(
*,
actions_html: str,
actions_list_http_status: int,
actions_list_payload: Any,
cd_jobs_http_status: int,
cd_jobs_payload: Any,
) -> dict[str, Any]:
visible_runs = parse_visible_runs(actions_html)
no_matching = next(
(run for run in visible_runs if run["no_matching_runner_label"]),
{},
)
cd_jobs = cd_jobs_payload if isinstance(cd_jobs_payload, dict) else {}
actions_list = actions_list_payload if isinstance(actions_list_payload, dict) else {}
actions_list_message = str(actions_list.get("message") or "")
jobs_total_count = _int(cd_jobs.get("total_count"))
readback = {
"actions_page_visible_run_count": len(visible_runs),
"actions_list_without_token_http_status": actions_list_http_status,
"actions_list_without_token_message": actions_list_message,
"cd_run_jobs_http_status": cd_jobs_http_status,
"cd_run_jobs_total_count": jobs_total_count,
"latest_visible_no_matching_runner_run_id": no_matching.get("run_id", ""),
"latest_visible_no_matching_runner_workflow": no_matching.get(
"workflow", ""
),
"latest_visible_no_matching_runner_kind": no_matching.get("kind", ""),
"latest_visible_no_matching_runner_status": no_matching.get("status", ""),
"latest_visible_no_matching_runner_label": no_matching.get(
"no_matching_runner_label", ""
),
"no_matching_online_runner_visible": bool(no_matching),
"top_visible_runs": visible_runs[:10],
}
return {
"schema_version": SCHEMA_VERSION,
"status": (
"blocked_no_matching_online_runner"
if no_matching
else "no_matching_runner_not_visible"
),
"readback": readback,
"rollups": {
"public_actions_readback_count": len(visible_runs),
"actions_list_requires_token": actions_list_http_status == 401,
"cd_run_jobs_total_count": jobs_total_count,
"no_matching_online_runner_visible": bool(no_matching),
},
"operation_boundaries": {
"public_gitea_read_only": True,
"token_required_but_not_collected": actions_list_http_status == 401,
"gitea_api_write_performed": False,
"workflow_dispatch_performed": False,
"host_write_performed": False,
"runner_registration_performed": False,
"runner_service_start_performed": False,
"secret_or_runner_token_read": False,
"github_api_used": False,
},
}
def load_json_text(text: str) -> Any:
try:
return json.loads(text)
except json.JSONDecodeError:
return {"message": text.strip()}
def load_json_file(path: Path) -> Any:
return load_json_text(path.read_text(encoding="utf-8"))
def _int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _read_text_file(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _human_summary(payload: dict[str, Any]) -> str:
readback = payload["readback"]
lines = [
f"AWOOOI_PUBLIC_GITEA_ACTIONS_QUEUE_STATUS={payload['status']}",
(
"ACTIONS_LIST_WITHOUT_TOKEN_HTTP_STATUS="
f"{readback['actions_list_without_token_http_status']}"
),
f"CD_RUN_JOBS_TOTAL_COUNT={readback['cd_run_jobs_total_count']}",
(
"NO_MATCHING_ONLINE_RUNNER_VISIBLE="
f"{int(readback['no_matching_online_runner_visible'])}"
),
(
"LATEST_NO_MATCHING_RUNNER_LABEL="
f"{readback['latest_visible_no_matching_runner_label']}"
),
"WRITE_PERFORMED=false",
"TOKEN_COLLECTED=false",
]
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Read public Gitea Actions queue state without credentials, dispatch, "
"runner registration, host access, or secret reads."
)
)
parser.add_argument("--actions-url", default=DEFAULT_ACTIONS_URL)
parser.add_argument("--actions-list-api-url", default=DEFAULT_ACTIONS_LIST_API_URL)
parser.add_argument("--cd-run-jobs-api-url", default=DEFAULT_CD_RUN_JOBS_API_URL)
parser.add_argument("--timeout-seconds", type=float, default=10.0)
parser.add_argument("--actions-html-file", type=Path)
parser.add_argument("--actions-list-json-file", type=Path)
parser.add_argument("--actions-list-http-status", type=int)
parser.add_argument("--cd-run-jobs-json-file", type=Path)
parser.add_argument("--cd-run-jobs-http-status", type=int)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
if args.actions_html_file:
actions_html = _read_text_file(args.actions_html_file)
else:
actions_html = fetch_public_url(args.actions_url, args.timeout_seconds).text
if args.actions_list_json_file:
actions_list_http_status = args.actions_list_http_status or 0
actions_list_payload = load_json_file(args.actions_list_json_file)
else:
actions_list_read = fetch_public_url(
args.actions_list_api_url,
args.timeout_seconds,
)
actions_list_http_status = actions_list_read.http_status
actions_list_payload = load_json_text(actions_list_read.text)
if args.cd_run_jobs_json_file:
cd_jobs_http_status = args.cd_run_jobs_http_status or 0
cd_jobs_payload = load_json_file(args.cd_run_jobs_json_file)
else:
cd_jobs_read = fetch_public_url(
args.cd_run_jobs_api_url,
args.timeout_seconds,
)
cd_jobs_http_status = cd_jobs_read.http_status
cd_jobs_payload = load_json_text(cd_jobs_read.text)
payload = build_readback(
actions_html=actions_html,
actions_list_http_status=actions_list_http_status,
actions_list_payload=actions_list_payload,
cd_jobs_http_status=cd_jobs_http_status,
cd_jobs_payload=cd_jobs_payload,
)
if args.json:
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
sys.stdout.write("\n")
else:
sys.stdout.write(_human_summary(payload))
return 0
if __name__ == "__main__":
raise SystemExit(main())