107 lines
4.1 KiB
Python
Executable File
107 lines
4.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Dispatch imba.yml workflow on selected fleet nodes (with SSL retries).
|
|
SAFEGATE: requires explicit --node, --limit, or --all. No args = dry-run + exit.
|
|
|
|
Usage examples:
|
|
python fleet_dispatch.py # dry-run: print node count, exit
|
|
python fleet_dispatch.py --node imba-node-0002
|
|
python fleet_dispatch.py --limit 10
|
|
python fleet_dispatch.py --all
|
|
"""
|
|
import os
|
|
KF_BASE = os.path.dirname(os.path.abspath(__file__))
|
|
os.makedirs(os.path.join(KF_BASE, "data"), exist_ok=True)
|
|
import argparse
|
|
import concurrent.futures as cf
|
|
import json
|
|
import time
|
|
import sys
|
|
|
|
import requests
|
|
from requests.adapters import HTTPAdapter, Retry
|
|
|
|
DEPLOY = os.path.join(KF_BASE, "data/fleet_deploy.json")
|
|
H = {"Accept": "application/vnd.github+json"}
|
|
|
|
s = requests.Session()
|
|
s.trust_env = False
|
|
s.mount("https://", HTTPAdapter(max_retries=Retry(total=5, backoff_factor=1)))
|
|
|
|
|
|
def dispatch(rec):
|
|
full = f"{rec['login']}/{rec['repo']}"
|
|
h = {**H, "Authorization": f"Bearer {rec['pat']}"}
|
|
for attempt in range(3):
|
|
try:
|
|
r = s.post(
|
|
f"https://api.github.com/repos/{full}/actions/workflows/imba.yml/dispatches",
|
|
headers=h, json={"ref": "main"}, timeout=30)
|
|
if r.status_code in (201, 204):
|
|
return full, True, r.status_code
|
|
return full, False, f"{r.status_code} {r.text[:120]}"
|
|
except Exception as e:
|
|
if attempt == 2:
|
|
return full, False, str(e)[:120]
|
|
time.sleep(3)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(
|
|
description="Dispatch imba.yml workflow on fleet nodes.",
|
|
epilog="Without --node/--limit/--all: dry-run + exit (no dispatch).")
|
|
target = ap.add_mutually_exclusive_group()
|
|
target.add_argument("--node", metavar="REPO",
|
|
help="exact repo name or login/repo")
|
|
target.add_argument("--limit", type=int, metavar="N",
|
|
help="first N ok nodes")
|
|
target.add_argument("--all", action="store_true",
|
|
help="ALL ok nodes (1391)")
|
|
ap.add_argument("--offset", type=int, default=0,
|
|
help="starting index")
|
|
ap.add_argument("--dry-run", action="store_true",
|
|
help="print target count and exit")
|
|
args, _ = ap.parse_known_args()
|
|
|
|
recs = [r for r in json.load(open(DEPLOY, encoding="utf-8")) if r.get("ok")]
|
|
if args.node:
|
|
recs = [r for r in recs
|
|
if r.get("repo") == args.node
|
|
or f"{r.get('login')}/{r.get('repo')}" == args.node]
|
|
elif args.limit:
|
|
recs = recs[args.offset:args.offset + args.limit]
|
|
elif args.all:
|
|
recs = recs[args.offset:] # support offset even with --all
|
|
else:
|
|
# dry-run default when no explicit target
|
|
print(f"fleet_dispatch.py SAFEGATE: no target specified.\n"
|
|
f" --node REPO | --limit N | --all required for live dispatch.\n"
|
|
f" ok nodes available: {len(recs)}\n"
|
|
f" (dry-run; use --dry-run to see target count)")
|
|
sys.exit(0)
|
|
|
|
if args.dry_run or not (args.node or args.limit or args.all):
|
|
print(f"DRY-RUN: would dispatch {len(recs)} nodes")
|
|
return
|
|
|
|
if not recs:
|
|
sys.exit("no nodes selected (--node/--limit matched nothing)")
|
|
print(f"dispatching {len(recs)} nodes", flush=True)
|
|
ok = 0
|
|
t0 = time.time()
|
|
with cf.ThreadPoolExecutor(max_workers=12) as ex:
|
|
for i, (full, good, info) in enumerate(ex.map(dispatch, recs), 1):
|
|
ok += good
|
|
if not good:
|
|
print(f" FAIL {full}: {info}", flush=True)
|
|
if i % 50 == 0:
|
|
# ":.0s" is not a format code: every dispatch died here on the
|
|
# final summary (and on the per-50 progress line), after the
|
|
# ex.map loop had already run, so runs were dispatched but the
|
|
# ok/total was never printed and the exit code was a traceback.
|
|
print(f" [{i}/{len(recs)}] ok={ok} {time.time()-t0:.0f}s", flush=True)
|
|
print(f"\nDONE {time.time()-t0:.0f}s: {ok}/{len(recs)} dispatched", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|