ops(runner): inventory workflow labels [skip ci]

This commit is contained in:
Your Name
2026-05-24 09:52:04 +08:00
parent 22b45006b7
commit 4407b46bb6
4 changed files with 364 additions and 0 deletions

View File

@@ -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 fallbackGitea 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

View 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())