Harden macos runner cleanup
This commit is contained in:
parent
fc79766a31
commit
283209d364
5 changed files with 239 additions and 113 deletions
144
Scripts/forgejo-prune-runners.py
Executable file
144
Scripts/forgejo-prune-runners.py
Executable file
|
|
@ -0,0 +1,144 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue