diff --git a/shell/check_images.py b/shell/check_images.py new file mode 100755 index 0000000..53d56ed --- /dev/null +++ b/shell/check_images.py @@ -0,0 +1,593 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +check_images.py — 检查 deploy.stack 常用 Docker 镜像最新版本 + +数据源: + - Docker Hub v2 API: https://hub.docker.com/v2/repositories///tags/ + - 通用 registry v2: https:///v2//tags/list + +用法: + ./check_images.py # 全量 + ./check_images.py gitea nginx postgres # 名称包含关键字的镜像 + ./check_images.py -j 20 # 并发数(默认 10) + ./check_images.py --json # JSON 输出 + ./check_images.py --pre # 含 rc/beta/dev 等预发布 + ./check_images.py --outdated # 只显示可升级的 + ./check_images.py --proxy # source ./proxy.sh 后开启代理 + ./check_images.py --timeout 20 # 单镜像超时(秒) + ./check_images.py --transport tinyfish # 强制 tinyfish(auto|curl|tinyfish) + ./check_images.py -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 + - 默认排除预发布标记:-rc / -beta / -alpha / -dev / -pre / -edge / + nightly / experimental / testing / -ea + - 默认排除平台变体:nanoserver / windowsservercore / wincore / + oraclelinux / oracle / pc / centos / rockylinux / alma / + enterprise / fips / builder + - 同版本(忽略后缀)视为已最新:1.36.0 ≡ 1.36.0-alpine + +依赖:python3 标准库(urllib、json、subprocess、concurrent.futures) +可选:tinyfish CLI(auto/tinyfish 模式下使用,作为 curl 的回退) +""" + +import argparse +import json +import os +import pathlib +import re +import subprocess +import sys +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass + +# ===================== ANSI 颜色 ===================== +class C: + R = "\033[0m" + B = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GRN = "\033[32m" + YEL = "\033[33m" + CYN = "\033[36m" + BC = "\033[1;36m" + + +# ===================== 镜像清单发现 ===================== +# 不再手工维护 IMAGES 列表,改从 /env.cfg.example 自动扫描 +# 扫描规则: +# 1. 递归找 **/env.cfg.example +# 2. 提取所有以 IMAGE* 开头的变量(如 IMAGE_TAG、IMAGE_NAME、IMAGE_TAG_V18、 +# IMAGE_TAG_REDISV8、Cadvisor_Image 等);跳过纯版本号变量 IMAGE_TAG_VER +# 3. 解析 ${VAR} 变量引用(递归最多 5 层防环) +# 4. 只保留看起来像 image:tag 格式的值(含冒号、名字部分含 . / - _) +# 5. 按 (image, tag) 去重,保留首次出现位置 +# +# 例外(无 env.cfg.example 或不走 IMAGE_TAG 约定的服务): +# - harbor: 官方安装器生成的 compose.yaml,不手工改 +# - mixapi/newapi: 在 env.cfg.example 中用 IMAGE_TAG 但版本号频繁迭代; +# 默认走自动扫描,同一镜像会同时列出 IMAGE_TAG 和 IMAGE_TAG_Vxx +# 手动补漏:MANUAL_IMAGES 在自动扫描结果上合并(一般留空) +MANUAL_IMAGES: list[tuple[str, str, str]] = [ + # ("some/image", "1.0.0", "手动补充:为什么自动扫不到的原因"), +] + +_VAR_REF_RE = re.compile(r"\$\{(\w+)\}") +_IMAGE_NAME_OK = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._/-]*$") + + +def parse_env_file(text: str) -> dict[str, str]: + """解析 KEY=VALUE 形式的配置,跳过空行/注释/引号包裹。""" + result = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, _, v = line.partition("=") + k, v = k.strip(), v.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'): + v = v[1:-1] + result[k] = v + return result + + +def resolve_variables(value: str, env: dict[str, str], _depth: int = 0) -> str: + """递归替换 ${VAR};最多 5 层防循环引用。""" + if _depth >= 5 or "${" not in value: + return value + + def repl(m: re.Match) -> str: + v = env.get(m.group(1), m.group(0)) + return resolve_variables(v, env, _depth + 1) if "${" in v else v + + return _VAR_REF_RE.sub(repl, value) + + +def split_image_tag(value: str) -> tuple[str, str]: + """拆 'image:tag'。正确处理私有仓库含端口的场景。""" + if ":" not in value: + return value, "" + # 冒号在最后一个斜杠之后 → tag 分隔符 + last_colon = value.rfind(":") + last_slash = value.rfind("/") + if last_colon <= last_slash: + # 形如 redis (无 tag)或 host:5000 (无 repo)→ 不能用 + return value, "" + return value[:last_colon], value[last_colon + 1:] + + +def looks_like_image_ref(value: str) -> bool: + if ":" not in value: + return False + name, tag = split_image_tag(value) + return bool(name and tag and _IMAGE_NAME_OK.match(name) and _IMAGE_NAME_OK.match(tag)) + + +def discover_images(repo_root: str = ".") -> list[tuple[str, str, str]]: + """扫描所有 env.cfg.example,生成 (image, current_tag, note) 列表。""" + root = pathlib.Path(repo_root) + found: list[tuple[str, str, str]] = [] + + # 完全跳过的顶层目录(非服务) + SKIP_TOPDIRS = { + "shell", "etc", "config", "crontab", "apt.list", + "i2c.py", ".trae", "$user", + } + # 需要下钻一层的容器目录(使用其子目录名作为 service) + NESTED_DIRS = {"dbSer", "WireGuardVPN", "base"} + + for env_file in sorted(root.rglob("env.cfg.example")): + rel = env_file.relative_to(root) + parts = rel.parts + if not parts or parts[0] in SKIP_TOPDIRS: + continue + + # builder/ 是开发容器镜像(golang/node/alpine/debian),其版本故意锁定,不扫描 + if parts[0] == "builder": + continue + + # 决定 service 名称 + if parts[0] in NESTED_DIRS and len(parts) >= 3: + service = parts[1] + else: + service = parts[0] if len(parts) == 2 else parts[0] + + try: + text = env_file.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + + env = parse_env_file(text) + for key in sorted(env.keys()): + if not re.match(r"^IMAGE", key, re.IGNORECASE): + continue + if key.upper() == "IMAGE_TAG_VER": + continue + + resolved = resolve_variables(env[key], env) + if not looks_like_image_ref(resolved): + continue + + image, tag = split_image_tag(resolved) + note = f"{service}/{key}" + found.append((image, tag, note)) + + found.extend(MANUAL_IMAGES) + + # 去重:同 (image, tag) 只保留首次出现 + seen: set[tuple[str, str]] = set() + unique: list[tuple[str, str, str]] = [] + for img, tag, note in found: + key = (img.lower(), tag) + if key in seen: + continue + seen.add(key) + unique.append((img, tag, note)) + + return unique + + +# ===================== 预发布 / 变体识别 ===================== +PRE_MARKERS = { + "rc", "beta", "alpha", "dev", "pre", "edge", + "nightly", "experimental", "testing", "ea", +} +BAD_VARIANTS = { + "nanoserver", "windowsservercore", "wincore", + "oraclelinux", "oracle", + "pc", "ea", + "centos", "rockylinux", "alma", + "enterprise", "fips", "builder", +} +VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)") + + +def is_pre_release(name: str) -> bool: + parts = re.split(r"[.\-]", name.lower()) + parts = [re.sub(r"^\d+|\d+$", "", p) for p in parts] + return any(p in PRE_MARKERS for p in parts) + + +def looks_like_version(name: str) -> bool: + return bool(VERSION_RE.match(name)) + + +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) + + +def version_key(name: str): + m = VERSION_RE.match(name) + if not m: + return (0,) + return tuple(int(p) for p in m.group(1).split(".")) + + +def pick_latest(data: dict, include_pre: bool = False): + """从 Docker Hub / registry v2 的 tags 数据中选出"最新稳定 tag"。 + 返回 (tag_str, None) 或 (None, err_msg)。""" + tags = [] + if "results" in data and isinstance(data["results"], list): + for t in data["results"]: + if isinstance(t, dict): + tags.append((t.get("name", ""), t.get("last_updated", "") or "")) + elif "tags" in data and isinstance(data["tags"], list): + for n in data["tags"]: + tags.append((n, "")) + else: + if isinstance(data, dict): + detail = data.get("detail") or data.get("message") or "unknown" + else: + detail = "unknown" + return None, f"detail: {detail}" + + if not tags: + return None, "no tags" + + # 主键:版本降序;次键:好变体优先;再次: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) + + 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 + return name, None + + # 第二遍:任意非预发布 + 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 + return name, None + + if fallback_latest: + return fallback_latest, None + return (tags[0][0], None) if tags else (None, "no usable tags") + + +# ===================== 网络层 ===================== +def resolve_registry(image: str) -> tuple[str, str]: + """返回 (api_type, url)。api_type: dockerhub | v2""" + if (":" in image and "/" in image.split(":", 1)[1]) \ + or image.startswith(("hub.", "ghcr.io/", "quay.io/", "gcr.io/")): + host_port = image.split("/", 1)[0] + repo_path = image.split("/", 1)[1] + return ("v2", f"https://{host_port}/v2/{repo_path}/tags/list") + if "/" in image: + ns, repo = image.split("/", 1) + else: + ns, repo = "library", image + return ("dockerhub", + f"https://hub.docker.com/v2/repositories/{ns}/{repo}/tags/?page_size=100&ordering=last_updated") + + +def fetch_via_curl(url: str, timeout: int) -> str: + req = urllib.request.Request(url, headers={"User-Agent": "check_images.py/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + + +def fetch_via_tinyfish(url: str, timeout: int) -> str: + if not has_tinyfish(): + raise RuntimeError("tinyfish not installed") + result = subprocess.run( + ["tinyfish", "fetch", "content", "get", "--format", "json", url], + capture_output=True, text=True, timeout=timeout, + ) + if result.returncode != 0: + raise RuntimeError(f"tinyfish exit {result.returncode}: {result.stderr[:200]}") + return result.stdout + + +def has_tinyfish() -> bool: + try: + subprocess.run(["tinyfish", "--version"], capture_output=True, timeout=3, check=False) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def fetch_json(url: str, transport: str, timeout: int) -> str: + if transport == "curl": + return fetch_via_curl(url, timeout) + if transport == "tinyfish": + return fetch_via_tinyfish(url, timeout) + raise ValueError(f"unknown transport: {transport}") + + +def probe_transport(url: str, requested: str) -> str: + """auto 模式:先试 curl,能通就用;不通且装了 tinyfish 就用 tinyfish。""" + if requested != "auto": + return requested + try: + fetch_via_curl(url, timeout=5) + return "curl" + except Exception: + pass + if has_tinyfish(): + print(f"{C.YEL}⚠️ curl 无法直连 Docker Hub,回退到 tinyfish{C.R}", file=sys.stderr) + return "tinyfish" + print(f"{C.RED}❌ curl 不通且未安装 tinyfish{C.R}", file=sys.stderr) + return "curl" # 维持 curl 让每个镜像标 (unreachable) + + +def unwrap_tinyfish(raw: str): + """如 raw 是 tinyfish 包装 JSON,则解包返回内层 dict;否则原样解析。""" + data = json.loads(raw) + if isinstance(data, dict) and "results" in data and "errors" in data: + results = data.get("results") or [] + if not results: + return None, "tinyfish: empty results" + text_node = (results[0] or {}).get("text") or {} + inner = None + for child in text_node.get("children") or []: + if isinstance(child, dict) and child.get("type") == "code" and "text" in child: + inner = child["text"] + break + if inner is None: + return None, "tinyfish: no code block" + try: + return json.loads(inner), None + except json.JSONDecodeError as e: + return None, f"tinyfish inner parse: {e}" + return data, None + + +# ===================== 业务逻辑 ===================== +@dataclass +class Result: + image: str + current: str + latest: str + status: str # up-to-date | outdated | unknown + note: str = "" + + +def parse_version_base(tag: str) -> str: + """1.36.0-alpine → 1.36.0;v1.2.3-rootless → 1.2.3""" + m = re.match(r"^v?(\d+(?:\.\d+)*)", tag) + return m.group(1) if m else "" + + +def check_one(image: str, current: str, note: str, transport: str, + timeout: int, include_pre: bool) -> Result: + api_type, url = resolve_registry(image) + try: + raw = fetch_json(url, transport, timeout) + except Exception as e: + return Result(image, current, "(unreachable)", "unknown", note) + + if not raw.strip(): + return Result(image, current, "(unreachable)", "unknown", note) + + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + return Result(image, current, f"(err: bad json: {e})", "unknown", note) + + # tinyfish 包装解包 + if isinstance(data, dict) and "results" in data and "errors" in data: + data, err = unwrap_tinyfish(raw) + if err: + return Result(image, current, f"(err: {err})", "unknown", note) + + latest, err = pick_latest(data, include_pre) + if err or latest is None: + return Result(image, current, f"(err: {err or 'no tag'})", "unknown", note) + + # 状态判定:同版本(忽略后缀)视为已最新 + cur_base = parse_version_base(current) + lat_base = parse_version_base(latest) + if (current == "-" or current == "latest" + or current == latest + or (cur_base and cur_base == lat_base)): + status = "up-to-date" + else: + status = "outdated" + return Result(image, current, latest, status, note) + + +# ===================== 代理 ===================== +def setup_proxy(use_proxy: bool): + """调用 ./proxy.sh 的 proxy_on,把导出的环境变量拉进当前进程。""" + if not use_proxy: + return + proxy_sh = os.path.join(os.path.dirname(os.path.abspath(__file__)), "proxy.sh") + if not os.path.exists(proxy_sh): + print(f"{C.YEL}⚠️ 未找到 proxy.sh({proxy_sh}),跳过代理{C.R}", file=sys.stderr) + return + try: + result = subprocess.run( + ["bash", "-c", f'. "{proxy_sh}" && proxy_on >/dev/null 2>&1 && env'], + capture_output=True, text=True, timeout=10, + ) + for line in result.stdout.splitlines(): + if "=" in line: + k, v = line.split("=", 1) + os.environ[k] = v + except Exception as e: + print(f"{C.YEL}⚠️ proxy_on 失败:{e}{C.R}", file=sys.stderr) + + +# ===================== 输出 ===================== +def display_table(results: list[Result]) -> None: + up_to_date = outdated = unknown = 0 + for r in results: + if r.status == "up-to-date": up_to_date += 1 + elif r.status == "outdated": outdated += 1 + else: unknown += 1 + + total = len(results) + box_w = 96 + bar = "─" * box_w + + print(f"{C.CYN}┌{bar}┐{C.R}") + print(f"{C.CYN}│{C.R} {C.BC}{'IMAGE':46} {'CURRENT':14} {'LATEST':14} {'STATUS':14}{C.R} {C.CYN}│{C.R}") + print(f"{C.CYN}├{bar}┤{C.R}") + + for r in results: + if r.status == "up-to-date": + sd = f"{C.GRN}✓ up-to-date{C.R}" + ld = r.latest + elif r.status == "outdated": + sd = f"{C.YEL}↑ outdated{C.R}" + ld = f"{C.CYN}{r.latest}{C.R}" + else: + sd = f"{C.RED}✗ unknown{C.R}" + ld = f"{C.DIM}{r.latest}{C.R}" + + img = r.image + if len(img) > 46: + img = img[:43] + "..." + print(f"{C.CYN}│{C.R} {img:46} {r.current:14} {ld:14} {sd:30} {C.CYN}│{C.R}".rstrip()) + + print(f"{C.CYN}└{bar}┘{C.R}") + print() + print(f"{C.BC}📊 汇总:{C.R} 总计 {total} | {C.GRN}✓ {up_to_date}{C.R} | " + f"{C.YEL}↑ {outdated}{C.R} | {C.RED}? {unknown}{C.R}") + + +def display_json(results: list[Result]) -> None: + print("[") + for i, r in enumerate(results): + sep = "," if i > 0 else "" + print(f'{sep}\n {{"image":"{r.image}","current":"{r.current}",' + f'"latest":"{r.latest}","status":"{r.status}","note":"{r.note}"}}', + end="") + print("\n]") + + +# ===================== 入口 ===================== +def main(): + parser = argparse.ArgumentParser( + prog="check_images.py", + description="检查 deploy.stack 常用 Docker 镜像的最新版本", + add_help=False, + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="退出码:0=全最新/未知;1=存在可升级;2=致命错误", + ) + parser.add_argument("filters", nargs="*", help="按关键字过滤镜像名(OR)") + parser.add_argument("-j", "--parallel", type=int, default=10, help="并发数(默认 10)") + parser.add_argument("--json", action="store_true", help="JSON 输出") + parser.add_argument("--pre", action="store_true", help="含 rc/beta/dev 等预发布") + parser.add_argument("--outdated", action="store_true", help="只显示可升级的") + parser.add_argument("--proxy", action="store_true", help="source ./proxy.sh 后开启代理") + parser.add_argument("--timeout", type=int, default=15, help="单镜像超时(秒,默认 15)") + parser.add_argument("--transport", + choices=["auto", "curl", "tinyfish"], default="auto", + help="传输方式:auto(默认)/ curl / tinyfish") + parser.add_argument("--repo", default=".", + help="env.cfg.example 扫描根目录(默认当前目录)") + parser.add_argument("-h", "--help", action="store_true", help="显示帮助") + + args = parser.parse_args() + + if args.help: + # 打印文件顶部 docstring 的前 35 行作为帮助 + with open(__file__, encoding="utf-8") as f: + for i, line in enumerate(f): + if i >= 35: break + if line.startswith("#!"): continue + print(line.rstrip()) + sys.exit(0) + + setup_proxy(args.proxy) + + # 从 env.cfg.example 自动扫描镜像清单 + images = discover_images(args.repo) + print(f"📦 discovered {len(images)} images from {args.repo}/**/env.cfg.example", + file=sys.stderr) + + # 找一个 Docker Hub 镜像用于探测 transport + probe_url = None + for img, _, _ in images: + api_type, url = resolve_registry(img) + if api_type == "dockerhub": + probe_url = url + break + transport = probe_transport(probe_url, args.transport) if probe_url else args.transport + print(f"🔌 transport: {transport}", file=sys.stderr) + + # 过滤 + if args.filters: + selected = [t for t in images if any(f in t[0] for f in args.filters)] + else: + selected = list(images) + + # 并发检查 + results: list[Result] = [] + with ThreadPoolExecutor(max_workers=max(1, args.parallel)) as pool: + futures = { + pool.submit(check_one, img, cur, note, transport, args.timeout, args.pre): img + for img, cur, note in selected + } + for fut in as_completed(futures): + results.append(fut.result()) + + # 仅过期 + if args.outdated: + results = [r for r in results if r.status == "outdated"] + + # 输出 + if args.json: + display_json(results) + else: + display_table(results) + + # 退出码 + outdated_n = sum(1 for r in results if r.status == "outdated") + sys.exit(1 if outdated_n > 0 else 0) + + +if __name__ == "__main__": + main() diff --git a/shell/check_images.sh b/shell/check_images.sh deleted file mode 100755 index f12a877..0000000 --- a/shell/check_images.sh +++ /dev/null @@ -1,487 +0,0 @@ -#!/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