e9bc340612
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 1m39s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m11s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 58s
CI/CD Pipeline / Unit Tests (push) Failing after 5m46s
CI/CD Pipeline / Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Frontend Lint (push) Successful in 41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m9s
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 14m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m0s
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
ACR镜像自动清理增强版: - 3种执行模式:PR-SHA清理/cron全量/手动预览 - 生产镜像保护:main/v*版本tag/固定tag永不删除 - develop镜像保留最近20个commit hash tag - PR镜像关闭后自动清理(Gitea PR状态检查 + 7天降级保留) - 安全兜底:staging运行中镜像白名单 - workflow触发:cron每天03:00 + PR关闭事件 + 手动触发
654 lines
24 KiB
Python
Executable File
654 lines
24 KiB
Python
Executable File
#!/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) 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信息。
|
||
返回: {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) 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) 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:
|
||
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-<sha>)"""
|
||
return tag.startswith("pr-")
|
||
|
||
|
||
def extract_sha_from_pr_tag(tag):
|
||
"""从pr-<sha> 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)
|
||
|
||
# 缓存仓库不清理
|
||
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>,sha可能属于某个PR
|
||
# 简化策略:收集所有打开PR的commit sha,在白名单里的保留
|
||
print(" 模式: Gitea PR状态检查")
|
||
open_pr_shas = set()
|
||
# 这里做了简化:因为每个PR都查commits太慢,我们用另一种方式
|
||
# 对于PR tag,先尝试匹配PR编号(如果tag名里有编号),否则按时间
|
||
# 实际pr-<sha>没法直接知道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创建时间...")
|
||
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镜像)")
|
||
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
|
||
)
|
||
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()
|