ops(runner): inventory workflow labels [skip ci]
This commit is contained in:
@@ -296,6 +296,42 @@ recent 2h repo counts: none
|
||||
- 下一步應先讀各 repo workflow 實際使用的 labels,再規劃 repo label isolation 或獨立 runner
|
||||
registration;不可在沒有替代 runner 前直接移除 live `ewoooc-host`。
|
||||
|
||||
### 第七層修復: workflow label matrix
|
||||
|
||||
Runner config 只能看到「這台 runner 願意接什麼 label」,不能回答「哪些 repo 實際在使用」。
|
||||
T141 新增 workflow label 盤點工具:
|
||||
|
||||
```bash
|
||||
ops/runner/audit-workflow-labels.py \
|
||||
--local-repo wooo/stockplatform-v2=/Users/ogt/stockplatform-v2
|
||||
```
|
||||
|
||||
工具會透過 Gitea API 讀 `.gitea/workflows/*.yml` / `.yaml` 的 `runs-on`,Gitea 不可讀時可指定
|
||||
local fallback;Gitea token 只從 env 或目前 repo `gitea` remote 解析,永不輸出。
|
||||
|
||||
T141 evidence 摘要(2026-05-24 台北):
|
||||
|
||||
```text
|
||||
wooo/awoooi:
|
||||
awoooi-host: cd.yaml tests / build-and-deploy / post-deploy-checks
|
||||
ubuntu-latest: code-review, e2e-health, deploy-alerts, cd-dev, ansible-lint, type-sync, run-migration
|
||||
|
||||
wooo/ewoooc:
|
||||
ewoooc-host: cd.yaml deploy
|
||||
|
||||
wooo/stockplatform-v2:
|
||||
ubuntu-latest: ci.yaml hygiene / frontend
|
||||
```
|
||||
|
||||
風險判讀:
|
||||
|
||||
- `awoooi-host` 已經是 AWOOI CD 專用 label,但同一個 runner service 仍同時宣告
|
||||
`ewoooc-host` 與 `ubuntu-latest`,所以 runner queue 仍共享。
|
||||
- `ubuntu-latest` 是最主要共享入口;AWOOI code-review / e2e-health 與 stockplatform-v2 CI
|
||||
仍可能互相排隊。
|
||||
- 下一步若要真正隔離,必須做新的 runner registration / service split,或把非 AWOOI repo 移到
|
||||
另一台 runner。不可只在同一個 runner config 加更多 label,因為 `capacity: 1` 仍是同一條隊列。
|
||||
|
||||
---
|
||||
版本: v2.0 | 更新: 2026-03-29 | 作者: Claude Code
|
||||
變更: v1.0→v2.0 序列建構取代 Job Concurrency Groups
|
||||
|
||||
259
ops/runner/audit-workflow-labels.py
Executable file
259
ops/runner/audit-workflow-labels.py
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only inventory for Gitea workflow runner labels.
|
||||
|
||||
The script never prints credentials. It reads workflow files from Gitea when
|
||||
GITEA_BASE/GITEA_USER/GITEA_TOKEN are available, or derives them from the
|
||||
current repository's `gitea` remote when that remote embeds basic auth.
|
||||
|
||||
Example:
|
||||
ops/runner/audit-workflow-labels.py \
|
||||
--local-repo wooo/stockplatform-v2=/Users/ogt/stockplatform-v2
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
DEFAULT_REPOS = ("wooo/awoooi", "wooo/ewoooc", "wooo/stockplatform-v2")
|
||||
WORKFLOW_DIRS = (".gitea/workflows",)
|
||||
RUNS_ON_RE = re.compile(r"^\s*runs-on:\s*(?P<label>.+?)\s*(?:#.*)?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaAuth:
|
||||
base: str
|
||||
user: str
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowLabel:
|
||||
repo: str
|
||||
source: str
|
||||
branch: str
|
||||
file_path: str
|
||||
line_number: int
|
||||
label: str
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--repo",
|
||||
action="append",
|
||||
dest="repos",
|
||||
help="Repository in owner/name form. Defaults to AWOOI, EwoooC, stockplatform-v2.",
|
||||
)
|
||||
parser.add_argument("--branch", default="main", help="Branch/ref to inspect. Default: main.")
|
||||
parser.add_argument(
|
||||
"--local-repo",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="OWNER/NAME=PATH",
|
||||
help="Local fallback repository path used when Gitea content is unavailable.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def derive_gitea_auth() -> GiteaAuth | None:
|
||||
try:
|
||||
remote_url = subprocess.check_output(
|
||||
["git", "remote", "get-url", "gitea"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return None
|
||||
|
||||
match = re.match(r"http://([^:]+):([^@]+)@([^/]+)", remote_url)
|
||||
if not match:
|
||||
return None
|
||||
user, token, host = match.groups()
|
||||
return GiteaAuth(base=f"http://{host}", user=user, token=token)
|
||||
|
||||
|
||||
def build_opener(auth: GiteaAuth) -> urllib.request.OpenerDirector:
|
||||
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
|
||||
password_mgr.add_password(None, auth.base, auth.user, auth.token)
|
||||
return urllib.request.build_opener(urllib.request.HTTPBasicAuthHandler(password_mgr))
|
||||
|
||||
|
||||
def get_json(opener: urllib.request.OpenerDirector, auth: GiteaAuth, path: str) -> object:
|
||||
with opener.open(auth.base + path, timeout=10) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def parse_runs_on(repo: str, source: str, branch: str, file_path: str, content: str) -> list[WorkflowLabel]:
|
||||
labels: list[WorkflowLabel] = []
|
||||
for line_number, line in enumerate(content.splitlines(), start=1):
|
||||
match = RUNS_ON_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
label = match.group("label").strip().strip("'\"")
|
||||
labels.append(
|
||||
WorkflowLabel(
|
||||
repo=repo,
|
||||
source=source,
|
||||
branch=branch,
|
||||
file_path=file_path,
|
||||
line_number=line_number,
|
||||
label=label,
|
||||
)
|
||||
)
|
||||
return labels
|
||||
|
||||
|
||||
def fetch_gitea_labels(repo: str, branch: str, auth: GiteaAuth) -> tuple[list[WorkflowLabel], str | None]:
|
||||
opener = build_opener(auth)
|
||||
labels: list[WorkflowLabel] = []
|
||||
owner, name = repo.split("/", 1)
|
||||
|
||||
for workflow_dir in WORKFLOW_DIRS:
|
||||
api_dir = f"/api/v1/repos/{owner}/{name}/contents/{workflow_dir}?ref={branch}"
|
||||
try:
|
||||
entries = get_json(opener, auth, api_dir)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return labels, f"gitea_http_{exc.code}:{workflow_dir}"
|
||||
except Exception as exc: # noqa: BLE001 - inventory should report and continue.
|
||||
return labels, f"gitea_error:{type(exc).__name__}:{workflow_dir}"
|
||||
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or entry.get("type") != "file":
|
||||
continue
|
||||
name = str(entry.get("name", ""))
|
||||
if not re.search(r"\.ya?ml$", name):
|
||||
continue
|
||||
|
||||
file_path = f"{workflow_dir}/{name}"
|
||||
api_file = f"/api/v1/repos/{owner}/{repo.split('/', 1)[1]}/contents/{file_path}?ref={branch}"
|
||||
try:
|
||||
item = get_json(opener, auth, api_file)
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
content = base64.b64decode(str(item.get("content", ""))).decode("utf-8", "replace")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return labels, f"gitea_file_error:{type(exc).__name__}:{file_path}"
|
||||
labels.extend(parse_runs_on(repo, "gitea", branch, file_path, content))
|
||||
|
||||
return labels, None
|
||||
|
||||
|
||||
def parse_local_repo_args(values: Iterable[str]) -> dict[str, Path]:
|
||||
paths: dict[str, Path] = {}
|
||||
for value in values:
|
||||
if "=" not in value:
|
||||
raise SystemExit(f"invalid --local-repo value: {value}")
|
||||
repo, path = value.split("=", 1)
|
||||
paths[repo] = Path(path).expanduser().resolve()
|
||||
return paths
|
||||
|
||||
|
||||
def fetch_local_labels(repo: str, branch: str, repo_path: Path) -> tuple[list[WorkflowLabel], str | None]:
|
||||
labels: list[WorkflowLabel] = []
|
||||
if not repo_path.exists():
|
||||
return labels, f"local_missing:{repo_path}"
|
||||
|
||||
for workflow_dir in WORKFLOW_DIRS:
|
||||
directory = repo_path / workflow_dir
|
||||
if not directory.exists():
|
||||
continue
|
||||
for path in sorted(directory.glob("*.y*ml")):
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
labels.extend(parse_runs_on(repo, "local", branch, str(path.relative_to(repo_path)), content))
|
||||
return labels, None
|
||||
|
||||
|
||||
def label_owner(label: str) -> str:
|
||||
value = label.strip().strip("'\"")
|
||||
if value == "awoooi-host":
|
||||
return "awoooi_dedicated"
|
||||
if value == "ewoooc-host":
|
||||
return "foreign_dedicated"
|
||||
if value == "ubuntu-latest" or "ubuntu-latest" in value:
|
||||
return "shared_queue"
|
||||
if value.startswith("ubuntu-") or value.startswith("["):
|
||||
return "shared_queue"
|
||||
return "unknown_or_custom"
|
||||
|
||||
|
||||
def print_labels(labels: list[WorkflowLabel], errors: list[str]) -> None:
|
||||
print("== workflow label inventory ==")
|
||||
if labels:
|
||||
print("repo\tsource\tbranch\tfile\tline\truns_on\towner")
|
||||
for item in labels:
|
||||
print(
|
||||
f"{item.repo}\t{item.source}\t{item.branch}\t{item.file_path}\t"
|
||||
f"{item.line_number}\t{item.label}\t{label_owner(item.label)}"
|
||||
)
|
||||
else:
|
||||
print("labels_found=0")
|
||||
|
||||
print("\n== label summary ==")
|
||||
summary: dict[str, set[str]] = {}
|
||||
for item in labels:
|
||||
summary.setdefault(item.label, set()).add(item.repo)
|
||||
if summary:
|
||||
for label, repos in sorted(summary.items(), key=lambda pair: (label_owner(pair[0]), pair[0])):
|
||||
print(f"label={label} owner={label_owner(label)} repo_count={len(repos)} repos={','.join(sorted(repos))}")
|
||||
else:
|
||||
print("summary=none")
|
||||
|
||||
print("\n== inventory warnings ==")
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error)
|
||||
else:
|
||||
print("warnings=none")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
repos = args.repos or list(DEFAULT_REPOS)
|
||||
local_paths = parse_local_repo_args(args.local_repo)
|
||||
auth = derive_gitea_auth()
|
||||
|
||||
labels: list[WorkflowLabel] = []
|
||||
errors: list[str] = []
|
||||
|
||||
for repo in repos:
|
||||
repo_labels: list[WorkflowLabel] = []
|
||||
error: str | None = None
|
||||
if auth is not None:
|
||||
repo_labels, error = fetch_gitea_labels(repo, args.branch, auth)
|
||||
elif repo not in local_paths:
|
||||
error = "gitea_auth_unavailable"
|
||||
|
||||
if error and repo in local_paths:
|
||||
local_labels, local_error = fetch_local_labels(repo, args.branch, local_paths[repo])
|
||||
if local_labels:
|
||||
repo_labels = local_labels
|
||||
errors.append(f"{repo}: {error}; local_fallback=used")
|
||||
elif local_error:
|
||||
errors.append(f"{repo}: {error}; {local_error}")
|
||||
else:
|
||||
errors.append(f"{repo}: {error}; local_fallback=no_workflows")
|
||||
elif error:
|
||||
errors.append(f"{repo}: {error}")
|
||||
|
||||
labels.extend(repo_labels)
|
||||
|
||||
print_labels(labels, errors)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user