1290 lines
38 KiB
Bash
1290 lines
38 KiB
Bash
#!/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_LINENO(Bash 特性)增强错误可观测性。
|
||
#
|
||
# 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 "$@" |