Files
CmiiDeploy/998-常用脚本/b-镜像同步/高级-镜像脚本-260302.sh
2026-05-19 14:28:56 +08:00

1290 lines
38 KiB
Bash
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
# shellcheck shell=bash
# Bash >= 5.0
#
# ==============================================================================
# Name : image_pipeline.sh
# Version : 1.0.0
# Author : OpenAI
# License : MIT
# Updated : 2026-03-03
#
# Assumptions
# - 输入镜像文件为纯文本:每行一个完整镜像名;支持空行与以 # 开头的注释行。
# - 默认动作为 download默认目标架构为 arm64默认压缩方式为 combined统一打包
# - “重新打包并上传”默认保留原镜像路径:目标镜像为 HARBOR_HOST/<原镜像路径>
# 若原镜像名不含 "/"(如 alpine:3.19),则上传为 HARBOR_HOST/library/alpine:3.19。
# - Harbor 地址格式为 IP:PORT不含协议前缀用户名/密码/端口可被参数覆盖,脚本内提供默认值。
# - 压缩优先使用 pigz 进行多线程压缩;若系统无 pigz则回退到 gzip单线程
# - 为满足“可直接落地”,脚本实现为单文件,默认不执行破坏性删除;上传属于显式网络写操作。
#
# Bash 特性说明
# - 使用数组Bash 特性)安全保存镜像列表与失败列表,避免字符串拼接引发的分词问题。
# - 使用 [[ ]]Bash 特性)进行更稳健的条件判断。
# - 使用 local、BASH_SOURCE、FUNCNAME、BASH_LINENOBash 特性)增强错误可观测性。
#
# Call Graph
# main
# ├─ parse_args
# ├─ init_runtime
# ├─ validate_args
# ├─ check_dependencies
# ├─ load_image_list
# ├─ case ACTION in
# │ ├─ download
# │ │ └─ perform_downloads
# │ │ └─ download_one_image
# │ │ ├─ compose_pull_source
# │ │ ├─ pull_image
# │ │ ├─ tag_image_if_needed
# │ │ └─ inspect_and_verify_arch
# │ ├─ compress
# │ │ ├─ verify_images_present
# │ │ └─ perform_compress
# │ │ ├─ compress_combined
# │ │ └─ compress_split_parallel
# │ ├─ push
# │ │ ├─ verify_images_present
# │ │ ├─ harbor_login
# │ │ └─ perform_pushes
# │ │ ├─ build_target_image_ref
# │ │ ├─ tag_image_if_needed
# │ │ └─ push_image
# │ └─ all
# │ ├─ perform_downloads
# │ ├─ verify_images_present
# │ ├─ perform_compress
# │ ├─ harbor_login
# │ └─ perform_pushes
# ├─ print_summary
# └─ cleanup
# ==============================================================================
set -Eeuo pipefail
IFS=$' \t\n'
# ==============================================================================
# 全局常量区
# ==============================================================================
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
readonly SCRIPT_VERSION="1.0.0"
readonly EXIT_SUCCESS=0
readonly EXIT_USAGE=2
readonly EXIT_DEPENDENCY=3
readonly EXIT_RUNTIME=4
readonly EXIT_VALIDATION=5
readonly EXIT_INTERRUPT=130
readonly DEFAULT_ACTION="download"
readonly DEFAULT_ARCH="amd64"
readonly DEFAULT_BUNDLE_MODE="combined"
readonly DEFAULT_OUTPUT_DIR="./image-artifacts"
readonly DEFAULT_THREADS="0" # 0 = auto detect
readonly DEFAULT_RETRY="1"
readonly DEFAULT_RETRY_DELAY="2"
readonly DEFAULT_LOG_LEVEL="INFO"
readonly DEFAULT_HARBOR_HOST="192.168.5.41:8033"
readonly DEFAULT_HARBOR_USERNAME="admin"
readonly DEFAULT_HARBOR_PASSWORD="V2ryStr@ngPss"
readonly LOCK_FILE_NAME=".image_pipeline.lock"
# ==============================================================================
# 全局变量区(可配置)
# ==============================================================================
ACTION="${DEFAULT_ACTION}"
IMAGE_FILE=""
ARCH="${DEFAULT_ARCH}"
MIRROR_PREFIX=""
BUNDLE_MODE="${DEFAULT_BUNDLE_MODE}"
OUTPUT_DIR="${DEFAULT_OUTPUT_DIR}"
THREADS="${DEFAULT_THREADS}"
RETRY="${DEFAULT_RETRY}"
RETRY_DELAY="${DEFAULT_RETRY_DELAY}"
HARBOR_HOST="${DEFAULT_HARBOR_HOST}"
HARBOR_USERNAME="${DEFAULT_HARBOR_USERNAME}"
HARBOR_PASSWORD="${DEFAULT_HARBOR_PASSWORD}"
DRY_RUN="false"
FORCE="false"
LOG_LEVEL="${LOG_LEVEL:-${DEFAULT_LOG_LEVEL}}"
TMP_DIR=""
LOCK_FILE=""
COMPRESSOR_CMD="gzip"
COMPRESSOR_ARGS=()
# Bash 数组用于安全保存列表
IMAGE_LIST=()
DOWNLOAD_FAILED=()
DOWNLOAD_USED_LOCAL=()
VERIFY_MISSING=()
VERIFY_ARCH_MISMATCH=()
COMPRESS_FAILED=()
PUSH_FAILED=()
PUSH_SUCCEEDED=()
# ==============================================================================
# usage / help
# ==============================================================================
usage() {
cat <<'EOF'
Usage:
image_pipeline.sh --image-file <images.txt> [options]
Required:
-f, --image-file <file> 镜像清单文件;每行一个完整镜像名
Action:
-a, --action <name> 动作: download | compress | push | all
默认: download
Download options:
--arch <arch> 目标架构: arm64 | amd64
默认: arm64
--mirror-prefix <prefix> 下载镜像加速前缀;如:
registry.local:5000
拉取时会从 <prefix>/<原镜像名> 下载,成功后重新 tag 为原镜像名
Compress options:
--bundle-mode <mode> 压缩模式: combined | split
combined: 所有镜像统一打包为一个 tar.gz默认
split : 每个镜像单独打包为 tar.gz
--output-dir <dir> 输出目录,默认: ./image-artifacts
--threads <n> 压缩线程数0 表示自动检测 CPU 核数(默认)
--force 强制跳过“镜像存在性/架构一致性”检查
Push options:
--harbor <ip:port> 目标 Harbor 地址,默认: 192.168.5.41:8033
--harbor-user <user> Harbor 用户名,默认: admin
--harbor-password <pass> Harbor 密码,默认: 脚本内置默认值(可改为环境变量注入)
Common options:
--retry <n> 外部命令失败重试次数(默认: 1即不重试
--retry-delay <sec> 重试间隔秒数(默认: 2
--dry-run 仅打印将执行的动作,不实际执行
--verbose 输出 DEBUG 日志
--quiet 仅输出 ERROR 日志
-h, --help 显示帮助
Examples:
1) 正常下载 ARM64 镜像:
./image_pipeline.sh -f ./kubernetes-1.30.14-arm.txt --action download --arch arm64
2) 通过加速前缀下载,并统一打包:
./image_pipeline.sh -f ./images.txt --action all \
--mirror-prefix registry-cache.local:5000 \
--bundle-mode combined --output-dir ./dist
3) 仅压缩(每镜像单独 tar.gz跳过校验
./image_pipeline.sh -f ./images.txt --action compress --bundle-mode split --force
4) 重新 tag 并上传至 Harbor
./image_pipeline.sh -f ./images.txt --action push \
--harbor 192.168.5.41:8033 --harbor-user admin --harbor-password '***'
Exit Codes:
0 成功
2 参数错误
3 依赖缺失/环境不满足
4 运行时错误
5 输入校验失败(文件不存在、镜像缺失、架构不匹配等)
最小自测方法:
# 正常路径dry-run
./image_pipeline.sh -f ./images.txt --action download --dry-run
# 参数错误
./image_pipeline.sh --action download
# 压缩 dry-run
./image_pipeline.sh -f ./images.txt --action compress --bundle-mode split --dry-run
EOF
}
# ==============================================================================
# 日志与错误处理区
# ==============================================================================
### 将日志级别名称转为数值,便于比较
### @param level string 日志级别名称
### @return 0 成功输出数值
### @require 无
### @side_effect 无
level_to_num() {
local level="${1:-INFO}"
case "${level^^}" in
DEBUG) printf '%s\n' "10" ;;
INFO) printf '%s\n' "20" ;;
WARN) printf '%s\n' "30" ;;
ERROR) printf '%s\n' "40" ;;
*) printf '%s\n' "20" ;;
esac
}
### 输出统一格式日志
### @param level string 日志级别
### @param message string 日志消息
### @return 0 成功输出
### @require date
### @side_effect 向 stderr 输出日志
log_msg() {
local level="${1:-INFO}"
shift || true
local message="${*:-}"
local now=""
local current_level_num=""
local target_level_num=""
now="$(date '+%Y-%m-%d %H:%M:%S')"
current_level_num="$(level_to_num "${LOG_LEVEL}")"
target_level_num="$(level_to_num "${level}")"
if [[ "${target_level_num}" -ge "${current_level_num}" ]]; then
printf '%s [%s] %s\n' "${now}" "${level^^}" "${message}" >&2
fi
}
### DEBUG 级别日志
### @param message string 日志消息
### @return 0 成功输出
### @require log_msg
### @side_effect 向 stderr 输出日志
log_debug() { log_msg "DEBUG" "$*"; }
### INFO 级别日志
### @param message string 日志消息
### @return 0 成功输出
### @require log_msg
### @side_effect 向 stderr 输出日志
log_info() { log_msg "INFO" "$*"; }
### WARN 级别日志
### @param message string 日志消息
### @return 0 成功输出
### @require log_msg
### @side_effect 向 stderr 输出日志
log_warn() { log_msg "WARN" "$*"; }
### ERROR 级别日志
### @param message string 日志消息
### @return 0 成功输出
### @require log_msg
### @side_effect 向 stderr 输出日志
log_error() { log_msg "ERROR" "$*"; }
### 统一失败出口
### @param message string 错误信息
### @param exit_code int 退出码
### @return 非 0 退出
### @require 无
### @side_effect 终止脚本
die() {
local message="${1:-Unknown error}"
local exit_code="${2:-${EXIT_RUNTIME}}"
log_error "${message}"
exit "${exit_code}"
}
### 打印调用栈,提升可观测性
### @param none none 无
### @return 0 打印完成
### @require Bash 调用栈变量
### @side_effect 向 stderr 输出栈信息
print_stack_trace() {
local i=""
local depth="${#FUNCNAME[@]}"
if [[ "${depth}" -le 1 ]]; then
return 0
fi
printf 'Stack trace:\n' >&2
for (( i=1; i<depth; i++ )); do
printf ' at %s() %s:%s\n' \
"${FUNCNAME[$i]}" \
"${BASH_SOURCE[$i]:-unknown}" \
"${BASH_LINENO[$((i-1))]:-unknown}" >&2
done
}
### ERR trap 处理器
### @param line_no int 出错行号
### @param cmd string 出错命令
### @param code int 退出码
### @return 非 0 继续退出
### @require trap ERR
### @side_effect 输出错误细节
on_err() {
local line_no="${1:-unknown}"
local cmd="${2:-unknown}"
local code="${3:-1}"
log_error "命令执行失败: line=${line_no}, exit_code=${code}, cmd=${cmd}"
print_stack_trace
exit "${code}"
}
### 信号中断处理器
### @param signal string 信号名
### @return 非 0 中断退出
### @require trap INT/TERM
### @side_effect 输出日志并退出
on_signal() {
local signal="${1:-INT}"
log_warn "收到中断信号: ${signal}"
exit "${EXIT_INTERRUPT}"
}
### 退出清理函数
### @param none none 无
### @return 0 清理完成
### @require mktemp
### @side_effect 删除临时目录/锁文件
cleanup() {
local code=$?
# > 只清理本脚本创建的临时资源,避免误删用户文件
if [[ -n "${LOCK_FILE}" && -f "${LOCK_FILE}" ]]; then
rm -f "${LOCK_FILE}" || true
fi
if [[ -n "${TMP_DIR}" && -d "${TMP_DIR}" ]]; then
rm -rf "${TMP_DIR}" || true
fi
# > EXIT trap 不覆盖原始退出码
return "${code}"
}
trap 'on_err "${LINENO}" "${BASH_COMMAND}" "$?"' ERR
trap 'on_signal "INT"' INT
trap 'on_signal "TERM"' TERM
trap cleanup EXIT
# ==============================================================================
# 通用工具函数
# ==============================================================================
### 判断命令是否存在
### @param cmd string 命令名
### @return 0 存在 / 1 不存在
### @require command
### @side_effect 无
has_cmd() {
command -v "${1}" >/dev/null 2>&1
}
### 规范化架构名称
### @param arch string 原始架构名
### @return 0 输出规范化架构名
### @require tr
### @side_effect 无
normalize_arch() {
local raw="${1:-}"
local value=""
value="$(printf '%s' "${raw}" | tr '[:upper:]' '[:lower:]')"
case "${value}" in
arm64|aarch64) printf '%s\n' "arm64" ;;
amd64|x86_64) printf '%s\n' "amd64" ;;
*) printf '%s\n' "${value}" ;;
esac
}
### 校验是否为正整数
### @param value string 待校验值
### @return 0 合法 / 1 非法
### @require 无
### @side_effect 无
is_positive_integer() {
local value="${1:-}"
[[ "${value}" =~ ^[0-9]+$ ]]
}
### 获取 CPU 核数
### @param none none 无
### @return 0 输出核数
### @require nproc 或 getconf
### @side_effect 无
detect_cpu_threads() {
local cpu_count="1"
if has_cmd "nproc"; then
cpu_count="$(nproc)"
elif has_cmd "getconf"; then
cpu_count="$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '%s' "1")"
fi
if ! is_positive_integer "${cpu_count}" || [[ "${cpu_count}" -lt 1 ]]; then
cpu_count="1"
fi
printf '%s\n' "${cpu_count}"
}
### 通过 run_cmd 统一执行外部命令,支持 dry-run 与重试
### @param ... string 命令及参数
### @return 0 成功 / 非 0 失败
### @require sleep重试时
### @side_effect 执行外部命令
run_cmd() {
local attempt=1
local max_attempts="${RETRY}"
local delay="${RETRY_DELAY}"
local printable_cmd=""
local arg=""
if [[ "$#" -eq 0 ]]; then
die "run_cmd 收到空命令" "${EXIT_RUNTIME}"
fi
for arg in "$@"; do
if [[ -n "${printable_cmd}" ]]; then
printable_cmd+=" "
fi
printable_cmd+="$(printf '%q' "${arg}")"
done
if [[ "${DRY_RUN}" == "true" ]]; then
log_info "[dry-run] ${printable_cmd}"
return 0
fi
while (( attempt <= max_attempts )); do
log_debug "执行命令(第 ${attempt}/${max_attempts} 次): ${printable_cmd}"
if "$@"; then
return 0
fi
if (( attempt < max_attempts )); then
log_warn "命令失败,${delay}s 后重试: ${printable_cmd}"
sleep "${delay}"
fi
attempt=$((attempt + 1))
done
log_error "命令最终失败: ${printable_cmd}"
return 1
}
# ==============================================================================
# 依赖与环境检查区
# ==============================================================================
### 初始化运行时目录、锁与压缩器
### @param none none 无
### @return 0 初始化完成
### @require mktemp
### @side_effect 创建临时目录/锁文件
init_runtime() {
TMP_DIR="$(mktemp -d -t image-pipeline.XXXXXX)"
LOCK_FILE="${TMP_DIR}/${LOCK_FILE_NAME}"
# > 使用临时目录中的锁文件,避免并发实例相互覆盖中间状态
: > "${LOCK_FILE}"
if has_cmd "pigz"; then
COMPRESSOR_CMD="pigz"
else
COMPRESSOR_CMD="gzip"
fi
}
### 按当前动作检查依赖
### @param none none 无
### @return 0 依赖满足
### @require command
### @side_effect 无
check_dependencies() {
local missing=()
has_cmd "docker" || missing+=("docker")
has_cmd "date" || missing+=("date")
has_cmd "mktemp" || missing+=("mktemp")
if [[ "${ACTION}" == "compress" || "${ACTION}" == "all" ]]; then
if [[ "${COMPRESSOR_CMD}" == "pigz" ]]; then
:
else
has_cmd "gzip" || missing+=("gzip")
fi
fi
if [[ "${#missing[@]}" -gt 0 ]]; then
die "缺少依赖命令: ${missing[*]}" "${EXIT_DEPENDENCY}"
fi
if [[ "${COMPRESSOR_CMD}" == "pigz" ]]; then
log_info "检测到 pigz将启用多线程压缩"
elif [[ "${ACTION}" == "compress" || "${ACTION}" == "all" ]]; then
log_warn "未检测到 pigz将回退到 gzip单线程"
fi
}
# ==============================================================================
# 参数解析与校验
# ==============================================================================
### 解析命令行参数
### @param ... string 命令行参数
### @return 0 解析成功
### @require 无
### @side_effect 修改全局配置变量
parse_args() {
while [[ "$#" -gt 0 ]]; do
case "$1" in
-f|--image-file)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个文件路径" "${EXIT_USAGE}"
IMAGE_FILE="$2"
shift 2
;;
-a|--action)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个动作值" "${EXIT_USAGE}"
ACTION="$2"
shift 2
;;
--arch)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个架构值" "${EXIT_USAGE}"
ARCH="$2"
shift 2
;;
--mirror-prefix)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个前缀值" "${EXIT_USAGE}"
MIRROR_PREFIX="$2"
shift 2
;;
--bundle-mode)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个模式值" "${EXIT_USAGE}"
BUNDLE_MODE="$2"
shift 2
;;
--output-dir)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个目录路径" "${EXIT_USAGE}"
OUTPUT_DIR="$2"
shift 2
;;
--threads)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个线程数" "${EXIT_USAGE}"
THREADS="$2"
shift 2
;;
--harbor)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个 Harbor 地址" "${EXIT_USAGE}"
HARBOR_HOST="$2"
shift 2
;;
--harbor-user)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个用户名" "${EXIT_USAGE}"
HARBOR_USERNAME="$2"
shift 2
;;
--harbor-password)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个密码" "${EXIT_USAGE}"
HARBOR_PASSWORD="$2"
shift 2
;;
--retry)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个重试次数" "${EXIT_USAGE}"
RETRY="$2"
shift 2
;;
--retry-delay)
[[ "$#" -ge 2 ]] || die "参数缺失: $1 需要一个秒数" "${EXIT_USAGE}"
RETRY_DELAY="$2"
shift 2
;;
--dry-run)
DRY_RUN="true"
shift
;;
--force)
FORCE="true"
shift
;;
--verbose)
LOG_LEVEL="DEBUG"
shift
;;
--quiet)
LOG_LEVEL="ERROR"
shift
;;
-h|--help)
usage
exit "${EXIT_SUCCESS}"
;;
--)
shift
break
;;
-*)
die "未知参数: $1" "${EXIT_USAGE}"
;;
*)
die "不支持的位置参数: $1" "${EXIT_USAGE}"
;;
esac
done
}
### 校验参数与输入
### @param none none 无
### @return 0 校验通过
### @require 无
### @side_effect 可能创建输出目录dry-run 时仅日志提示)
validate_args() {
ARCH="$(normalize_arch "${ARCH}")"
[[ -n "${IMAGE_FILE}" ]] || die "必须提供 --image-file" "${EXIT_USAGE}"
if [[ -L "${IMAGE_FILE}" ]]; then
die "出于安全考虑,不接受符号链接作为镜像文件: ${IMAGE_FILE}" "${EXIT_VALIDATION}"
fi
[[ -f "${IMAGE_FILE}" ]] || die "镜像文件不存在: ${IMAGE_FILE}" "${EXIT_VALIDATION}"
[[ -r "${IMAGE_FILE}" ]] || die "镜像文件不可读: ${IMAGE_FILE}" "${EXIT_VALIDATION}"
case "${ACTION}" in
download|compress|push|all) ;;
*)
die "非法动作: ${ACTION}(允许: download|compress|push|all" "${EXIT_USAGE}"
;;
esac
case "${ARCH}" in
arm64|amd64) ;;
*)
die "非法架构: ${ARCH}(允许: arm64|amd64" "${EXIT_USAGE}"
;;
esac
case "${BUNDLE_MODE}" in
combined|split) ;;
*)
die "非法压缩模式: ${BUNDLE_MODE}(允许: combined|split" "${EXIT_USAGE}"
;;
esac
is_positive_integer "${THREADS}" || die "--threads 必须为非负整数" "${EXIT_USAGE}"
is_positive_integer "${RETRY}" || die "--retry 必须为非负整数" "${EXIT_USAGE}"
is_positive_integer "${RETRY_DELAY}" || die "--retry-delay 必须为非负整数" "${EXIT_USAGE}"
if [[ "${RETRY}" -eq 0 ]]; then
die "--retry 不能为 0如不重试请使用 1" "${EXIT_USAGE}"
fi
if [[ "${THREADS}" -eq 0 ]]; then
THREADS="$(detect_cpu_threads)"
fi
if [[ "${ACTION}" == "compress" || "${ACTION}" == "all" ]]; then
if [[ "${DRY_RUN}" == "true" ]]; then
log_info "[dry-run] 将创建输出目录: ${OUTPUT_DIR}"
else
mkdir -p "${OUTPUT_DIR}"
fi
fi
if [[ "${ACTION}" == "push" || "${ACTION}" == "all" ]]; then
[[ "${HARBOR_HOST}" =~ ^[A-Za-z0-9._-]+:[0-9]+$ ]] || die \
"Harbor 地址格式非法(应为 IP:PORT 或 HOST:PORT: ${HARBOR_HOST}" "${EXIT_USAGE}"
[[ -n "${HARBOR_USERNAME}" ]] || die "Harbor 用户名不能为空" "${EXIT_USAGE}"
[[ -n "${HARBOR_PASSWORD}" ]] || die "Harbor 密码不能为空" "${EXIT_USAGE}"
fi
log_debug "参数校验通过: action=${ACTION}, arch=${ARCH}, bundle_mode=${BUNDLE_MODE}, threads=${THREADS}"
}
# ==============================================================================
# 业务函数区:镜像清单
# ==============================================================================
### 加载镜像清单到数组
### @param file string 镜像清单文件
### @return 0 加载成功
### @require 文件可读
### @side_effect 修改全局 IMAGE_LIST 数组
load_image_list() {
local file_path="${1}"
local line=""
local trimmed=""
IMAGE_LIST=()
while IFS= read -r line || [[ -n "${line}" ]]; do
# > 去掉行首尾空白后再判断,避免空格行被误识别为镜像名
trimmed="${line#"${line%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
[[ -z "${trimmed}" ]] && continue
[[ "${trimmed}" == \#* ]] && continue
IMAGE_LIST+=("${trimmed}")
done < "${file_path}"
[[ "${#IMAGE_LIST[@]}" -gt 0 ]] || die "镜像文件中未读取到任何有效镜像: ${file_path}" "${EXIT_VALIDATION}"
log_info "已加载镜像数量: ${#IMAGE_LIST[@]}"
}
# ==============================================================================
# 业务函数区Docker 基础操作
# ==============================================================================
### 生成实际拉取的源镜像名
### @param image string 原始镜像名
### @return 0 输出实际拉取镜像名
### @require 无
### @side_effect 无
compose_pull_source() {
local image_ref="${1}"
local prefix="${MIRROR_PREFIX%/}"
if [[ -n "${prefix}" ]]; then
printf '%s/%s\n' "${prefix}" "${image_ref}"
else
printf '%s\n' "${image_ref}"
fi
}
### 拉取镜像
### @param source_image string 实际拉取源镜像
### @param arch string 目标架构
### @return 0 拉取成功
### @require docker
### @side_effect 向本地 Docker 写入镜像
pull_image() {
local source_image="${1}"
local target_arch="${2}"
# > 显式指定平台,确保拉取结果符合目标 CPU 架构
run_cmd docker pull --platform "linux/${target_arch}" "${source_image}"
}
### 条件性重新打 tag
### @param source_image string 源镜像名
### @param target_image string 目标镜像名
### @return 0 成功
### @require docker
### @side_effect 生成新的本地镜像标签
tag_image_if_needed() {
local source_image="${1}"
local target_image="${2}"
if [[ "${source_image}" == "${target_image}" ]]; then
return 0
fi
run_cmd docker tag "${source_image}" "${target_image}"
}
### 获取本地镜像架构
### @param image string 镜像名
### @return 0 输出架构;不存在则返回非 0
### @require docker
### @side_effect 无
get_local_image_arch() {
local image_ref="${1}"
local arch_value=""
arch_value="$(docker image inspect --format '{{.Architecture}}' "${image_ref}" 2>/dev/null)"
[[ -n "${arch_value}" ]] || return 1
normalize_arch "${arch_value}"
}
### 检查本地镜像是否存在
### @param image string 镜像名
### @return 0 存在 / 1 不存在
### @require docker
### @side_effect 无
image_exists_locally() {
local image_ref="${1}"
docker image inspect "${image_ref}" >/dev/null 2>&1
}
### 检查并输出架构,同时校验是否匹配
### @param image string 镜像名
### @param expected_arch string 期望架构
### @return 0 匹配 / 1 不匹配或不存在
### @require docker
### @side_effect 输出日志
inspect_and_verify_arch() {
local image_ref="${1}"
local expected_arch="${2}"
local actual_arch=""
if ! actual_arch="$(get_local_image_arch "${image_ref}")"; then
log_error "镜像不存在或无法读取架构: ${image_ref}"
return 1
fi
log_info "镜像架构检查: ${image_ref} -> ${actual_arch}"
if [[ "${actual_arch}" != "${expected_arch}" ]]; then
log_error "镜像架构不匹配: ${image_ref}, expected=${expected_arch}, actual=${actual_arch}"
return 1
fi
return 0
}
# ==============================================================================
# 业务函数区:下载
# ==============================================================================
### 下载单个镜像,并在需要时重新 tag
### @param image string 原始镜像名
### @return 0 成功
### @require docker
### @side_effect 拉取/打 tag 到本地 Docker
download_one_image() {
local original_image="${1}"
local source_image=""
local pull_succeeded="false"
source_image="$(compose_pull_source "${original_image}")"
log_info "开始下载镜像: ${original_image}"
log_debug "实际拉取源: ${source_image}"
if pull_image "${source_image}" "${ARCH}"; then
pull_succeeded="true"
log_info "镜像下载成功: ${source_image}"
else
log_warn "镜像下载失败: ${source_image}"
fi
if [[ "${pull_succeeded}" == "true" ]]; then
tag_image_if_needed "${source_image}" "${original_image}"
else
# > 若拉取失败但本地已存在同名镜像,则允许复用本地镜像继续流程
if image_exists_locally "${original_image}"; then
DOWNLOAD_USED_LOCAL+=("${original_image}")
log_warn "下载失败但本地已存在镜像,将复用本地镜像: ${original_image}"
else
DOWNLOAD_FAILED+=("${original_image}")
return 1
fi
fi
inspect_and_verify_arch "${original_image}" "${ARCH}"
}
### 批量执行下载
### @param none none 无
### @return 0 成功;存在失败则返回非 0
### @require IMAGE_LIST 已加载
### @side_effect 写入本地 Docker
perform_downloads() {
local image_ref=""
local failed_count_before="${#DOWNLOAD_FAILED[@]}"
for image_ref in "${IMAGE_LIST[@]}"; do
if ! download_one_image "${image_ref}"; then
log_error "下载阶段失败: ${image_ref}"
fi
done
if [[ "${#DOWNLOAD_FAILED[@]}" -gt "${failed_count_before}" ]]; then
return 1
fi
return 0
}
# ==============================================================================
# 业务函数区:压缩前校验
# ==============================================================================
### 校验镜像是否全部存在且架构符合预期
### @param none none 无
### @return 0 校验通过
### @require docker
### @side_effect 填充 VERIFY_MISSING / VERIFY_ARCH_MISMATCH
verify_images_present() {
local image_ref=""
local actual_arch=""
VERIFY_MISSING=()
VERIFY_ARCH_MISMATCH=()
if [[ "${FORCE}" == "true" ]]; then
log_warn "已启用 --force跳过镜像存在性/架构一致性检查"
return 0
fi
for image_ref in "${IMAGE_LIST[@]}"; do
if ! image_exists_locally "${image_ref}"; then
VERIFY_MISSING+=("${image_ref}")
continue
fi
actual_arch="$(get_local_image_arch "${image_ref}" || true)"
if [[ -z "${actual_arch}" ]]; then
VERIFY_MISSING+=("${image_ref}")
continue
fi
if [[ "${actual_arch}" != "${ARCH}" ]]; then
VERIFY_ARCH_MISMATCH+=("${image_ref} (actual=${actual_arch})")
fi
done
if [[ "${#VERIFY_MISSING[@]}" -gt 0 || "${#VERIFY_ARCH_MISMATCH[@]}" -gt 0 ]]; then
if [[ "${#VERIFY_MISSING[@]}" -gt 0 ]]; then
log_error "缺失镜像:"
printf ' - %s\n' "${VERIFY_MISSING[@]}" >&2
fi
if [[ "${#VERIFY_ARCH_MISMATCH[@]}" -gt 0 ]]; then
log_error "架构不匹配镜像:"
printf ' - %s\n' "${VERIFY_ARCH_MISMATCH[@]}" >&2
fi
return 1
fi
log_info "镜像存在性与架构检查通过"
return 0
}
# ==============================================================================
# 业务函数区:压缩
# ==============================================================================
### 将镜像名转换为安全文件名
### @param image string 镜像名
### @return 0 输出文件安全名
### @require tr
### @side_effect 无
sanitize_image_to_filename() {
local image_ref="${1}"
local safe_name=""
safe_name="$(printf '%s' "${image_ref}" | sed 's|/|_|g; s|:|__|g; s|@|__|g')"
printf '%s\n' "${safe_name}"
}
### 构建压缩命令参数
### @param none none 无
### @return 0 设置 COMPRESSOR_ARGS
### @require 无
### @side_effect 修改全局 COMPRESSOR_ARGS 数组
prepare_compressor_args() {
COMPRESSOR_ARGS=()
if [[ "${COMPRESSOR_CMD}" == "pigz" ]]; then
COMPRESSOR_ARGS=(-p "${THREADS}")
else
COMPRESSOR_ARGS=()
fi
}
### 统一打包所有镜像为一个 tar.gz
### @param output_file string 输出文件路径
### @return 0 成功
### @require docker + pigz/gzip
### @side_effect 写入压缩包文件
compress_combined() {
local output_file="${1}"
local image_ref=""
local -a image_args=()
for image_ref in "${IMAGE_LIST[@]}"; do
image_args+=("${image_ref}")
done
prepare_compressor_args
log_info "开始统一打包: ${output_file}"
# > 这里只传“镜像列表”本身,不要把字面量 save 传进去
run_cmd bash -c '
set -Eeuo pipefail
docker save "$@" | '"${COMPRESSOR_CMD}"' '"${COMPRESSOR_ARGS[*]:-}"' > "'"${output_file}"'"
' _ "${image_args[@]}" || return 1
log_info "统一打包完成: ${output_file}"
if [[ "${DRY_RUN}" != "true" && -f "${output_file}" ]]; then
log_info "压缩包大小: $(du -h "${output_file}" | awk '{print $1}')"
fi
}
### 单个镜像压缩为 tar.gz
### @param image string 镜像名
### @param output_dir string 输出目录
### @return 0 成功
### @require docker + pigz/gzip
### @side_effect 写入单镜像压缩包
compress_one_image() {
local image_ref="${1}"
local out_dir="${2}"
local file_name=""
local output_file=""
file_name="$(sanitize_image_to_filename "${image_ref}")"
output_file="${out_dir}/${file_name}.tar.gz"
prepare_compressor_args
log_info "开始单镜像打包: ${image_ref} -> ${output_file}"
run_cmd bash -c '
set -Eeuo pipefail
docker save "$1" | '"${COMPRESSOR_CMD}"' '"${COMPRESSOR_ARGS[*]:-}"' > "$2"
' _ "${image_ref}" "${output_file}"
log_info "单镜像打包完成: ${output_file}"
}
### 并发执行每镜像单独压缩
### @param output_dir string 输出目录
### @return 0 全部成功 / 非 0 存在失败
### @require docker + pigz/gzip
### @side_effect 写入多个压缩包
compress_split_parallel() {
local out_dir="${1}"
local image_ref=""
local -a pids=()
local -a pid_to_image=()
local index=0
local active_jobs=0
local wait_pid=""
local wait_rc="0"
COMPRESS_FAILED=()
pids=()
pid_to_image=()
for image_ref in "${IMAGE_LIST[@]}"; do
(
compress_one_image "${image_ref}" "${out_dir}"
) &
pids+=("$!")
pid_to_image+=("${image_ref}")
active_jobs=$((active_jobs + 1))
# > 控制最大并发数,避免同时大量 docker save 导致 IO/CPU 失控
if [[ "${active_jobs}" -ge "${THREADS}" ]]; then
wait_pid="${pids[0]}"
if ! wait "${wait_pid}"; then
wait_rc=$?
COMPRESS_FAILED+=("${pid_to_image[0]}")
log_error "单镜像压缩失败: ${pid_to_image[0]} (exit=${wait_rc})"
fi
pids=("${pids[@]:1}")
pid_to_image=("${pid_to_image[@]:1}")
active_jobs=$((active_jobs - 1))
fi
index=$((index + 1))
done
for index in "${!pids[@]}"; do
if ! wait "${pids[$index]}"; then
wait_rc=$?
COMPRESS_FAILED+=("${pid_to_image[$index]}")
log_error "单镜像压缩失败: ${pid_to_image[$index]} (exit=${wait_rc})"
fi
done
[[ "${#COMPRESS_FAILED[@]}" -eq 0 ]]
}
### 执行压缩动作
### @param none none 无
### @return 0 成功
### @require verify_images_present 已通过
### @side_effect 写入压缩文件
perform_compress() {
local output_file=""
local image_file_name=""
local image_file_base=""
local archive_date=""
if [[ "${BUNDLE_MODE}" == "combined" ]]; then
image_file_name="$(basename "${IMAGE_FILE}")"
image_file_base="${image_file_name%.txt}"
archive_date="$(date '+%Y%m%d')"
output_file="${OUTPUT_DIR}/${image_file_base}_${archive_date}_${ARCH}.tar.gz"
compress_combined "${output_file}"
else
compress_split_parallel "${OUTPUT_DIR}"
fi
}
# ==============================================================================
# 业务函数区:上传 Harbor
# ==============================================================================
### 构建目标 Harbor 镜像名
### @param image string 原始镜像名
### @return 0 输出目标镜像名
build_target_image_ref() {
local image_ref="${1}"
local pure_image
local image_name_tag
# 1. 剥离域名部分 (处理 docker.io/xxx 或 my.reg.com/xxx)
# 如果斜杠数量 >= 2说明带了域名取最后两部分
if [[ "${image_ref}" =~ .*/.*/.* ]]; then
pure_image=$(echo "${image_ref}" | rev | cut -d'/' -f1,2 | rev)
else
pure_image="${image_ref}"
fi
# 2. 核心转换逻辑
if [[ "${pure_image}" == rancher/* ]]; then
# 特例rancher 开头,直接拼接
printf '%s/%s\n' "${HARBOR_HOST}" "${pure_image}"
elif [[ "${pure_image}" == */* ]]; then
# 包含路径但不是 rancher (如 bitnamilegacy/nginx 或 ossrs/srs)
# 提取斜杠后的部分 (镜像名:标签),前缀换成 cmii
image_name_tag="${pure_image#*/}"
printf '%s/cmii/%s\n' "${HARBOR_HOST}" "${image_name_tag}"
else
# 完全不带路径 (如 nginx:latest)
# 强制变成 cmii/nginx:latest
printf '%s/cmii/%s\n' "${HARBOR_HOST}" "${pure_image}"
fi
}
### 登录 Harbor
### @param none none 无
### @return 0 成功
### @require docker
### @side_effect 向 Docker 写入登录状态
harbor_login() {
log_info "开始登录 Harbor: ${HARBOR_HOST}"
# > 使用 --password-stdin 避免密码出现在进程参数列表中
if [[ "${DRY_RUN}" == "true" ]]; then
log_info "[dry-run] printf '***' | docker login --username '${HARBOR_USERNAME}' --password-stdin '${HARBOR_HOST}'"
return 0
fi
printf '%s' "${HARBOR_PASSWORD}" | docker login --username "${HARBOR_USERNAME}" --password-stdin "${HARBOR_HOST}"
log_info "Harbor 登录成功: ${HARBOR_HOST}"
}
### 推送单个镜像到 Harbor
### @param image string 原始镜像名
### @return 0 成功
### @require docker
### @side_effect 向 Harbor 上传镜像
push_one_image() {
local source_image="${1}"
local target_image=""
target_image="$(build_target_image_ref "${source_image}")"
log_info "开始推送镜像: ${source_image} -> ${target_image}"
tag_image_if_needed "${source_image}" "${target_image}"
run_cmd docker push "${target_image}"
PUSH_SUCCEEDED+=("${target_image}")
}
### 批量执行推送
### @param none none 无
### @return 0 全部成功 / 非 0 存在失败
### @require Harbor 已登录
### @side_effect 向 Harbor 上传多个镜像
perform_pushes() {
local image_ref=""
PUSH_FAILED=()
PUSH_SUCCEEDED=()
for image_ref in "${IMAGE_LIST[@]}"; do
if ! push_one_image "${image_ref}"; then
PUSH_FAILED+=("${image_ref}")
log_error "上传失败: ${image_ref}"
fi
done
[[ "${#PUSH_FAILED[@]}" -eq 0 ]]
}
# ==============================================================================
# 汇总输出
# ==============================================================================
### 打印流程总结
### @param none none 无
### @return 0 打印完成
### @require 无
### @side_effect 向 stderr 输出汇总
print_summary() {
log_info "================ 执行总结 ================"
if [[ "${#DOWNLOAD_FAILED[@]}" -gt 0 ]]; then
log_warn "下载失败镜像:"
printf ' - %s\n' "${DOWNLOAD_FAILED[@]}" >&2
fi
if [[ "${#DOWNLOAD_USED_LOCAL[@]}" -gt 0 ]]; then
log_warn "下载失败但已复用本地镜像:"
printf ' - %s\n' "${DOWNLOAD_USED_LOCAL[@]}" >&2
fi
if [[ "${#VERIFY_MISSING[@]}" -gt 0 ]]; then
log_warn "校验缺失镜像:"
printf ' - %s\n' "${VERIFY_MISSING[@]}" >&2
fi
if [[ "${#VERIFY_ARCH_MISMATCH[@]}" -gt 0 ]]; then
log_warn "校验架构不匹配镜像:"
printf ' - %s\n' "${VERIFY_ARCH_MISMATCH[@]}" >&2
fi
if [[ "${#COMPRESS_FAILED[@]}" -gt 0 ]]; then
log_warn "压缩失败镜像:"
printf ' - %s\n' "${COMPRESS_FAILED[@]}" >&2
fi
if [[ "${#PUSH_SUCCEEDED[@]}" -gt 0 ]]; then
log_info "上传成功镜像数量: ${#PUSH_SUCCEEDED[@]}"
fi
if [[ "${#PUSH_FAILED[@]}" -gt 0 ]]; then
log_warn "上传失败镜像:"
printf ' - %s\n' "${PUSH_FAILED[@]}" >&2
fi
log_info "========================================"
}
# ==============================================================================
# main 与入口区
# ==============================================================================
### 主流程编排
### @param ... string 命令行参数
### @return 0 成功
### @require 各业务函数
### @side_effect 根据动作执行下载/压缩/上传
main() {
parse_args "$@"
init_runtime
validate_args
check_dependencies
load_image_list "${IMAGE_FILE}"
case "${ACTION}" in
download)
perform_downloads || die "下载阶段存在失败,请查看上方总结" "${EXIT_RUNTIME}"
;;
compress)
verify_images_present || die "压缩前校验失败;可使用 --force 跳过" "${EXIT_VALIDATION}"
perform_compress || die "压缩阶段存在失败" "${EXIT_RUNTIME}"
;;
push)
verify_images_present || die "上传前校验失败;可使用 --force 跳过" "${EXIT_VALIDATION}"
harbor_login
perform_pushes || die "上传阶段存在失败" "${EXIT_RUNTIME}"
;;
all)
perform_downloads || die "下载阶段存在失败,请先修复后重试" "${EXIT_RUNTIME}"
verify_images_present || die "下载后校验失败;可使用 --force 跳过" "${EXIT_VALIDATION}"
perform_compress || die "压缩阶段存在失败" "${EXIT_RUNTIME}"
harbor_login
perform_pushes || die "上传阶段存在失败" "${EXIT_RUNTIME}"
;;
*)
die "未实现的动作: ${ACTION}" "${EXIT_USAGE}"
;;
esac
print_summary
log_info "任务完成"
}
main "$@"