#!/usr/bin/env python3 """ ACR 镜像清理脚本(增强版) 清理策略: - 版本tag (v*): 永久保留 - 固定tag (latest, main, develop, master): 永久保留 - 缓存镜像 (*-cache): 永久保留 - 受保护tag (--protected-tags): 永久保留(如当前运行中镜像) - PR预览tag (pr-*): - --pr-sha模式:删除指定PR commit的镜像(PR关闭时触发) - cron模式:通过Gitea API检查PR状态,已关闭/合并的删除 - 普通commit hash tag: 保留最近 N 个(默认20),老的删除 使用方式: # 预览(不实际删除) python3 acr_cleanup.py --dry-run # 实际执行(cron模式) python3 acr_cleanup.py --execute # 保留最近30个commit镜像 python3 acr_cleanup.py --keep 30 --execute # PR关闭时清理指定commit的PR镜像 python3 acr_cleanup.py --pr-sha abc123def --execute # 传入受保护tag列表(运行中镜像白名单) python3 acr_cleanup.py --protected-tags "sha1,sha2" --execute """ 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", "") # Gitea配置(用于PR状态检查) GITEA_URL = os.environ.get("GITEA_URL", "https://git.xiaoxiajianji.com") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") GITEA_REPO = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas") 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" # ========== Registry API ========== 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, timeout=30) 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, timeout=30) 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, timeout=30) as resp: return json.loads(resp.read()), resp.headers def get_manifest_info(repo, tag, token): """ 获取tag的manifest信息。 返回: {digest, created, media_type, error} """ url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag result = {"digest": "", "created": "", "media_type": "", "error": ""} # 先尝试 OCI index 格式 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) manifests = data.get("manifests", []) amd64_manifest = None for m in manifests: arch = m.get("platform", {}).get("architecture", "") if arch == "amd64": amd64_manifest = m break 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: 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, timeout=30) as resp: return True, resp.status except urllib.error.HTTPError as e: return False, str(e.code) + " " + e.read().decode()[:200] # ========== Gitea API ========== def gitea_get_open_prs(): """获取所有打开的PR编号列表""" if not GITEA_TOKEN: print(" 警告: 无GITEA_TOKEN,跳过PR状态检查") return None open_prs = set() page = 1 while True: url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls?state=open&page=" + str(page) + "&limit=50" req = urllib.request.Request(url) req.add_header("Authorization", "token " + GITEA_TOKEN) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) if not data: break for pr in data: open_prs.add(pr.get("number", 0)) if len(data) < 50: break page += 1 except Exception as e: print(f" 警告: 获取Gitea PR列表失败: {e}") return None return open_prs def gitea_get_pr_commits(pr_number): """获取指定PR的所有commit sha""" if not GITEA_TOKEN: return [] url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls/" + str(pr_number) + "/commits?limit=100" req = urllib.request.Request(url) req.add_header("Authorization", "token " + GITEA_TOKEN) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) return [c.get("sha", "") for c in data] except Exception as e: print(f" 警告: 获取PR #{pr_number} commits失败: {e}") return [] # ========== 工具函数 ========== 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 (pr-)""" return tag.startswith("pr-") def extract_sha_from_pr_tag(tag): """从pr- tag中提取sha""" if tag.startswith("pr-"): return tag[3:] return tag def is_in_protected_list(tag, protected_set): """检查tag是否在受保护列表中""" if not protected_set: return False # 精确匹配 if tag in protected_set: return True # 前缀匹配(commit hash可能是完整或短的) for p in protected_set: if tag.startswith(p) or p.startswith(tag): return True return False # ========== 核心清理逻辑 ========== def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None, pr_days=0): """ 清理单个仓库 Args: repo: 仓库名 keep_count: 保留最近N个commit tag dry_run: 是否预览模式 protected_tags: 受保护tag集合(白名单) pr_sha: 指定PR commit sha(PR关闭模式),None表示cron模式 pr_open_set: 打开的PR编号集合(cron模式用) Returns: (总tag数, 删除数) """ 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)) if not tags: print(" 无tag,跳过") return 0, 0 # ========== PR-SHA模式:只删除指定commit的PR镜像 ========== if pr_sha: pr_tags_to_del = [ t for t in tags if t.startswith("pr-" + pr_sha) or t == "pr-" + pr_sha or pr_sha.startswith(extract_sha_from_pr_tag(t)) ] if not pr_tags_to_del: print(f" 未找到PR镜像: pr-{pr_sha[:12]}") return len(tags), 0 print(f" 找到 {len(pr_tags_to_del)} 个PR镜像待删除:") for t in pr_tags_to_del: print(f" - {t}") to_delete = [] for tag in pr_tags_to_del: info = get_manifest_info(repo, tag, token_pull) if info["digest"]: to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]}) else: print(f" 警告: {tag} 无法获取digest,跳过") return _execute_delete(repo, to_delete, dry_run, len(tags)) # ========== Cron模式:全量清理 ========== # 分类 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的删除") print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "个") print(" 白名单tag:", len(protected_tags), "个") # --- PR tag清理:检查PR状态 --- pr_to_delete = [] if pr_tags_list: print() print(" 检查PR镜像状态...") # 策略:有Gitea token则检查PR状态,否则按时间保留7天 if pr_open_set is not None: # 通过Gitea API检查每个PR镜像对应的PR是否还开着 # 注意:pr tag是pr-,sha可能属于某个PR # 简化策略:收集所有打开PR的commit sha,在白名单里的保留 print(" 模式: Gitea PR状态检查") # 这里做了简化:因为每个PR都查commits太慢,我们用另一种方式 # 对于PR tag,先尝试匹配PR编号(如果tag名里有编号),否则按时间 # 实际pr-没法直接知道PR编号,所以降级为按时间+打开PR的head sha白名单 open_head_shas = set() page = 1 while True: url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls?state=open&page=" + str(page) + "&limit=50" req = urllib.request.Request(url) req.add_header("Authorization", "token " + GITEA_TOKEN) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) if not data: break for pr in data: head_sha = pr.get("head", {}).get("sha", "") if head_sha: open_head_shas.add(head_sha) open_head_shas.add(head_sha[:7]) open_head_shas.add(head_sha[:12]) if len(data) < 50: break page += 1 except Exception: break deleted_count = 0 for tag in pr_tags_list: sha = extract_sha_from_pr_tag(tag) # 检查是否是打开PR的head sha is_open_pr = False for ohs in open_head_shas: if sha.startswith(ohs) or ohs.startswith(sha): is_open_pr = True break if not is_open_pr: info = get_manifest_info(repo, tag, token_pull) if info["digest"]: pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]}) deleted_count += 1 print(f" 打开PR数: {len(open_head_shas)}个head sha") print(f" 将删除PR镜像: {deleted_count}个") # pr-days兜底:超过指定天数的打开PR镜像也清理 if pr_days > 0: cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days) extra_old = [] for tag in pr_tags_list: sha = extract_sha_from_pr_tag(tag) is_open_pr = False for ohs in open_head_shas: if sha.startswith(ohs) or ohs.startswith(sha): is_open_pr = True break if is_open_pr: info = get_manifest_info(repo, tag, token_pull) created = parse_time(info["created"]) if created < cutoff and info["digest"]: extra_old.append({"tag": tag, "digest": info["digest"], "created": info["created"]}) if extra_old: pr_to_delete.extend(extra_old) print(f" pr-days兜底: 额外清理{len(extra_old)}个超期打开PR镜像(>{pr_days}天)") else: # 无Gitea token,降级为按pr_days天保留(默认7天) print(" 模式: 按时间保留7天(无Gitea token降级)") cutoff = datetime.now(timezone.utc) - timedelta(days=7) for tag in pr_tags_list: info = get_manifest_info(repo, tag, token_pull) created = parse_time(info["created"]) if created < cutoff and info["digest"]: pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]}) print(f" 将删除PR镜像: {len(pr_to_delete)}个") # --- Commit tag清理:保留最近N个 --- print() print(" 获取commit tag创建时间...") commit_tag_infos = [] 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 commit_tag_infos.append({"tag": tag, "digest": info["digest"], "created": info["created"]}) if (i + 1) % 20 == 0: print(" 已获取", i + 1, "/", len(commit_tags), "...") if errors: print(" 注意:", errors, "个tag获取manifest失败") # 按时间倒序排序 commit_tag_infos.sort(key=lambda x: parse_time(x["created"]), reverse=True) # 确定要删除的commit tag commit_to_delete = [] if len(commit_tag_infos) > keep_count: commit_to_delete = commit_tag_infos[keep_count:] print(f" 保留前{keep_count}个commit tag,删除{len(commit_to_delete)}个") # 白名单过滤:受保护的tag不删除 if protected_tags: before = len(commit_to_delete) commit_to_delete = [t for t in commit_to_delete if not is_in_protected_list(t["tag"], protected_tags)] removed = before - len(commit_to_delete) if removed > 0: print(f" 白名单保护: 跳过{removed}个运行中镜像") # 过滤无digest的 commit_to_delete = [t for t in commit_to_delete if t["digest"]] print(f" 可删除(有digest): {len(commit_to_delete)}个") else: print(f" commit tag数量不足{keep_count}个,无需清理") # --- 合并所有待删除项 --- all_to_delete = commit_to_delete + pr_to_delete # 再次过滤白名单(PR镜像也受白名单保护) if protected_tags: before = len(all_to_delete) all_to_delete = [t for t in all_to_delete if not is_in_protected_list(t["tag"], protected_tags)] removed = before - len(all_to_delete) if removed > 0: print(f" 白名单保护(PR镜像): 跳过{removed}个") return _execute_delete(repo, all_to_delete, dry_run, len(tags)) def _execute_delete(repo, to_delete, dry_run, total_tags): """执行删除操作""" if not to_delete: print() print(" 无需删除任何tag") return total_tags, 0 # 按digest去重 seen_digests = set() unique_delete = [] for item in to_delete: if item["digest"] and item["digest"] not in seen_digests: seen_digests.add(item["digest"]) unique_delete.append(item) print() if dry_run: print(f" [DRY RUN] 将删除{len(unique_delete)}个manifest(预览模式)") for item in unique_delete[:5]: created_str = item.get("created", "")[:10] or "未知" print(f" - {item['tag'][:30]} ({created_str})") if len(unique_delete) > 5: print(f" ... 还有{len(unique_delete) - 5}个") return total_tags, len(unique_delete) token_delete = get_token(repo, "delete") deleted = 0 failed = 0 print(f" 开始删除{len(unique_delete)}个唯一manifest...") for item in unique_delete: success, result = delete_manifest(repo, item["digest"], token_delete) if success: deleted += 1 print(f" 已删除: {item['tag'][:30]}") else: failed += 1 print(f" 删除失败: {item['tag'][:30]} - {result}") print() print(f" 删除完成: 成功{deleted}个,失败{failed}个") return total_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("--dry-run", action="store_true", help="预览模式,不实际删除") parser.add_argument("--execute", action="store_true", help="实际执行删除") parser.add_argument("--repo", type=str, default="", help="只清理指定仓库") parser.add_argument("--pr-sha", type=str, default="", help="PR关闭模式:删除指定commit sha的PR镜像") parser.add_argument("--protected-tags", type=str, default="", help="受保护tag列表,逗号分隔(运行中镜像白名单)") parser.add_argument("--skip-pr-check", action="store_true", help="跳过Gitea PR状态检查(纯按时间清理PR镜像)") parser.add_argument( "--pr-days", type=int, default=0, help="PR镜像保留天数(超过天数的PR镜像会被清理,0表示不按天数清理)" ) 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 --pr-sha abc123 --execute # PR关闭时清理") sys.exit(1) # 凭证检查 global USERNAME, PASSWORD if not USERNAME or not PASSWORD: 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) # 解析受保护tag protected_tags = set() if args.protected_tags: protected_tags = set(t.strip() for t in args.protected_tags.split(",") if t.strip()) dry_run = args.dry_run or not args.execute mode = "预览模式" if dry_run else "执行模式" print("=" * 60) print("ACR 镜像清理工具(增强版)-", mode) print("=" * 60) print("Registry:", REGISTRY) print("Namespace:", NAMESPACE) if args.pr_sha: print("模式: PR关闭清理") print("PR commit SHA:", args.pr_sha[:12]) else: print("模式: Cron全量清理") print("保留commit tag数:", args.keep) print("PR状态检查:", "关闭" if args.skip_pr_check else "开启") if protected_tags: print("白名单tag数:", len(protected_tags)) print() # PR模式不需要查Gitea pr_open_set = None if not args.pr_sha and not args.skip_pr_check and GITEA_TOKEN: print("获取打开的PR列表...") pr_open_set = gitea_get_open_prs() if pr_open_set is not None: print(f" 打开的PR: {len(pr_open_set)}个") 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, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set, pr_days=args.pr_days ) 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()