From 6f28fc49d6ed4d07bb77ccf8eeedf8e66b2881d7 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 25 Jul 2026 12:08:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(ci):=20ACR=E9=95=9C=E5=83=8F=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=B8=85=E7=90=86=E5=A2=9E=E5=BC=BA=E7=89=88=20-=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=88=B0main=EF=BC=88#524=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/acr-cleanup.yml | 162 ++++++++++++ scripts/ci/acr_cleanup.py | 415 ++++++++++++++++++++++++------- 2 files changed, 483 insertions(+), 94 deletions(-) create mode 100644 .gitea/workflows/acr-cleanup.yml mode change 100644 => 100755 scripts/ci/acr_cleanup.py diff --git a/.gitea/workflows/acr-cleanup.yml b/.gitea/workflows/acr-cleanup.yml new file mode 100644 index 000000000..82791d4c2 --- /dev/null +++ b/.gitea/workflows/acr-cleanup.yml @@ -0,0 +1,162 @@ +name: ACR Cleanup + +on: + schedule: + - cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00 + workflow_dispatch: + inputs: + pr_sha: + description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)" + required: false + default: "" + dry_run: + description: "预览模式(dry-run),不实际删除" + required: false + default: "true" + pull_request_target: + types: [closed] + branches: [develop, main] + +concurrency: + group: acr-cleanup-${{ gitea.ref }} + cancel-in-progress: false + +jobs: + cleanup: + name: ACR Image Cleanup + runs-on: ci-l2 + timeout-minutes: 20 + permissions: + contents: read + env: + ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com + ACR_NAMESPACE: xiaoxiakeji + ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa + GITEA_URL: https://git.xiaoxiajianji.com + GITEA_REPO: xiaoxia/xiaoxia-saas + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # ====== Cron模式:获取staging运行中镜像作为白名单 ====== + - name: Get staging running images (whitelist) + id: protected_images + if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha + env: + STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }} + run: | + set +e + echo "获取staging服务器运行中镜像作为白名单..." + mkdir -p ~/.ssh + echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + + staging_host="${STAGING_SSH_HOST:-47.98.113.167}" + staging_port="${STAGING_SSH_PORT:-22222}" + + ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null + + # 获取所有运行容器的镜像,提取tag部分 + IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \ + "root@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u) + + PROTECTED_TAGS="" + if [ -n "$IMAGES" ]; then + while IFS= read -r img; do + # 从完整镜像名中提取tag(最后一个冒号后) + tag=$(echo "$img" | rev | cut -d: -f1 | rev) + if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then + if [ -z "$PROTECTED_TAGS" ]; then + PROTECTED_TAGS="$tag" + else + PROTECTED_TAGS="$PROTECTED_TAGS,$tag" + fi + fi + done <<< "$IMAGES" + fi + + echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}" + echo "protected_tags=$PROTECTED_TAGS" >> $GITEA_OUTPUT + + # ====== Docker登录 ====== + - name: Docker login to ACR + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + run: | + printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin + + # ====== 模式1:PR关闭时清理 ====== + - name: Cleanup PR images (PR closed) + if: gitea.event_name == 'pull_request_target' + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + PR_SHA: ${{ gitea.event.pull_request.head.sha }} + PR_NUMBER: ${{ gitea.event.pull_request.number }} + run: | + echo "============================================" + echo " PR #$PR_NUMBER 已关闭,清理对应镜像" + echo " Head SHA: ${PR_SHA::12}" + echo "============================================" + echo "" + python3 scripts/ci/acr_cleanup.py \ + --pr-sha "$PR_SHA" \ + --execute + + # ====== 模式2:Cron全量清理 ====== + - name: Full cleanup (cron / manual) + if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }} + DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }} + run: | + echo "============================================" + echo " ACR 全量清理(${{ gitea.event_name }})" + echo "============================================" + echo "" + + # 决定是否dry-run + DRY_RUN_FLAG="" + if [ "$DRY_RUN_INPUT" = "true" ]; then + DRY_RUN_FLAG="--dry-run" + echo "模式: 预览模式 (dry-run)" + else + echo "模式: 执行模式" + fi + echo "" + + python3 scripts/ci/acr_cleanup.py \ + --keep 20 \ + --protected-tags "$PROTECTED_TAGS" \ + $DRY_RUN_FLAG + + # ====== 模式3:手动指定PR SHA清理 ====== + - name: Cleanup specific PR image (manual) + if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + PR_SHA: ${{ gitea.event.inputs.pr_sha }} + DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }} + run: | + echo "手动清理PR镜像: ${PR_SHA::12}" + echo "" + + DRY_RUN_FLAG="" + if [ "$DRY_RUN_INPUT" = "true" ]; then + DRY_RUN_FLAG="--dry-run" + fi + + python3 scripts/ci/acr_cleanup.py \ + --pr-sha "$PR_SHA" \ + $DRY_RUN_FLAG diff --git a/scripts/ci/acr_cleanup.py b/scripts/ci/acr_cleanup.py old mode 100644 new mode 100755 index a705f618e..8bcbbe17b --- a/scripts/ci/acr_cleanup.py +++ b/scripts/ci/acr_cleanup.py @@ -1,17 +1,32 @@ #!/usr/bin/env python3 """ -ACR 镜像清理脚本 -策略: +ACR 镜像清理脚本(增强版) + +清理策略: - 版本tag (v*): 永久保留 - 固定tag (latest, main, develop, master): 永久保留 - 缓存镜像 (*-cache): 永久保留 -- PR预览tag (pr-*): 保留 N 天(默认7天) +- 受保护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 # 预览,不实际删除 - python3 acr_cleanup.py --execute # 实际执行删除 - python3 acr_cleanup.py --keep 20 --execute # 保留最近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 @@ -23,7 +38,8 @@ 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") @@ -31,6 +47,11 @@ 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", @@ -49,6 +70,9 @@ 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 @@ -81,22 +105,19 @@ def http_get_json(url, token, accept_header): def get_manifest_info(repo, tag, token): """ - 获取tag的manifest信息,支持OCI index和普通manifest两种格式。 - 返回: {digest, created, media_type} - - digest: 顶层manifest的digest(用于删除) - - created: 镜像创建时间 + 获取tag的manifest信息。 + 返回: {digest, created, media_type, error} """ url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag result = {"digest": "", "created": "", "media_type": "", "error": ""} - # 先尝试 OCI index 格式(ACR多用这种) + # 先尝试 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) - # OCI index:找amd64的manifest,再取config blob manifests = data.get("manifests", []) amd64_manifest = None for m in manifests: @@ -104,7 +125,6 @@ def get_manifest_info(repo, tag, token): if arch == "amd64": amd64_manifest = m break - # 没有amd64就用第一个 if not amd64_manifest and manifests: amd64_manifest = manifests[0] @@ -114,7 +134,6 @@ def get_manifest_info(repo, tag, token): 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", "") @@ -181,6 +200,58 @@ def delete_manifest(repo, digest, token): 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) 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) 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: @@ -204,12 +275,49 @@ def is_fixed_tag(tag): def is_pr_tag(tag): - """判断是否是PR预览tag""" + """判断是否是PR预览tag (pr-)""" return tag.startswith("pr-") -def cleanup_repo(repo, keep_count, pr_days, dry_run): - """清理单个仓库""" +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): + """ + 清理单个仓库 + + 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) @@ -225,6 +333,37 @@ def cleanup_repo(repo, keep_count, pr_days, dry_run): 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 = [] @@ -243,122 +382,185 @@ def cleanup_repo(repo, keep_count, pr_days, dry_run): print(" 版本tag (v*):", len(version_tags), "-> 永久保留") print(" 固定tag:", len(fixed_tags), "-> 永久保留") - print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "天") + print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 已关闭PR的删除") print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "个") + print(" 白名单tag:", len(protected_tags), "个") - # 获取所有commit tag的创建时间 + # --- 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状态检查") + open_pr_shas = set() + # 这里做了简化:因为每个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) 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}个") + else: + # 无Gitea token,降级为按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创建时间...") - tag_info_list = [] + 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 - # 取不到信息的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": ""}) + 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失败") - # 按时间倒序排序(空时间放最后) - tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True) + # 按时间倒序排序 + commit_tag_infos.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) + 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" 保护当前构建tag: {protected_tag[:12]} (跳过{removed}个)") + print(f" 白名单保护: 跳过{removed}个运行中镜像") - to_del_valid = [t for t in to_delete if t["digest"]] - print(" 可删除(有digest):", len(to_del_valid), "个") + # 过滤无digest的 + commit_to_delete = [t for t in commit_to_delete if t["digest"]] + print(f" 可删除(有digest): {len(commit_to_delete)}个") else: - print(" commit tag数量不足", keep_count, ",无需清理") + print(f" 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 = commit_to_delete + 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"]] + # 再次过滤白名单(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}个") - if not all_to_delete: + 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 len(tags), 0 + return total_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去重,避免重复删除同一镜像 + # 按digest去重 seen_digests = set() unique_delete = [] - for item in all_to_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(" 开始删除", len(unique_delete), "个唯一manifest...") + 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(" 已删除:", item["tag"][:20]) + print(f" 已删除: {item['tag'][:30]}") else: failed += 1 - print(" 删除失败:", item["tag"][:20], "-", result) + print(f" 删除失败: {item['tag'][:30]} - {result}") print() - print(" 删除完成: 成功", deleted, "个,失败", failed, "个") - return len(tags), deleted + print(f" 删除完成: 成功{deleted}个,失败{failed}个") + return total_tags, deleted + + +# ========== 主函数 ========== def main(): - parser = argparse.ArgumentParser(description="ACR镜像清理工具") + 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="只清理指定仓库") + 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镜像)") args = parser.parse_args() # 必须指定 --dry-run 或 --execute @@ -368,13 +570,12 @@ def main(): print("示例:") print(" python3 acr_cleanup.py --dry-run # 预览清理效果") print(" python3 acr_cleanup.py --execute # 实际执行清理") - print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个") + print(" python3 acr_cleanup.py --pr-sha abc123 --execute # PR关闭时清理") 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: @@ -391,15 +592,39 @@ def main(): 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("ACR 镜像清理工具 -", mode) + + print("=" * 60) + print("ACR 镜像清理工具(增强版)-", mode) + print("=" * 60) print("Registry:", REGISTRY) print("Namespace:", NAMESPACE) - print("保留commit tag数:", args.keep) - print("PR预览保留天数:", args.pr_days) + 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] @@ -407,11 +632,13 @@ def main(): total_deleted = 0 total_tags = 0 for repo in repos_to_clean: - count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run) + count, deleted = cleanup_repo( + repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set + ) total_tags += count total_deleted += deleted + print() - print() print("=" * 60) print("清理完成") print(" 总tag数:", total_tags) -- 2.54.0