6eac0b2cf2
Worker Base Image Build / Build Worker Base Images (worker-base-builder-cache, infra/docker/worker-base-builder.Dockerfile, worker-base-builder, builder) (push) Failing after 1m54s
Worker Base Image Build / Build Worker Base Images (worker-base-runtime-cache, infra/docker/worker-base-runtime.Dockerfile, worker-base-runtime, runtime) (push) Failing after 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 44s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
同步内容: 1. CI流水线配置(ci-pipeline.yml)与develop对齐 2. PR构建脚本docker_build_only.sh增加buildx→docker build回退 3. pre-build步骤worker基础镜像构建增加buildx回退 4. 单元测试脚本全量覆盖率改为仅报告不阻塞 5. diff-cover依赖加入requirements-dev.txt 6. worker base builder/runtime Dockerfile同步 7. test_config_oss.py clear=False→clear=True修复OSS污染 8. Frontend Lint增加prettier依赖
427 lines
16 KiB
Python
427 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ACR 镜像清理脚本
|
||
策略:
|
||
- 版本tag (v*): 永久保留
|
||
- 固定tag (latest, main, develop, master): 永久保留
|
||
- 缓存镜像 (*-cache): 永久保留
|
||
- PR预览tag (pr-*): 保留 N 天(默认7天)
|
||
- 普通commit hash tag: 保留最近 N 个(默认20),老的删除
|
||
|
||
使用方式:
|
||
python3 acr_cleanup.py --dry-run # 预览,不实际删除
|
||
python3 acr_cleanup.py --execute # 实际执行删除
|
||
python3 acr_cleanup.py --keep 20 --execute # 保留最近20个
|
||
"""
|
||
|
||
import argparse
|
||
import base64
|
||
import json
|
||
import os
|
||
import sys
|
||
import urllib.error
|
||
import urllib.request
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
# 配置
|
||
REGISTRY = os.environ.get("ACR_REGISTRY", "xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com")
|
||
AUTH_URL = "https://dockerauth.cn-hangzhou.aliyuncs.com/auth"
|
||
SERVICE = os.environ.get("ACR_SERVICE", "registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa")
|
||
NAMESPACE = os.environ.get("ACR_NAMESPACE", "xiaoxiakeji")
|
||
USERNAME = os.environ.get("ACR_USERNAME", "")
|
||
PASSWORD = os.environ.get("ACR_PASSWORD", "")
|
||
|
||
REPOS = [
|
||
"xiaoxia-saas-api",
|
||
"xiaoxia-saas-worker",
|
||
"xiaoxia-saas-web",
|
||
"api-cache",
|
||
"worker-cache",
|
||
"web-cache",
|
||
]
|
||
|
||
# 缓存镜像仓库(所有tag永久保留)
|
||
CACHE_REPOS = {"api-cache", "worker-cache", "web-cache"}
|
||
|
||
# OCI / Docker manifest types
|
||
ACCEPT_INDEX = "application/vnd.oci.image.index.v1+json"
|
||
ACCEPT_MANIFEST_OCI = "application/vnd.oci.image.manifest.v1+json"
|
||
ACCEPT_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json"
|
||
|
||
|
||
def get_token(repo, action="pull"):
|
||
"""获取仓库访问token"""
|
||
scope = "repository:" + NAMESPACE + "/" + repo + ":" + action
|
||
token_url = AUTH_URL + "?service=" + SERVICE + "&scope=" + scope
|
||
req = urllib.request.Request(token_url)
|
||
req.add_header("Authorization", "Basic " + base64.b64encode((USERNAME + ":" + PASSWORD).encode()).decode())
|
||
with urllib.request.urlopen(req) as resp:
|
||
data = json.loads(resp.read())
|
||
return data.get("token", "")
|
||
|
||
|
||
def get_tags(repo, token):
|
||
"""获取仓库所有tag"""
|
||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/tags/list?n=1000"
|
||
req = urllib.request.Request(url)
|
||
req.add_header("Authorization", "Bearer " + token)
|
||
with urllib.request.urlopen(req) as resp:
|
||
data = json.loads(resp.read())
|
||
return data.get("tags", []) or []
|
||
|
||
|
||
def http_get_json(url, token, accept_header):
|
||
"""带Authorization的GET请求,返回(json_data, headers)"""
|
||
req = urllib.request.Request(url)
|
||
req.add_header("Authorization", "Bearer " + token)
|
||
req.add_header("Accept", accept_header)
|
||
with urllib.request.urlopen(req) as resp:
|
||
return json.loads(resp.read()), resp.headers
|
||
|
||
|
||
def get_manifest_info(repo, tag, token):
|
||
"""
|
||
获取tag的manifest信息,支持OCI index和普通manifest两种格式。
|
||
返回: {digest, created, media_type}
|
||
- digest: 顶层manifest的digest(用于删除)
|
||
- created: 镜像创建时间
|
||
"""
|
||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
|
||
result = {"digest": "", "created": "", "media_type": "", "error": ""}
|
||
|
||
# 先尝试 OCI index 格式(ACR多用这种)
|
||
try:
|
||
data, headers = http_get_json(url, token, ACCEPT_INDEX)
|
||
top_digest = headers.get("Docker-Content-Digest", "")
|
||
result["digest"] = top_digest
|
||
result["media_type"] = data.get("mediaType", ACCEPT_INDEX)
|
||
|
||
# OCI index:找amd64的manifest,再取config blob
|
||
manifests = data.get("manifests", [])
|
||
amd64_manifest = None
|
||
for m in manifests:
|
||
arch = m.get("platform", {}).get("architecture", "")
|
||
if arch == "amd64":
|
||
amd64_manifest = m
|
||
break
|
||
# 没有amd64就用第一个
|
||
if not amd64_manifest and manifests:
|
||
amd64_manifest = manifests[0]
|
||
|
||
if amd64_manifest:
|
||
inner_digest = amd64_manifest["digest"]
|
||
inner_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + inner_digest
|
||
try:
|
||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_OCI)
|
||
except Exception:
|
||
# 退而求其次用v2格式
|
||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_V2)
|
||
|
||
config_digest = inner_data.get("config", {}).get("digest", "")
|
||
if config_digest:
|
||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||
try:
|
||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||
result["created"] = blob_data.get("created", "")
|
||
except Exception:
|
||
pass
|
||
return result
|
||
except urllib.error.HTTPError:
|
||
pass
|
||
|
||
# 再尝试普通 OCI manifest 格式
|
||
try:
|
||
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_OCI)
|
||
result["digest"] = headers.get("Docker-Content-Digest", "")
|
||
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_OCI)
|
||
config_digest = data.get("config", {}).get("digest", "")
|
||
if config_digest:
|
||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||
try:
|
||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||
result["created"] = blob_data.get("created", "")
|
||
except Exception:
|
||
pass
|
||
return result
|
||
except urllib.error.HTTPError:
|
||
pass
|
||
|
||
# 最后试 Docker v2 格式
|
||
try:
|
||
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_V2)
|
||
result["digest"] = headers.get("Docker-Content-Digest", "")
|
||
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_V2)
|
||
config_digest = data.get("config", {}).get("digest", "")
|
||
if config_digest:
|
||
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
|
||
try:
|
||
blob_data, _ = http_get_json(blob_url, token, "application/json")
|
||
result["created"] = blob_data.get("created", "")
|
||
except Exception:
|
||
pass
|
||
return result
|
||
except urllib.error.HTTPError as e:
|
||
result["error"] = "HTTP " + str(e.code) + " " + e.read().decode()[:200]
|
||
|
||
return result
|
||
|
||
|
||
def delete_manifest(repo, digest, token):
|
||
"""按digest删除manifest(会级联删除所有指向它的tag)"""
|
||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + digest
|
||
req = urllib.request.Request(url, method="DELETE")
|
||
req.add_header("Authorization", "Bearer " + token)
|
||
req.add_header("Accept", ACCEPT_INDEX)
|
||
req.add_header("Accept", ACCEPT_MANIFEST_OCI)
|
||
req.add_header("Accept", ACCEPT_MANIFEST_V2)
|
||
try:
|
||
with urllib.request.urlopen(req) as resp:
|
||
return True, resp.status
|
||
except urllib.error.HTTPError as e:
|
||
return False, str(e.code) + " " + e.read().decode()[:200]
|
||
|
||
|
||
def parse_time(created_str):
|
||
"""解析ISO时间字符串"""
|
||
if not created_str:
|
||
return datetime.min.replace(tzinfo=timezone.utc)
|
||
try:
|
||
if created_str.endswith("Z"):
|
||
created_str = created_str[:-1] + "+00:00"
|
||
return datetime.fromisoformat(created_str)
|
||
except Exception:
|
||
return datetime.min.replace(tzinfo=timezone.utc)
|
||
|
||
|
||
def is_version_tag(tag):
|
||
"""判断是否是版本tag (v1.2.3, v0.1.0-alpha等)"""
|
||
return tag.startswith("v") and len(tag) > 1 and tag[1].isdigit()
|
||
|
||
|
||
def is_fixed_tag(tag):
|
||
"""判断是否是固定tag"""
|
||
return tag in ("latest", "main", "develop", "master", "dev", "stable")
|
||
|
||
|
||
def is_pr_tag(tag):
|
||
"""判断是否是PR预览tag"""
|
||
return tag.startswith("pr-")
|
||
|
||
|
||
def cleanup_repo(repo, keep_count, pr_days, dry_run):
|
||
"""清理单个仓库"""
|
||
print("=" * 60)
|
||
print("仓库:", repo)
|
||
print("=" * 60)
|
||
|
||
# 缓存仓库不清理
|
||
if repo in CACHE_REPOS:
|
||
token_pull = get_token(repo, "pull")
|
||
tags = get_tags(repo, token_pull)
|
||
print(" 缓存仓库,跳过清理 (共", len(tags), "个tag)")
|
||
return len(tags), 0
|
||
|
||
token_pull = get_token(repo, "pull")
|
||
tags = get_tags(repo, token_pull)
|
||
print(" 总tag数:", len(tags))
|
||
|
||
# 分类
|
||
version_tags = []
|
||
fixed_tags = []
|
||
pr_tags_list = []
|
||
commit_tags = []
|
||
|
||
for tag in tags:
|
||
if is_version_tag(tag):
|
||
version_tags.append(tag)
|
||
elif is_fixed_tag(tag):
|
||
fixed_tags.append(tag)
|
||
elif is_pr_tag(tag):
|
||
pr_tags_list.append(tag)
|
||
else:
|
||
commit_tags.append(tag)
|
||
|
||
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
|
||
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
|
||
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "天")
|
||
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "个")
|
||
|
||
# 获取所有commit tag的创建时间
|
||
print()
|
||
print(" 获取commit tag创建时间...")
|
||
tag_info_list = []
|
||
errors = 0
|
||
for i, tag in enumerate(commit_tags):
|
||
info = get_manifest_info(repo, tag, token_pull)
|
||
if info["error"] or not info["digest"]:
|
||
errors += 1
|
||
# 取不到信息的tag,放到最后(最旧处理),但标记一下
|
||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": "", "error": info.get("error", "")})
|
||
else:
|
||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": info["created"], "error": ""})
|
||
if (i + 1) % 20 == 0:
|
||
print(" 已获取", i + 1, "/", len(commit_tags), "...")
|
||
|
||
if errors:
|
||
print(" 注意:", errors, "个tag获取manifest失败")
|
||
|
||
# 按时间倒序排序(空时间放最后)
|
||
tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True)
|
||
|
||
# 确定要删除的commit tag
|
||
to_delete = []
|
||
if len(tag_info_list) > keep_count:
|
||
to_delete = tag_info_list[keep_count:]
|
||
print(" 保留前", keep_count, "个commit tag,删除", len(to_delete), "个")
|
||
# 打印保留范围
|
||
kept = tag_info_list[:keep_count]
|
||
valid_kept = [t for t in kept if t["created"]]
|
||
if valid_kept:
|
||
print(" 最早保留:", valid_kept[-1]["tag"][:12], "(" + valid_kept[-1]["created"][:10] + ")")
|
||
# 保护当前构建的tag(通过PROTECTED_TAG环境变量传入,如GITHUB_SHA)
|
||
protected_tag = os.environ.get("PROTECTED_TAG", "").strip()
|
||
if protected_tag:
|
||
before = len(to_delete)
|
||
to_delete = [t for t in to_delete if not t["tag"].startswith(protected_tag)]
|
||
removed = before - len(to_delete)
|
||
if removed > 0:
|
||
print(f" 保护当前构建tag: {protected_tag[:12]} (跳过{removed}个)")
|
||
|
||
to_del_valid = [t for t in to_delete if t["digest"]]
|
||
print(" 可删除(有digest):", len(to_del_valid), "个")
|
||
else:
|
||
print(" commit tag数量不足", keep_count, ",无需清理")
|
||
|
||
# PR tag按时间清理
|
||
pr_to_delete = []
|
||
if pr_tags_list:
|
||
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
|
||
print()
|
||
print(" 检查PR预览tag(超过", pr_days, "天删除)...")
|
||
for tag in pr_tags_list:
|
||
info = get_manifest_info(repo, tag, token_pull)
|
||
created = parse_time(info["created"])
|
||
if created < cutoff:
|
||
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||
print(" PR tag将删除:", len(pr_to_delete), "个")
|
||
|
||
all_to_delete = [t for t in to_delete if t["digest"]] + [t for t in pr_to_delete if t["digest"]]
|
||
|
||
if not all_to_delete:
|
||
print()
|
||
print(" 无需删除任何tag")
|
||
return len(tags), 0
|
||
|
||
# 执行删除
|
||
print()
|
||
if dry_run:
|
||
print(" [DRY RUN] 将删除", len(all_to_delete), "个tag(预览模式,不实际删除)")
|
||
# 去重digest
|
||
unique_digests = set(t["digest"] for t in all_to_delete if t["digest"])
|
||
print(" 去重后唯一digest数:", len(unique_digests))
|
||
for item in all_to_delete[:5]:
|
||
created_str = item.get("created", "")[:10] or "未知"
|
||
print(" -", item["tag"][:20], "(" + created_str + ")")
|
||
if len(all_to_delete) > 5:
|
||
print(" ... 还有", len(all_to_delete) - 5, "个")
|
||
return len(tags), len(unique_digests)
|
||
|
||
token_delete = get_token(repo, "delete")
|
||
deleted = 0
|
||
failed = 0
|
||
# 按digest去重,避免重复删除同一镜像
|
||
seen_digests = set()
|
||
unique_delete = []
|
||
for item in all_to_delete:
|
||
if item["digest"] and item["digest"] not in seen_digests:
|
||
seen_digests.add(item["digest"])
|
||
unique_delete.append(item)
|
||
|
||
print(" 开始删除", len(unique_delete), "个唯一manifest...")
|
||
for item in unique_delete:
|
||
success, result = delete_manifest(repo, item["digest"], token_delete)
|
||
if success:
|
||
deleted += 1
|
||
print(" 已删除:", item["tag"][:20])
|
||
else:
|
||
failed += 1
|
||
print(" 删除失败:", item["tag"][:20], "-", result)
|
||
|
||
print()
|
||
print(" 删除完成: 成功", deleted, "个,失败", failed, "个")
|
||
return len(tags), deleted
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="ACR镜像清理工具")
|
||
parser.add_argument("--keep", type=int, default=20, help="保留最近N个commit hash tag(默认20)")
|
||
parser.add_argument("--pr-days", type=int, default=7, help="PR预览tag保留天数(默认7天)")
|
||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际删除")
|
||
parser.add_argument("--execute", action="store_true", help="实际执行删除")
|
||
parser.add_argument("--repo", type=str, default="", help="只清理指定仓库")
|
||
args = parser.parse_args()
|
||
|
||
# 必须指定 --dry-run 或 --execute
|
||
if not args.dry_run and not args.execute:
|
||
print("请指定 --dry-run(预览)或 --execute(执行)")
|
||
print()
|
||
print("示例:")
|
||
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
|
||
print(" python3 acr_cleanup.py --execute # 实际执行清理")
|
||
print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个")
|
||
sys.exit(1)
|
||
|
||
# 凭证检查
|
||
global USERNAME, PASSWORD
|
||
if not USERNAME or not PASSWORD:
|
||
# 尝试从docker config读取
|
||
try:
|
||
docker_config_path = os.path.expanduser("~/.docker/config.json")
|
||
with open(docker_config_path) as f:
|
||
config = json.load(f)
|
||
auth = config.get("auths", {}).get(REGISTRY, {}).get("auth", "")
|
||
if auth:
|
||
creds = base64.b64decode(auth).decode().strip()
|
||
USERNAME, PASSWORD = creds.split(":", 1)
|
||
except Exception:
|
||
pass
|
||
|
||
if not USERNAME or not PASSWORD:
|
||
print("错误: 缺少ACR凭证,请设置 ACR_USERNAME 和 ACR_PASSWORD 环境变量")
|
||
print("或确保已执行 docker login", REGISTRY)
|
||
sys.exit(1)
|
||
|
||
dry_run = args.dry_run or not args.execute
|
||
mode = "预览模式" if dry_run else "执行模式"
|
||
print("ACR 镜像清理工具 -", mode)
|
||
print("Registry:", REGISTRY)
|
||
print("Namespace:", NAMESPACE)
|
||
print("保留commit tag数:", args.keep)
|
||
print("PR预览保留天数:", args.pr_days)
|
||
print()
|
||
|
||
repos_to_clean = REPOS
|
||
if args.repo:
|
||
repos_to_clean = [args.repo]
|
||
|
||
total_deleted = 0
|
||
total_tags = 0
|
||
for repo in repos_to_clean:
|
||
count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run)
|
||
total_tags += count
|
||
total_deleted += deleted
|
||
|
||
print()
|
||
print("=" * 60)
|
||
print("清理完成")
|
||
print(" 总tag数:", total_tags)
|
||
if dry_run:
|
||
print(" 预览将删除(去重后):", total_deleted, "个manifest")
|
||
else:
|
||
print(" 已删除:", total_deleted, "个manifest")
|
||
print("=" * 60)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|