Files
deploy.stack/shell/check_images.py
T

594 lines
21 KiB
Python
Raw Normal View History

#!/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 # 强制 tinyfishauto|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 CLIauto/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.0v1.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()