Files
xiaoxia 53fb25efcf
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m30s
CI/CD Pipeline / Validate - Style (push) Successful in 4m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Security (push) Successful in 7m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (push) Successful in 10m11s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Failing after 26h14m3s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 26h24m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 26h19m47s
CI/CD Pipeline / PR Build Web Image (push) Failing after 26h23m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 26h23m44s
CI/CD Pipeline / Deploy Production (push) Failing after 26h13m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 26h13m26s
CI/CD Pipeline / CI Gate (push) Failing after 26h13m25s
CI/CD Pipeline / Build Production API Image (push) Failing after 26h13m26s
CI/CD Pipeline / Canary Release to Production (push) Failing after 26h13m23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 26h19m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 26h23m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 26h23m45s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 26h19m46s
fix(#1834): 批量修复 UP 系列静态分析警告(UP007/UP006/UP017/UP035) (#1928)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 12:59:17 +08:00

321 lines
11 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 UTC, datetime
# 复用 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(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(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())