#!/usr/bin/env bash # ============================================================================== # Metadata # ============================================================================== # ubuntu 22.04 版本对应关系 # docker-ce=5:24.0.9-1~ubuntu.22.04~jammy # docker-ce-cli=5:24.0.9-1~ubuntu.22.04~jammy # docker-ce-rootless-extras=5:24.0.9-1~ubuntu.22.04~jammy # docker-compose-plugin=2.24.5-1~ubuntu.22.04~jammy # 官方为 1.7.13 # containerd=1.7.24-0ubuntu1~22.04.2 # docker-buildx-plugin=0.11.2-1~ubuntu.22.04~jammy # ============================================================================== set -euo pipefail # ============================================================================== # Global Constants # ============================================================================== readonly SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_VERSION="2.0.0" # Default mirror for Docker repo (you asked: only focus on docker source) readonly DEFAULT_DOCKER_APT_MIRROR="https://mirrors.aliyun.com/docker-ce/linux/ubuntu" # Default keyring location (recommended by modern Ubuntu) readonly DEFAULT_KEYRING_PATH="/etc/apt/keyrings/docker.gpg" # Exit codes readonly EC_OK=0 readonly EC_GENERAL=1 readonly EC_UNSUPPORTED_OS=10 readonly EC_DEPENDENCY=11 readonly EC_OFFLINE_NO_KEY=20 readonly EC_APT_FAILURE=30 # ============================================================================== # Configurable Variables (Environment Overrides) # ============================================================================== # You may export these before running: # DOCKER_VERSION="20.10" # or "20.10.15" (optional) # DOCKER_APT_MIRROR="https://..." # DOCKER_KEYRING_PATH="/root/wdd/docker.gpg" # LOCAL_DOCKER_GPG="/path/to/docker.gpg" (optional) # LOG_LEVEL="DEBUG|INFO|WARN|ERROR" DOCKER_VERSION="${DOCKER_VERSION:-24.0.9}" DOCKER_APT_MIRROR="${DOCKER_APT_MIRROR:-$DEFAULT_DOCKER_APT_MIRROR}" DOCKER_KEYRING_PATH="${DOCKER_KEYRING_PATH:-$DEFAULT_KEYRING_PATH}" LOCAL_DOCKER_GPG="${LOCAL_DOCKER_GPG:-/root/wdd/docker.gpg}" LOG_LEVEL="${LOG_LEVEL:-INFO}" # ============================================================================== # Function Call Graph (ASCII) # ============================================================================== # main # | # +--> init_traps # | # +--> check_platform # | # +--> ensure_prerequisites # | # +--> detect_public_network # | | # | +--> can_fetch_url_head # | # +--> ensure_docker_gpg_key # | | # | +--> install_key_from_online # | | | # | | +--> require_cmd (curl, gpg) # | | # | +--> install_key_from_local # | # +--> configure_docker_repo # | # +--> install_docker_packages # | | # | +--> resolve_docker_version # | # +--> pin_docker_packages # | # +--> enable_docker_service # ============================================================================== # ============================================================================== # Logging # ============================================================================== ### Map log level string to numeric value. ### @param level_str string Level string (DEBUG/INFO/WARN/ERROR) ### @return 0 Always returns 0; outputs numeric level to stdout ### @require none log_level_to_num() { case "${1:-INFO}" in DEBUG) echo 10 ;; INFO) echo 20 ;; WARN) echo 30 ;; ERROR) echo 40 ;; *) echo 20 ;; esac } ### Unified logger with level gating. ### @param level string Log level ### @param message string Message ### @return 0 Always returns 0 ### @require date log() { local level="${1:?level required}" shift local message="${*:-}" local now now="$(date '+%F %T')" local current_level_num wanted_level_num current_level_num="$(log_level_to_num "$LOG_LEVEL")" wanted_level_num="$(log_level_to_num "$level")" if [ "$wanted_level_num" -lt "$current_level_num" ]; then return 0 fi # > Keep format stable for parsing by log collectors printf '%s [%s] %s: %s\n' "$now" "$level" "$SCRIPT_NAME" "$message" >&2 } # ============================================================================== # Error Handling & Traps # ============================================================================== ### Trap handler for unexpected errors. ### @param exit_code int Exit code from failing command ### @return 0 Always returns 0 ### @require none on_error() { local exit_code="${1:-$EC_GENERAL}" log ERROR "Unhandled error occurred (exit_code=${exit_code})." exit "$exit_code" } ### Trap handler for script exit. ### @param exit_code int Exit code ### @return 0 Always returns 0 ### @require none on_exit() { local exit_code="${1:-$EC_OK}" if [ "$exit_code" -eq 0 ]; then log INFO "Done." else log WARN "Exited with code ${exit_code}." fi return 0 } ### Initialize traps (ERR/INT/TERM/EXIT). ### @return 0 Success ### @require none init_traps() { trap 'on_error $?' ERR trap 'log WARN "Interrupted (SIGINT)"; exit 130' INT trap 'log WARN "Terminated (SIGTERM)"; exit 143' TERM trap 'on_exit $?' EXIT } # ============================================================================== # Privilege Helpers # ============================================================================== ### Run a command as root (uses sudo if not root). ### @param cmd string Command to run ### @return 0 Success; non-zero on failure ### @require sudo (if not root) run_root() { if [ "$(id -u)" -eq 0 ]; then # shellcheck disable=SC2068 "$@" else # shellcheck disable=SC2068 sudo "$@" fi } # ============================================================================== # Dependency Checks # ============================================================================== ### Ensure a command exists in PATH. ### @param cmd_name string Command name ### @return 0 If exists; 1 otherwise ### @require none require_cmd() { local cmd_name="${1:?cmd required}" if ! command -v "$cmd_name" >/dev/null 2>&1; then log ERROR "Missing dependency: ${cmd_name}" return 1 fi return 0 } # ============================================================================== # Platform Check # ============================================================================== ### Check OS is Ubuntu and supported versions. ### @return 0 Supported; exits otherwise ### @require lsb_release, awk check_platform() { require_cmd lsb_release || exit "$EC_DEPENDENCY" local distro version distro="$(lsb_release -is 2>/dev/null || true)" version="$(lsb_release -rs 2>/dev/null || true)" if [ "$distro" != "Ubuntu" ]; then log ERROR "Unsupported OS: ${distro}. This script supports Ubuntu only." exit "$EC_UNSUPPORTED_OS" fi case "$version" in 18.04|20.04|22.04|24.04) ;; *) log ERROR "Unsupported Ubuntu version: ${version}. Supported: 18.04/20.04/22.04/24.04" exit "$EC_UNSUPPORTED_OS" ;; esac log INFO "Platform OK: ${distro} ${version}" } # ============================================================================== # APT Prerequisites # ============================================================================== ### Install required packages for repository/key management and Docker installation. ### @return 0 Success; exits on apt failures ### @require apt-get ensure_prerequisites() { require_cmd apt-get || exit "$EC_DEPENDENCY" log INFO "Installing prerequisites (does NOT modify APT sources or proxy)..." # > apt update must work via your existing proxy+mirror scripts if ! run_root apt-get update; then log ERROR "apt-get update failed. Check APT proxy / mirror configuration." exit "$EC_APT_FAILURE" fi # > Keep dependencies minimal; curl/gpg used only for online key fetch. if ! run_root apt-get install -y ca-certificates gnupg lsb-release; then log ERROR "Failed to install prerequisites." exit "$EC_APT_FAILURE" fi log INFO "Prerequisites installed." } # ============================================================================== # Public Network Reachability # ============================================================================== ### Check whether we can fetch HTTP headers from a URL (lightweight reachability). ### @param test_url string URL to test ### @return 0 Reachable; 1 otherwise ### @require curl (optional; if missing returns 1) can_fetch_url_head() { local test_url="${1:?url required}" if ! command -v curl >/dev/null 2>&1; then log WARN "curl not found; cannot test public network reachability via HTTP." return 1 fi # > Use short timeout to avoid hanging in restricted networks curl -fsSI --max-time 3 "$test_url" >/dev/null 2>&1 } ### Detect whether public network access is available for Docker key fetch. ### @return 0 Online; 1 Offline/Uncertain ### @require none detect_public_network() { local test_url="${DOCKER_APT_MIRROR%/}/gpg" log INFO "Detecting public network reachability: HEAD ${test_url}" if can_fetch_url_head "$test_url"; then log INFO "Public network reachable for Docker mirror." return 0 fi log WARN "Public network NOT reachable (or curl missing). Will try local GPG key." return 1 } # ============================================================================== # Docker GPG Key Management # ============================================================================== ### Install Docker GPG key from online source (mirror). ### @param gpg_url string GPG URL ### @param keyring_path string Keyring output path ### @return 0 Success; non-zero on failure ### @require curl, gpg, install, mkdir, chmod install_key_from_online() { local gpg_url="${1:?gpg_url required}" local keyring_path="${2:?keyring_path required}" require_cmd curl || return 1 require_cmd gpg || return 1 # > Write to temp then atomically install to avoid partial files local tmp_dir tmp_gpg tmp_dir="$(mktemp -d)" tmp_gpg="${tmp_dir}/docker.gpg" log INFO "Fetching Docker GPG key online: ${gpg_url}" curl -fsSL --max-time 10 "$gpg_url" | gpg --dearmor -o "$tmp_gpg" run_root mkdir -p "$(dirname "$keyring_path")" run_root install -m 0644 "$tmp_gpg" "$keyring_path" run_root chmod a+r "$keyring_path" || true rm -rf "$tmp_dir" log INFO "Docker GPG key installed: ${keyring_path}" return 0 } ### Install Docker GPG key from local file (offline-friendly). ### @param local_gpg_path string Local GPG file path ### @param keyring_path string Keyring output path ### @return 0 Success; 1 if local key missing; non-zero on other failures ### @require install, mkdir, chmod install_key_from_local() { local local_gpg_path="${1:?local_gpg_path required}" local keyring_path="${2:?keyring_path required}" if [ ! -f "$local_gpg_path" ]; then log WARN "Local Docker GPG key not found: ${local_gpg_path}" return 1 fi run_root mkdir -p "$(dirname "$keyring_path")" run_root install -m 0644 "$local_gpg_path" "$keyring_path" run_root chmod a+r "$keyring_path" || true log INFO "Docker GPG key installed from local: ${local_gpg_path} -> ${keyring_path}" return 0 } ### Ensure Docker GPG key exists, using online if reachable; otherwise local-only. ### Offline policy: if local key missing -> DO NOT proceed (exit). ### @param is_online int 0 online; 1 offline ### @return 0 Success; exits with EC_OFFLINE_NO_KEY when offline and no local key ### @require none ensure_docker_gpg_key() { local is_online="${1:?is_online required}" # > If keyring already exists, reuse it (idempotent) if [ -f "$DOCKER_KEYRING_PATH" ]; then log INFO "Docker keyring already exists: ${DOCKER_KEYRING_PATH}" run_root chmod a+r "$DOCKER_KEYRING_PATH" || true return 0 fi # > Determine local key candidate paths (priority order) local script_dir local_candidate script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [ -n "$LOCAL_DOCKER_GPG" ]; then local_candidate="$LOCAL_DOCKER_GPG" elif [ -f "${script_dir}/docker.gpg" ]; then local_candidate="${script_dir}/docker.gpg" else local_candidate="" fi local gpg_url gpg_url="${DOCKER_APT_MIRROR%/}/gpg" if [ "$is_online" -eq 0 ]; then # Online: try online key fetch first; if fails, fallback to local if present. log DEBUG "Online mode: attempt online key install, fallback to local." if install_key_from_online "$gpg_url" "$DOCKER_KEYRING_PATH"; then return 0 fi if [ -n "$local_candidate" ] && install_key_from_local "$local_candidate" "$DOCKER_KEYRING_PATH"; then return 0 fi log ERROR "Failed to install Docker GPG key (online fetch failed and no usable local key)." exit "$EC_DEPENDENCY" fi # Offline: strictly local only; if missing -> do not proceed log INFO "Offline mode: install Docker GPG key from local only." if [ -n "$local_candidate" ] && install_key_from_local "$local_candidate" "$DOCKER_KEYRING_PATH"; then return 0 fi log ERROR "Offline and local Docker GPG key is missing. Will NOT proceed (per policy)." exit "$EC_OFFLINE_NO_KEY" } # ============================================================================== # Docker Repo Configuration # ============================================================================== ### Configure Docker APT repository list file. ### @return 0 Success; exits on apt update failures ### @require dpkg, lsb_release, tee, apt-get configure_docker_repo() { require_cmd dpkg || exit "$EC_DEPENDENCY" require_cmd lsb_release || exit "$EC_DEPENDENCY" require_cmd tee || exit "$EC_DEPENDENCY" local codename arch list_file codename="$(lsb_release -cs)" arch="$(dpkg --print-architecture)" list_file="/etc/apt/sources.list.d/docker.list" log INFO "Configuring Docker APT repo: ${DOCKER_APT_MIRROR} (${codename}, ${arch})" # > Only touch docker repo; do not touch system sources.list run_root tee "$list_file" >/dev/null < apt-cache madison output includes epoch, keep it for apt-get install if [[ "$docker_version" =~ ^[0-9]+\.[0-9]+$ ]]; then # Pick newest patch/build for that major.minor resolved="$( apt-cache madison docker-ce \ | awk -F'|' '{gsub(/ /,"",$2); print $2}' \ | grep -E "^[0-9]+:${docker_version}([.-]|\~)" \ | sort -rV \ | head -1 || true )" elif [[ "$docker_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then resolved="$( apt-cache madison docker-ce \ | awk -F'|' '{gsub(/ /,"",$2); print $2}' \ | grep -E "^[0-9]+:${docker_version}.*" \ | head -1 || true )" else log ERROR "Invalid DOCKER_VERSION format: ${docker_version} (expect 20.10 or 20.10.15)" exit "$EC_GENERAL" fi if [ -z "$resolved" ]; then log ERROR "Cannot find Docker version '${docker_version}' from APT. Check repo/mirror and apt proxy." exit "$EC_APT_FAILURE" fi echo "$resolved" return 0 } ### Install Docker packages via APT. ### @return 0 Success; exits on failure ### @require apt-get, systemctl install_docker_packages() { require_cmd apt-get || exit "$EC_DEPENDENCY" local full_version full_version="$(resolve_docker_version "$DOCKER_VERSION")" log INFO "Installing Docker packages: docker-ce=${full_version}" # > Compose: use docker-compose-plugin (no curl downloading binaries) if ! run_root apt-get install -y \ "docker-ce=${full_version}" \ "docker-ce-cli=${full_version}" \ "docker-ce-rootless-extras=${full_version}" \ containerd.io \ docker-buildx-plugin \ docker-compose-plugin; then log ERROR "Docker installation failed." exit "$EC_APT_FAILURE" fi # > Optional: provide docker-compose legacy command compatibility if ! command -v docker-compose >/dev/null 2>&1; then if [ -x /usr/libexec/docker/cli-plugins/docker-compose ]; then run_root ln -sf /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose || true fi fi log INFO "Docker packages installed." } ### Pin Docker packages to avoid unintended upgrades. ### @return 0 Success; non-zero on failures (non-fatal) ### @require apt-mark pin_docker_packages() { if ! command -v apt-mark >/dev/null 2>&1; then log WARN "apt-mark not found; skip pinning." return 0 fi log INFO "Holding Docker packages (prevent auto-upgrade)..." run_root apt-mark hold \ docker-ce docker-ce-cli docker-ce-rootless-extras containerd.io \ docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1 || true return 0 } ### Enable and start Docker service, then verify versions. ### @return 0 Success; exits on failure to enable docker ### @require systemctl, docker enable_docker_service() { require_cmd systemctl || exit "$EC_DEPENDENCY" log INFO "Enabling and starting docker service..." run_root systemctl enable --now docker # > Verification should not hard-fail the whole script if command -v docker >/dev/null 2>&1; then docker --version || true docker compose version || true fi if command -v docker-compose >/dev/null 2>&1; then docker-compose --version || true fi log INFO "Docker service enabled." } # ============================================================================== # Main # ============================================================================== ### Main entrypoint. ### @return 0 Success; non-zero on failure ### @require none main() { init_traps log INFO "Starting Docker installer (v${SCRIPT_VERSION})..." check_platform ensure_prerequisites local is_online=1 if detect_public_network; then is_online=0 fi ensure_docker_gpg_key "$is_online" configure_docker_repo install_docker_packages pin_docker_packages enable_docker_service log INFO "All tasks completed successfully." exit "$EC_OK" } main "$@"