forked from DevOps/deploy.stack
488 lines
16 KiB
Bash
Executable File
488 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
#
|
||
# check_images.sh — 检查 deploy.stack 常用 Docker 镜像的最新版本
|
||
#
|
||
# 数据源:
|
||
# - Docker Hub v2 API: https://hub.docker.com/v2/repositories/<ns>/<repo>/tags/
|
||
# - 通用 registry v2: https://<host>/v2/<repo>/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,输出 "<api_type>|<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<idx; i++)); do
|
||
if [[ -f "$tmpdir/$i.out" ]]; then
|
||
rows+=("$(cat "$tmpdir/$i.out")")
|
||
fi
|
||
done
|
||
|
||
# 仅显示过期
|
||
if [[ $ONLY_OUTDATED -eq 1 ]]; then
|
||
declare -a filtered=()
|
||
for row in "${rows[@]}"; do
|
||
IFS='|' read -r _ _ _ status _ <<< "$row"
|
||
[[ "$status" == "outdated" ]] && filtered+=("$row")
|
||
done
|
||
rows=("${filtered[@]}")
|
||
fi
|
||
|
||
# 输出
|
||
if [[ $JSON_MODE -eq 1 ]]; then
|
||
display_json "${rows[@]}"
|
||
else
|
||
display_table "${rows[@]}"
|
||
fi
|
||
|
||
# 退出码
|
||
outdated_count=0
|
||
for row in "${rows[@]}"; do
|
||
IFS='|' read -r _ _ _ status _ <<< "$row"
|
||
[[ "$status" == "outdated" ]] && outdated_count=$((outdated_count+1))
|
||
done
|
||
|
||
if [[ $outdated_count -gt 0 ]]; then
|
||
exit 1
|
||
fi
|
||
exit 0
|