diff --git a/shell/check_images.sh b/shell/check_images.sh new file mode 100755 index 0000000..f12a877 --- /dev/null +++ b/shell/check_images.sh @@ -0,0 +1,487 @@ +#!/usr/bin/env bash +# +# check_images.sh — 检查 deploy.stack 常用 Docker 镜像的最新版本 +# +# 数据源: +# - Docker Hub v2 API: https://hub.docker.com/v2/repositories///tags/ +# - 通用 registry v2: https:///v2//tags/list +# +# 用法: +# ./check_images.sh # 全量 +# ./check_images.sh gitea nginx postgres # 名称包含关键字的镜像 +# ./check_images.sh -j 20 # 并发数(默认 10) +# ./check_images.sh --json # JSON 输出 +# ./check_images.sh --pre # 含 rc/beta/dev 等预发布 +# ./check_images.sh --outdated # 只显示可升级的 +# ./check_images.sh --proxy # source ./proxy.sh 后开启代理 +# ./check_images.sh --timeout 20 # 单镜像超时(秒) +# ./check_images.sh -h | --help # 帮助 +# +# 退出码: +# 0 = 全部最新或仅有 unknown;1 = 存在可升级;2 = 致命错误 +# +# 注意: +# - 私有仓库(hub.tp229.com:3500、hub.wesais.cn、hub.6t7.net 等)大多不开 +# 匿名 tags API,这些会标 "unknown",不会算作失败 +# - Docker Hub 限流:匿名 100 req/IP/6h;并发 > 20 易触发 429 +# - tag 命名差异大(日期 tag it-tools:20241022、变体 tag mysql:8.4-alpine +# 等),本脚本按 "last_updated 倒序 + 非预发布" 选 "最新",不做语义版本 +# 比较,仅字符串对比 +# - 默认排除预发布标记:-rc / -beta / -alpha / -dev / -pre / -edge / +# nightly / experimental / testing;edge 频道镜像(netdata/netdata:edge-0) +# 在 --pre 模式下可见 +# +# 扩展: +# 如需新增/修改镜像,编辑下方 IMAGES 数组即可。 +# +# 依赖:curl、python3(仅用于 JSON 解析) + +set -euo pipefail + +# ===================== 默认配置 ===================== +PARALLEL=10 +JSON_MODE=0 +INCLUDE_PRE=0 +ONLY_OUTDATED=0 +USE_PROXY=0 +TIMEOUT=15 +declare -a FILTERS=() + +# 镜像清单:image|current_tag|note +# - current_tag 写 "-" 表示只查最新不比较;latest 也算"已最新" +# - note 仅展示用 +IMAGES=( + # ---- 自托管应用 ---- + "vaultwarden/server|1.36.0-alpine|" + "xhofe/alist|v3.32.0-ffmpeg|带 ffmpeg 变体" + "adminer|5.4.2|" + "anqicms/anqicms|latest|" + "mouday/domain-admin|v1.6.78|" + "pubuzhixing/drawnix|v0.4.1|" + "sigoden/dufs|v0.46.0|" + "pawelmalak/flame|multiarch2.3.1|多架构" + "gitea/gitea|1.27.3-rootless|rootless 变体" + "binwiederhier/ntfy|v2.24|" + "joplin/server|3.6.1|" + "neosmemo/memos|0.30.0|" + "n8nio/n8n|2.3.6|" + "redf0x1/camofox-browser|2.4.7|" + "nextcloud/all-in-one|latest|" + "owncloud/ocis|latest|" + "wg-easy/wg-easy|15.2.2|" + + # ---- 数据库 ---- + "redis|8.4.0-alpine|alpine 变体" + "postgres|18.4|" + "mysql|8.4|LTS" + "percona|8.0.35-27|" + "mongo|5.0|" + "couchdb|3.5|" + "valkey/valkey|9.0.0|" + "quay.io/coreos/etcd|v3.5.0|" + + # ---- 监控 ---- + "grafana/grafana|13.0.2|" + "grafana/loki|3.6.2|" + "victoriametrics/victoria-metrics|v1.126.0|" + "flashcatcloud/nightingale|9.0.0|" + "flashcatcloud/categraf|v0.4.15|" + "netdata/netdata|edge-0|edge 频道" + + # ---- 反代/网关 ---- + "haproxy|3.3.0|" + "caddy|2.10.0|" + "traefik|3.5.0|" + "portainer/portainer-ce|2.27.4|" + "portainer/agent|2.27.4|" + + # ---- 私有仓库(多返回 unknown)---- + "hub.tp229.com:3500/registry|3.0|私有" + "hub.tp229.com:3500/cnphpbb/registry-ui|latest|私有" + "hub.6t7.net/cnphpbb/it-tools|20241022|日期 tag" + "hub.wesais.cn/cnphpbb/mynat|v250603|日期 tag" + "hub.tp229.com:3500/ansible-alpine|py3.13-rootless|私有" + "hub.tp229.com:3500/gitea/gitea|1.27.3-rootless|私有镜像示例" +) + +# ===================== 参数解析 ===================== +while [[ $# -gt 0 ]]; do + case "$1" in + -j|--parallel) PARALLEL="$2"; shift 2 ;; + --json) JSON_MODE=1; shift ;; + --pre) INCLUDE_PRE=1; shift ;; + --outdated) ONLY_OUTDATED=1; shift ;; + --proxy) USE_PROXY=1; shift ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + -h|--help) + sed -n '2,30p' "$0" + exit 0 + ;; + -*) + echo "未知选项: $1" >&2 + echo "使用 -h 查看帮助" >&2 + exit 2 + ;; + *) + FILTERS+=("$1"); shift + ;; + esac +done + +# ===================== 代理 ===================== +if [[ $USE_PROXY -eq 1 ]]; then + _proxy_sh="$(cd "$(dirname "$0")" && pwd)/proxy.sh" + if [[ -f "$_proxy_sh" ]]; then + # shellcheck disable=SC1090 + source "$_proxy_sh" + proxy_on >/dev/null || true + else + echo "⚠️ 未找到 proxy.sh(${_proxy_sh}),跳过代理" >&2 + fi +fi + +# ===================== 依赖检查 ===================== +for cmd in curl python3; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "❌ 缺少依赖: $cmd" >&2 + exit 2 + fi +done + +# ===================== 函数 ===================== + +# 解析 registry 类型与 API URL,输出 "|" +resolve_registry() { + local image="$1" + # 通用 v2 registry:含端口或知名前缀 + if [[ "$image" == *":"*"/"* ]] || \ + [[ "$image" == hub.* ]] || \ + [[ "$image" == ghcr.io/* ]] || \ + [[ "$image" == quay.io/* ]] || \ + [[ "$image" == gcr.io/* ]]; then + local host_port="${image%%/*}" + local repo_path="${image#*/}" + echo "v2|https://${host_port}/v2/${repo_path}/tags/list" + return + fi + # Docker Hub + local ns repo + if [[ "$image" == */* ]]; then + ns="${image%%/*}" + repo="${image##*/}" + else + ns="library" + repo="$image" + fi + echo "dockerhub|https://hub.docker.com/v2/repositories/${ns}/${repo}/tags/?page_size=100&ordering=last_updated" +} + +# python3 解析 JSON,从 stdin 读取 +# 输出 "TAG" 或 "ERR|reason" +parse_latest() { + python3 - "$INCLUDE_PRE" <<'PYEOF' +import json, sys, re + +include_pre = sys.argv[1] == "1" +raw = sys.stdin.read() +if not raw.strip(): + print("ERR|empty response") + sys.exit(0) + +try: + data = json.loads(raw) +except json.JSONDecodeError as e: + print(f"ERR|invalid json: {e}") + sys.exit(0) + +tags = [] +if isinstance(data, dict) and "results" in data: + for t in data["results"]: + if isinstance(t, dict): + tags.append((t.get("name", ""), t.get("last_updated", "") or "")) +elif isinstance(data, dict) and "tags" in data: + for n in data.get("tags") or []: + tags.append((n, "")) +else: + if isinstance(data, dict): + detail = data.get("detail") or data.get("message") or "unknown" + else: + detail = "unknown" + print(f"ERR|{detail}") + sys.exit(0) + +if not tags: + print("ERR|no tags") + sys.exit(0) + +def version_key(name): + """可排序的版本主键:v1.27.3 → (1,27,3);无法识别 → (0,)""" + m = re.match(r"^v?(\d+(?:\.\d+)*)", name) + if not m: + return (0,) + return tuple(int(p) for p in m.group(1).split(".")) + +# 主:版本降序;次:好变体优先;再次:last_updated 降序 +tags.sort(key=lambda x: (version_key(x[0]), + 1 if is_good_variant(x[0]) else 0, + x[1], x[0]), reverse=True) + +# 按 . 或 - 切分 tag,任一段命中预发布关键词即视为预发布 +PRE_MARKERS = { + "rc", "beta", "alpha", "dev", "pre", "edge", + PRE_MARKERS = {"rc", "beta", "alpha", "dev", "pre", "edge", + "nightly", "experimental", "testing", "ea", +} + +def is_pre_release(name: str) -> bool: + parts = re.split(r"[.\-]", name.lower()) + # 去掉头尾数字:rc1→rc、19beta3→beta、2.0.0-rc.1→rc + parts = [re.sub(r"^\d+|\d+$", "", p) for p in parts] + return any(p in PRE_MARKERS for p in parts) + +# 版本型 tag 前缀:v可选,后接数字与点(如 2.10.0、v1.27.3-rootless、8.4.0-alpine) +VERSION_PREFIX = re.compile(r"^v?\d+(?:\.\d+)*") +def looks_like_version(name: str) -> bool: + return bool(VERSION_PREFIX.match(name)) + +# 不适合作为 "最新推荐" 的后缀:Windows 变体 / Oracle Linux / x86_64 桌面 / 早期访问 / CentOS / 企业版 / builder +BAD_VARIANTS = { + "nanoserver", "windowsservercore", "wincore", + "oraclelinux", "oracle", + "pc", # x86_64 桌面构建(服务器场景不需要) + "ea", # Early Access + "centos", "rockylinux", "alma", # RHEL 系变体(服务器场景多不用) + "enterprise", "fips", # 企业版 / FIPS(需要商业授权) + "builder", # 镜像构建阶段,不适合运行时 +} +def is_good_variant(name: str) -> bool: + base = re.sub(r"^v?\d+(?:\.\d+)*", "", name.lower()).lstrip("-") + return not any(bad in base for bad in BAD_VARIANTS) + +# 三遍选择:版本型 > 非预发布非平台变体 > latest 回退 +fallback_latest = None +seen = set() +for name, _ in tags: + if not name or name in seen: + continue + seen.add(name) + if name.lower() == "latest": + fallback_latest = name + continue + if not include_pre and is_pre_release(name): + continue + if not looks_like_version(name): + continue + print(name) + sys.exit(0) + +# 第二遍:含日期/字母前缀但仍像版本(20241022、py3.13、multiarch2.3.1 等) +for name, _ in tags: + if not name or name in seen: + continue + seen.add(name) + if name.lower() == "latest": + continue + if not include_pre and is_pre_release(name): + continue + print(name) + sys.exit(0) + +if fallback_latest: + print(fallback_latest) +elif tags: + print(tags[0][0]) +PYEOF +} + +# 检查单个镜像,stdout: IMAGE|CURRENT|LATEST|STATUS|NOTE +check_one() { + local image="$1" + local current="$2" + local note="$3" + + local api_info api_type url + api_info=$(resolve_registry "$image") + api_type="${api_info%%|*}" + url="${api_info#*|}" + + local body + if ! body=$(curl -sS --max-time "$TIMEOUT" "$url" 2>/dev/null); then + printf '%s|%s|%s|unknown|%s\n' "$image" "$current" "(unreachable)" "$note" + return 0 + fi + + if [[ -z "$body" ]]; then + printf '%s|%s|%s|unknown|%s\n' "$image" "$current" "(unreachable)" "$note" + return 0 + fi + + local parsed latest + parsed=$(printf '%s' "$body" | parse_latest) || true + if [[ "$parsed" == ERR* ]]; then + printf '%s|%s|%s|unknown|%s\n' "$image" "$current" "$parsed" "$note" + return 0 + fi + latest="$parsed" + + local status + # 同版本(忽略后缀)视为已最新:1.36.0 vs 1.36.0-alpine、3.6.1 vs 3.6.1-bookworm + local cur_base lat_base + cur_base=$(echo "$current" | sed -E 's/^v?([0-9]+(\.[0-9]+)*).*/\1/') + lat_base=$(echo "$latest" | sed -E 's/^v?([0-9]+(\.[0-9]+)*).*/\1/') + if [[ "$current" == "-" ]] || [[ "$current" == "latest" ]] \ + || [[ "$current" == "$latest" ]] || [[ "$cur_base" == "$lat_base" && -n "$cur_base" ]]; then + status="up-to-date" + else + status="outdated" + fi + + printf '%s|%s|%s|%s|%s\n' "$image" "$current" "$latest" "$status" "$note" +} + +# 表格输出 +display_table() { + local -a rows=("$@") + local total=${#rows[@]} + local up_to_date=0 outdated=0 unknown=0 + + for row in "${rows[@]}"; do + IFS='|' read -r _ _ _ status _ <<< "$row" + case "$status" in + up-to-date) up_to_date=$((up_to_date+1)) ;; + outdated) outdated=$((outdated+1)) ;; + unknown) unknown=$((unknown+1)) ;; + esac + done + + printf '\033[36m┌──────────────────────────────────────────────────────────────────────────────────────────────┐\033[0m\n' + printf '\033[36m│\033[0m \033[1;36m%-46s %-14s %-14s %-14s\033[0m \033[36m│\033[0m\n' "IMAGE" "CURRENT" "LATEST" "STATUS" + printf '\033[36m├──────────────────────────────────────────────────────────────────────────────────────────────┤\033[0m\n' + + for row in "${rows[@]}"; do + IFS='|' read -r img cur latest status _ <<< "$row" + local status_disp latest_disp + case "$status" in + up-to-date) + status_disp=$(printf '\033[32m✓ up-to-date\033[0m') + latest_disp="$latest" + ;; + outdated) + status_disp=$(printf '\033[33m↑ outdated\033[0m') + latest_disp=$(printf '\033[36m%s\033[0m' "$latest") + ;; + unknown) + status_disp=$(printf '\033[31m✗ unknown\033[0m') + latest_disp=$(printf '\033[2m%s\033[0m' "$latest") + ;; + *) + status_disp="$status" + latest_disp="$latest" + ;; + esac + local img_short="$img" + if [[ ${#img_short} -gt 46 ]]; then + img_short="${img_short:0:43}..." + fi + printf '\033[36m│\033[0m %-46s %-14s %-14s %-30b \033[36m│\033[0m\n' \ + "$img_short" "$cur" "$latest_disp" "$status_disp" + done + + printf '\033[36m└──────────────────────────────────────────────────────────────────────────────────────────────┘\033[0m\n' + + echo + printf '\033[1;36m📊 汇总:\033[0m 总计 %d | \033[32m✓ %d\033[0m | \033[33m↑ %d\033[0m | \033[31m? %d\033[0m\n' \ + "$total" "$up_to_date" "$outdated" "$unknown" +} + +# JSON 输出 +display_json() { + local -a rows=("$@") + echo "[" + local first=1 + for row in "${rows[@]}"; do + IFS='|' read -r img cur latest status note <<< "$row" + if [[ $first -eq 1 ]]; then + printf ' {"image":"%s","current":"%s","latest":"%s","status":"%s","note":"%s"}' \ + "$img" "$cur" "$latest" "$status" "$note" + first=0 + else + printf ',\n {"image":"%s","current":"%s","latest":"%s","status":"%s","note":"%s"}' \ + "$img" "$cur" "$latest" "$status" "$note" + fi + done + echo + echo "]" +} + +# ===================== 主逻辑 ===================== + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +declare -a rows=() +active=0 +idx=0 + +for entry in "${IMAGES[@]}"; do + IFS='|' read -r image current note <<< "$entry" + [[ -z "$image" ]] && continue + + if [[ ${#FILTERS[@]} -gt 0 ]]; then + matched=0 + for f in "${FILTERS[@]}"; do + [[ "$image" == *"$f"* ]] && matched=1 + done + [[ $matched -eq 0 ]] && continue + fi + + ( + check_one "$image" "$current" "$note" > "$tmpdir/$idx.out" 2>&1 + ) & + + active=$((active+1)) + idx=$((idx+1)) + + if [[ $active -ge $PARALLEL ]]; then + wait -n 2>/dev/null || true + active=$((active-1)) + fi +done + +wait + +# 收集结果 +for ((i=0; i