256 lines
8.7 KiB
Python
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())
|