Files
deploy.stack/shell/check_images.sh
T

488 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 / testingedge 频道镜像(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