forked from DevOps/deploy.stack
feat(shell): 用 python 重写 check_images 并支持自动扫描 env.cfg.example
- 用 python3 重写(stdlib only,无需 requests 等第三方依赖)
- CURRENT 不再手工维护 IMAGES 列表,启动时扫描 <repo>/**/env.cfg.example
- 解析 IMAGE_TAG / IMAGE_NAME / IMAGE_TAG_Vxx / IMAGE_TAG_REDISVxx / Cadvisor_Image 等
- 解析 ${VAR} 变量引用(递归 5 层防环)
- 多版本变体(mysql/postgres/redis)自动展开为多行
- 跳过 builder/、crontab/、shell/、etc/ 等非服务目录
- 新增 --transport {auto,curl,tinyfish}:curl 不通 Docker Hub 时自动回退 tinyfish
- 新增 --repo 标志指定扫描根目录(默认当前目录)
This commit is contained in:
Executable
+593
@@ -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/<ns>/<repo>/tags/
|
||||
- 通用 registry v2: https://<host>/v2/<repo>/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 列表,改从 <service>/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()
|
||||
@@ -1,487 +0,0 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user