144 lines
4.8 KiB
Python
Executable file
144 lines
4.8 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import subprocess
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
|
|
def _read_token() -> str:
|
|
token = os.environ.get("FORGEJO_API_TOKEN", "").strip()
|
|
token_file = os.environ.get("FORGEJO_API_TOKEN_FILE", "").strip()
|
|
if not token and token_file:
|
|
token = pathlib.Path(token_file).read_text().strip()
|
|
if not token:
|
|
raise SystemExit("Forgejo API token is missing")
|
|
if token.startswith("PENDING-"):
|
|
raise SystemExit("Forgejo API token is pending")
|
|
return token
|
|
|
|
|
|
def _request(method: str, url: str, token: str) -> tuple[int, str]:
|
|
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
|
req = urllib.request.Request(url, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
body = resp.read().decode("utf-8")
|
|
return resp.getcode(), body
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode("utf-8")
|
|
return exc.code, body
|
|
|
|
|
|
def _list_runners(api_url: str, token: str, org: str | None) -> tuple[str, list[dict]]:
|
|
if org:
|
|
list_url = f"{api_url}/orgs/{org}/actions/runners"
|
|
else:
|
|
list_url = f"{api_url}/actions/runners"
|
|
status, body = _request("GET", list_url, token)
|
|
if status == 404:
|
|
return list_url, []
|
|
if status >= 400:
|
|
raise RuntimeError(f"list runners failed ({status}) {body}")
|
|
try:
|
|
runners = json.loads(body)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"invalid runner list response: {exc}") from exc
|
|
if not isinstance(runners, list):
|
|
raise RuntimeError("runner list response is not a list")
|
|
return list_url, runners
|
|
|
|
|
|
def _delete_runner(api_url: str, token: str, org: str | None, runner_id: int) -> bool:
|
|
if org:
|
|
delete_url = f"{api_url}/orgs/{org}/actions/runners/{runner_id}"
|
|
else:
|
|
delete_url = f"{api_url}/actions/runners/{runner_id}"
|
|
status, body = _request("DELETE", delete_url, token)
|
|
if status in (200, 204):
|
|
return True
|
|
print(f"[forgejo-prune-runners] delete {runner_id} failed: {status} {body}")
|
|
return False
|
|
|
|
|
|
def _prune_db(ttl_seconds: int) -> int:
|
|
cutoff = int(time.time()) - ttl_seconds
|
|
now = int(time.time())
|
|
sql = (
|
|
"WITH updated AS ("
|
|
"UPDATE action_runner "
|
|
f"SET deleted = {now} "
|
|
"WHERE (deleted IS NULL OR deleted = 0) "
|
|
f"AND ((last_online IS NOT NULL AND last_online > 0 AND last_online < {cutoff}) "
|
|
f"OR (COALESCE(last_online, 0) = 0 AND created < {cutoff})) "
|
|
"RETURNING 1"
|
|
") SELECT count(*) FROM updated;"
|
|
)
|
|
result = subprocess.run(
|
|
["psql", "-h", "/run/postgresql", "-U", "forgejo", "forgejo", "-tAc", sql],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
output = (result.stdout or "").strip()
|
|
try:
|
|
return int(output)
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def main() -> None:
|
|
api_url = os.environ.get("FORGEJO_API_URL", "https://git.burrow.net/api/v1").rstrip("/")
|
|
org = os.environ.get("FORGEJO_ORG", "hackclub").strip() or None
|
|
dry_run = os.environ.get("FORGEJO_DRY_RUN", "0") == "1"
|
|
db_only = os.environ.get("FORGEJO_PRUNE_DB", "0") == "1"
|
|
ttl_seconds = int(os.environ.get("FORGEJO_RUNNER_TTL_SEC", "3600"))
|
|
|
|
if db_only:
|
|
removed = _prune_db(ttl_seconds)
|
|
print(f"[forgejo-prune-runners] pruned {removed} runners via DB")
|
|
return
|
|
|
|
token = _read_token()
|
|
|
|
try:
|
|
_, runners = _list_runners(api_url, token, org)
|
|
except RuntimeError as exc:
|
|
if org is not None:
|
|
print(f"[forgejo-prune-runners] org runner list failed ({exc}); retrying instance scope")
|
|
_, runners = _list_runners(api_url, token, None)
|
|
org = None
|
|
else:
|
|
raise SystemExit(str(exc))
|
|
|
|
if not runners:
|
|
removed = _prune_db(ttl_seconds)
|
|
print(f"[forgejo-prune-runners] pruned {removed} runners via DB fallback")
|
|
return
|
|
|
|
removed = 0
|
|
for runner in runners:
|
|
runner_id = runner.get("id")
|
|
name = runner.get("name", "unknown")
|
|
status = (runner.get("status") or "").lower()
|
|
busy = bool(runner.get("busy"))
|
|
if status == "online" or busy:
|
|
continue
|
|
if runner_id is None:
|
|
continue
|
|
if dry_run:
|
|
print(f"[forgejo-prune-runners] would delete runner {runner_id} ({name}) status={status}")
|
|
continue
|
|
if _delete_runner(api_url, token, org, int(runner_id)):
|
|
removed += 1
|
|
print(f"[forgejo-prune-runners] deleted runner {runner_id} ({name})")
|
|
|
|
print(f"[forgejo-prune-runners] done; removed {removed} runners")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|