efc79b4457
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 5m14s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 16s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m19s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Waiting to run
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Waiting to run
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
321 lines
11 KiB
Python
Executable File
321 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Runner 状态巡检 - 调 Gitea API 查 runner 列表 + 状态 + 队列积压
|
||
|
||
功能:
|
||
- 获取所有 runner 的在线状态、忙闲状态
|
||
- 检测离线/禁用 runner
|
||
- 检测 CI 队列积压(pending 数量 + 持续时间)
|
||
- 生成 runner 状态快照
|
||
|
||
说明:
|
||
Gitea Actions API 直接返回的 runner 信息不包含心跳时间,
|
||
因此"离线超过N分钟"的判断通过以下方式近似:
|
||
1. status != "online" 的 runner 直接判定为离线
|
||
2. busy=true 且长时间无 job 完成的 runner 标记为疑似挂起(待增强)
|
||
3. 通过 pending job 数量和时长判断队列积压
|
||
|
||
用法:
|
||
python3 scripts/ci/runner_monitor/runner_status.py --check
|
||
python3 scripts/ci/runner_monitor/runner_status.py --snapshot
|
||
python3 scripts/ci/runner_monitor/runner_status.py --list
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
from datetime import datetime, timezone
|
||
|
||
# 复用 chatops 的 GiteaClient
|
||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
_CI_DIR = os.path.dirname(_SCRIPT_DIR)
|
||
if _CI_DIR not in sys.path:
|
||
sys.path.insert(0, _CI_DIR)
|
||
|
||
from chatops.gitea_client import GiteaClient # noqa: E402
|
||
|
||
|
||
class RunnerStatusChecker:
|
||
"""Runner 状态巡检器"""
|
||
|
||
def __init__(self, gitea_client=None):
|
||
self.gitea = gitea_client or GiteaClient()
|
||
|
||
# ── Runner 列表 ──────────────────────────────────
|
||
|
||
def get_runners(self):
|
||
"""获取仓库所有 runner 列表
|
||
|
||
Returns:
|
||
list[dict]: runner 列表
|
||
"""
|
||
# 直接调用 Gitea Actions runners API
|
||
data = self.gitea._request("actions/runners")
|
||
if not data:
|
||
return []
|
||
return data.get("runners", [])
|
||
|
||
def get_runner_summary(self):
|
||
"""获取 runner 汇总信息
|
||
|
||
Returns:
|
||
dict: {total, online, offline, busy, disabled, runners}
|
||
"""
|
||
runners = self.get_runners()
|
||
if not runners:
|
||
return {
|
||
"total": 0,
|
||
"online": 0,
|
||
"offline": 0,
|
||
"busy": 0,
|
||
"disabled": 0,
|
||
"runners": [],
|
||
}
|
||
|
||
online = sum(1 for r in runners if r.get("status") == "online" and not r.get("disabled"))
|
||
offline = sum(1 for r in runners if r.get("status") != "online" and not r.get("disabled"))
|
||
busy = sum(1 for r in runners if r.get("busy"))
|
||
disabled = sum(1 for r in runners if r.get("disabled"))
|
||
|
||
return {
|
||
"total": len(runners),
|
||
"online": online,
|
||
"offline": offline,
|
||
"busy": busy,
|
||
"disabled": disabled,
|
||
"runners": runners,
|
||
}
|
||
|
||
def get_offline_runners(self, offline_minutes=5):
|
||
"""获取离线的 runner 列表
|
||
|
||
由于 Gitea API 不返回心跳时间,status != online 即视为离线。
|
||
offline_minutes 参数保留用于后续 SSH 心跳检测增强。
|
||
|
||
Returns:
|
||
list[dict]: 离线 runner 列表
|
||
"""
|
||
runners = self.get_runners()
|
||
if not runners:
|
||
return []
|
||
|
||
offline = [r for r in runners if not r.get("disabled") and r.get("status") != "online"]
|
||
# 补充离线时长字段(暂时用 None,后续增强)
|
||
for r in offline:
|
||
r["offline_minutes"] = None
|
||
r["offline_reason"] = f"status={r.get('status', 'unknown')}"
|
||
|
||
return offline
|
||
|
||
# ── 队列积压检测 ──────────────────────────────────
|
||
|
||
def get_pending_runs(self):
|
||
"""获取 pending / queued 状态的 workflow runs
|
||
|
||
Returns:
|
||
list[dict]: pending run 列表
|
||
"""
|
||
# 尝试多种状态名(Gitea 可能用 queued / pending / waiting)
|
||
pending = []
|
||
for status in ["queued", "pending", "waiting"]:
|
||
runs, _ = self.gitea.list_runs(status=status, limit=50)
|
||
pending.extend(runs)
|
||
|
||
# 去重
|
||
seen = set()
|
||
unique = []
|
||
for r in pending:
|
||
rid = r.get("id")
|
||
if rid and rid not in seen:
|
||
seen.add(rid)
|
||
unique.append(r)
|
||
|
||
return unique
|
||
|
||
def get_queue_backlog(self, pending_threshold=10, duration_minutes=10):
|
||
"""检测队列积压
|
||
|
||
Args:
|
||
pending_threshold: pending 数量阈值
|
||
duration_minutes: 持续时间阈值(分钟)
|
||
|
||
Returns:
|
||
dict: {is_backlogged, pending_count, oldest_pending_minutes, pending_runs}
|
||
"""
|
||
pending = self.get_pending_runs()
|
||
if not pending:
|
||
return {
|
||
"is_backlogged": False,
|
||
"pending_count": 0,
|
||
"oldest_pending_minutes": 0,
|
||
"pending_runs": [],
|
||
}
|
||
|
||
now = datetime.now(timezone.utc)
|
||
oldest_minutes = 0
|
||
for r in pending:
|
||
created = r.get("created_at", "")
|
||
if not created:
|
||
continue
|
||
try:
|
||
t = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||
age = (now - t).total_seconds() / 60
|
||
oldest_minutes = max(oldest_minutes, age)
|
||
except Exception:
|
||
pass
|
||
|
||
is_backlogged = len(pending) >= pending_threshold and oldest_minutes >= duration_minutes
|
||
|
||
return {
|
||
"is_backlogged": is_backlogged,
|
||
"pending_count": len(pending),
|
||
"oldest_pending_minutes": round(oldest_minutes, 1),
|
||
"pending_runs": pending,
|
||
}
|
||
|
||
# ── 综合巡检 ──────────────────────────────────────
|
||
|
||
def run_full_check(self, offline_minutes=5, pending_threshold=10, pending_duration=10):
|
||
"""执行完整的 runner 巡检
|
||
|
||
Returns:
|
||
dict: 巡检结果
|
||
"""
|
||
summary = self.get_runner_summary()
|
||
offline_runners = self.get_offline_runners(offline_minutes=offline_minutes)
|
||
backlog = self.get_queue_backlog(
|
||
pending_threshold=pending_threshold,
|
||
duration_minutes=pending_duration,
|
||
)
|
||
|
||
return {
|
||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
"runner_summary": {
|
||
"total": summary["total"],
|
||
"online": summary["online"],
|
||
"offline": summary["offline"],
|
||
"busy": summary["busy"],
|
||
"disabled": summary["disabled"],
|
||
},
|
||
"offline_runners": [
|
||
{
|
||
"id": r.get("id"),
|
||
"name": r.get("name"),
|
||
"status": r.get("status"),
|
||
"busy": r.get("busy"),
|
||
"labels": [label.get("name") for label in r.get("labels", [])],
|
||
"offline_minutes": r.get("offline_minutes"),
|
||
"offline_reason": r.get("offline_reason"),
|
||
}
|
||
for r in offline_runners
|
||
],
|
||
"queue_backlog": {
|
||
"is_backlogged": backlog["is_backlogged"],
|
||
"pending_count": backlog["pending_count"],
|
||
"oldest_pending_minutes": backlog["oldest_pending_minutes"],
|
||
},
|
||
"issues_found": len(offline_runners) > 0 or backlog["is_backlogged"],
|
||
}
|
||
|
||
# ── 快照输出 ──────────────────────────────────────
|
||
|
||
def save_snapshot(self, output_dir=None, data=None):
|
||
"""保存状态快照为 JSON 文件
|
||
|
||
Returns:
|
||
str: 快照文件路径
|
||
"""
|
||
if data is None:
|
||
data = self.run_full_check()
|
||
if output_dir is None:
|
||
from runner_monitor import config
|
||
|
||
output_dir = config.OUTPUT_DIR
|
||
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||
filename = f"runner_snapshot_{ts}.json"
|
||
filepath = os.path.join(output_dir, filename)
|
||
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
|
||
# 清理旧快照(保留最近 24 个)
|
||
self._cleanup_old_snapshots(output_dir, keep=24)
|
||
|
||
return filepath
|
||
|
||
@staticmethod
|
||
def _cleanup_old_snapshots(directory, keep=24):
|
||
"""清理旧快照文件"""
|
||
try:
|
||
files = sorted(
|
||
[f for f in os.listdir(directory) if f.startswith("runner_snapshot_")],
|
||
reverse=True,
|
||
)
|
||
for old in files[keep:]:
|
||
os.remove(os.path.join(directory, old))
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
# ── CLI 入口 ──────────────────────────────────────────
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Runner 状态巡检")
|
||
parser.add_argument("--list", action="store_true", help="列出所有 runner")
|
||
parser.add_argument("--check", action="store_true", help="执行完整巡检")
|
||
parser.add_argument("--snapshot", action="store_true", help="生成快照 JSON")
|
||
parser.add_argument("--pending", action="store_true", help="查看 pending 队列")
|
||
parser.add_argument("--output-dir", help="快照输出目录")
|
||
|
||
args = parser.parse_args()
|
||
|
||
checker = RunnerStatusChecker()
|
||
|
||
if args.list:
|
||
summary = checker.get_runner_summary()
|
||
print(
|
||
f"Runner 总览: {summary['online']}/{summary['total']} 在线, "
|
||
f"{summary['busy']} 忙碌, {summary['offline']} 离线, "
|
||
f"{summary['disabled']} 禁用"
|
||
)
|
||
print()
|
||
for r in summary["runners"]:
|
||
status_icon = "🟢" if r.get("status") == "online" else "🔴"
|
||
if r.get("disabled"):
|
||
status_icon = "⚪"
|
||
busy_icon = "⚡" if r.get("busy") else " "
|
||
labels = ", ".join(label.get("name") for label in r.get("labels", [])[:4])
|
||
print(f" {status_icon}{busy_icon} {r['name']:<30} {r.get('status', '?'):<10} labels: {labels}")
|
||
|
||
elif args.pending:
|
||
pending = checker.get_pending_runs()
|
||
print(f"Pending runs: {len(pending)}")
|
||
for r in pending[:10]:
|
||
print(
|
||
f" #{r.get('id')} {r.get('name', '?')} - {r.get('status', '?')} "
|
||
f"({r.get('head_branch', '?')}) created: {r.get('created_at', '?')[:16]}"
|
||
)
|
||
|
||
elif args.check:
|
||
result = checker.run_full_check()
|
||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||
|
||
elif args.snapshot:
|
||
path = checker.save_snapshot(output_dir=args.output_dir)
|
||
print(f"快照已保存: {path}")
|
||
|
||
else:
|
||
parser.print_help()
|
||
return 1
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|