You've already forked hubmanager
Use keyctl (keyutils) to cache the master passphrase in the kernel keyring with a configurable TTL (default 5 min). New unlock/lock subcommands for manual cache control. keyctl is optional — silently skipped if not installed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1745 lines
62 KiB
Bash
Executable File
1745 lines
62 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# hubmanager - Manage Docker Registry images remotely
|
|
# Version: 0.3.0
|
|
# Dependencies: curl, jq, bash 4+
|
|
# Usage: hubmanager --help
|
|
|
|
set -euo pipefail
|
|
|
|
# =============================================================================
|
|
# --- Constants ---
|
|
# =============================================================================
|
|
readonly HM_VERSION="0.3.0"
|
|
readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io"
|
|
readonly HM_DEFAULT_CONFIG="${HOME}/.config/hubmanager.conf"
|
|
readonly HM_DOCKERHUB_API="https://hub.docker.com"
|
|
readonly HM_KEYRING_KEY="hubmanager:master"
|
|
readonly HM_KEYRING_DEFAULT_TIMEOUT=300 # seconds (5 minutes)
|
|
|
|
# =============================================================================
|
|
# --- Global state ---
|
|
# =============================================================================
|
|
HM_REGISTRY=""
|
|
HM_USERNAME=""
|
|
HM_PASSWORD=""
|
|
HM_CONFIG_FILE="$HM_DEFAULT_CONFIG"
|
|
HM_OPT_JSON=false
|
|
HM_OPT_NO_COLOR=false
|
|
HM_OPT_VERBOSE=false
|
|
HM_OPT_QUIET=false
|
|
|
|
declare -A HM_AUTH_MODE=() # registry -> "bearer"|"basic"|"none"
|
|
declare -A HM_AUTH_REALM=() # registry -> realm URL
|
|
declare -A HM_AUTH_SERVICE=() # registry -> service name
|
|
declare -A HM_TOKEN_CACHE=() # "registry|scope" -> token
|
|
declare -A HM_TOKEN_EXPIRY=() # "registry|scope" -> epoch seconds
|
|
declare -A HM_CONFIG_VARS=() # raw config key/value pairs
|
|
|
|
HM_HUB_TOKEN="" # Docker Hub REST API token (JWT)
|
|
HM_MASTER_PASS="" # Master passphrase for encrypted config values (session-scoped)
|
|
HM_CACHE_TIMEOUT="$HM_KEYRING_DEFAULT_TIMEOUT" # Keyring cache TTL in seconds
|
|
HM_TMPFILES=()
|
|
HM_LAST_HTTP_CODE=""
|
|
HM_LAST_HEADERS_FILE=""
|
|
HM_HTTP_CODE_FILE=$(mktemp)
|
|
HM_HEADERS_REF_FILE=$(mktemp)
|
|
|
|
trap '_hm_cleanup' EXIT INT TERM
|
|
_hm_cleanup() { rm -f "${HM_TMPFILES[@]}" "$HM_HTTP_CODE_FILE" "$HM_HEADERS_REF_FILE" 2>/dev/null || true; }
|
|
|
|
# =============================================================================
|
|
# --- Output / Formatting helpers ---
|
|
# =============================================================================
|
|
C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_BOLD="" C_DIM="" C_RESET=""
|
|
|
|
setup_colors() {
|
|
if [[ -t 1 && "$HM_OPT_NO_COLOR" == false ]]; then
|
|
C_RED=$'\033[0;31m'
|
|
C_GREEN=$'\033[0;32m'
|
|
C_YELLOW=$'\033[1;33m'
|
|
C_CYAN=$'\033[0;36m'
|
|
C_BOLD=$'\033[1m'
|
|
C_DIM=$'\033[2m'
|
|
C_RESET=$'\033[0m'
|
|
fi
|
|
}
|
|
|
|
die() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 1; }
|
|
die_auth() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 2; }
|
|
die_notfound() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 3; }
|
|
die_permission() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 4; }
|
|
die_server() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 5; }
|
|
die_network() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 6; }
|
|
die_notsup() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 7; }
|
|
|
|
warn() { [[ "$HM_OPT_QUIET" == true ]] || echo "${C_YELLOW}warning:${C_RESET} $*" >&2; }
|
|
info() { [[ "$HM_OPT_QUIET" == true ]] || echo "$*"; }
|
|
verbose() { [[ "$HM_OPT_VERBOSE" == true ]] || return 0; echo "${C_DIM}$*${C_RESET}" >&2; }
|
|
|
|
print_kv() {
|
|
printf "${C_BOLD}%-16s${C_RESET} %s\n" "$1:" "$2"
|
|
}
|
|
|
|
human_size() {
|
|
local bytes="$1"
|
|
awk -v b="$bytes" 'BEGIN {
|
|
if (b >= 1073741824) printf "%.1f GB", b/1073741824
|
|
else if (b >= 1048576) printf "%.1f MB", b/1048576
|
|
else if (b >= 1024) printf "%.1f KB", b/1024
|
|
else printf "%d B", b
|
|
}'
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Dependency check ---
|
|
# =============================================================================
|
|
check_deps() {
|
|
local missing=()
|
|
for cmd in curl jq; do
|
|
command -v "$cmd" &>/dev/null || missing+=("$cmd")
|
|
done
|
|
if (( ${#missing[@]} > 0 )); then
|
|
die "Missing required dependencies: ${missing[*]}. Please install them and retry."
|
|
fi
|
|
(( BASH_VERSINFO[0] >= 4 )) || die "Bash 4.0+ is required (found: $BASH_VERSION)."
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Encryption helpers ---
|
|
# =============================================================================
|
|
|
|
# Verify openssl is available (only needed when enc: values are present)
|
|
_require_openssl() {
|
|
command -v openssl &>/dev/null || \
|
|
die "openssl is required for encrypted config values. Install openssl and retry."
|
|
}
|
|
|
|
# --- Keyring helpers (optional; keyctl from keyutils) ---
|
|
|
|
# Check if keyctl is available
|
|
_keyring_available() { command -v keyctl &>/dev/null; }
|
|
|
|
# Try to read the cached passphrase from the user session keyring.
|
|
# Outputs the passphrase on stdout; returns 1 if not found or expired.
|
|
_keyring_get() {
|
|
_keyring_available || return 1
|
|
local key_id
|
|
key_id=$(keyctl request user "$HM_KEYRING_KEY" 2>/dev/null) || return 1
|
|
keyctl pipe "$key_id" 2>/dev/null || return 1
|
|
}
|
|
|
|
# Store passphrase in the user session keyring with a TTL.
|
|
# Args: <passphrase> [timeout_seconds]
|
|
_keyring_set() {
|
|
_keyring_available || return 0
|
|
local pass="$1"
|
|
local ttl="${2:-$HM_CACHE_TIMEOUT}"
|
|
local key_id
|
|
key_id=$(keyctl add user "$HM_KEYRING_KEY" "$pass" @u 2>/dev/null) || return 0
|
|
keyctl timeout "$key_id" "$ttl" 2>/dev/null || true
|
|
}
|
|
|
|
# Revoke (clear) the cached passphrase from the keyring.
|
|
_keyring_clear() {
|
|
_keyring_available || return 0
|
|
local key_id
|
|
key_id=$(keyctl request user "$HM_KEYRING_KEY" 2>/dev/null) || return 0
|
|
keyctl revoke "$key_id" 2>/dev/null || true
|
|
}
|
|
|
|
# Prompt once per session; result cached in HM_MASTER_PASS and keyring
|
|
_prompt_master_pass() {
|
|
[[ -n "$HM_MASTER_PASS" ]] && return 0
|
|
# Try keyring cache first
|
|
local cached
|
|
if cached=$(_keyring_get); then
|
|
HM_MASTER_PASS="$cached"
|
|
return 0
|
|
fi
|
|
_require_openssl
|
|
printf "hubmanager master passphrase: " >/dev/tty
|
|
read -rs HM_MASTER_PASS </dev/tty
|
|
printf "\n" >/dev/tty
|
|
[[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty."
|
|
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
|
|
}
|
|
|
|
# Prompt and confirm a new master passphrase (used by login --encrypt)
|
|
_prompt_set_master_pass() {
|
|
_require_openssl
|
|
local pass1 pass2
|
|
printf "New master passphrase: " >/dev/tty
|
|
read -rs pass1 </dev/tty; printf "\n" >/dev/tty
|
|
printf "Confirm master passphrase: " >/dev/tty
|
|
read -rs pass2 </dev/tty; printf "\n" >/dev/tty
|
|
[[ "$pass1" == "$pass2" ]] || die "Passphrases do not match."
|
|
[[ -n "$pass1" ]] || die "Master passphrase cannot be empty."
|
|
HM_MASTER_PASS="$pass1"
|
|
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
|
|
}
|
|
|
|
# Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines)
|
|
_encrypt_value() {
|
|
local plaintext="$1"
|
|
_prompt_master_pass
|
|
local pass_file result
|
|
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
|
|
printf '%s' "$HM_MASTER_PASS" > "$pass_file"
|
|
result=$(printf '%s' "$plaintext" | \
|
|
openssl enc -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null | tr -d '\n')
|
|
[[ -n "$result" ]] || die "Encryption failed. Check that openssl is installed."
|
|
printf '%s' "$result"
|
|
}
|
|
|
|
# Decrypt base64 CIPHERTEXT produced by _encrypt_value; outputs plaintext
|
|
_decrypt_value() {
|
|
local ciphertext="$1"
|
|
_prompt_master_pass
|
|
local pass_file result
|
|
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
|
|
printf '%s' "$HM_MASTER_PASS" > "$pass_file"
|
|
if ! result=$(printf '%s\n' "$ciphertext" | \
|
|
openssl enc -d -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null); then
|
|
die "Failed to decrypt config value. Wrong master passphrase?"
|
|
fi
|
|
printf '%s' "$result"
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Config loading ---
|
|
# =============================================================================
|
|
load_config() {
|
|
local file="$HM_CONFIG_FILE"
|
|
[[ -f "$file" ]] || return 0
|
|
|
|
# Warn if file is world-readable
|
|
local perms
|
|
perms=$(stat -c %a "$file" 2>/dev/null || stat -f %Lp "$file" 2>/dev/null || echo "")
|
|
if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then
|
|
warn "Config file '$file' has permissions $perms. Recommended: chmod 600 $file"
|
|
fi
|
|
|
|
local line key value
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
# Skip comments and blank lines
|
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
|
[[ -z "${line//[[:space:]]/}" ]] && continue
|
|
|
|
# Split on first =
|
|
key="${line%%=*}"
|
|
value="${line#*=}"
|
|
|
|
# Trim whitespace from key
|
|
key="${key#"${key%%[![:space:]]*}"}"
|
|
key="${key%"${key##*[![:space:]]}"}"
|
|
[[ -z "$key" ]] && continue
|
|
|
|
# Trim whitespace from value
|
|
value="${value#"${value%%[![:space:]]*}"}"
|
|
value="${value%"${value##*[![:space:]]}"}"
|
|
|
|
HM_CONFIG_VARS["$key"]="$value"
|
|
done < "$file"
|
|
|
|
# Apply config defaults (CLI flags already set take precedence)
|
|
[[ -z "$HM_REGISTRY" && -v 'HM_CONFIG_VARS[REGISTRY]' ]] && HM_REGISTRY="${HM_CONFIG_VARS[REGISTRY]}"
|
|
[[ -z "$HM_USERNAME" && -v 'HM_CONFIG_VARS[USERNAME]' ]] && HM_USERNAME="${HM_CONFIG_VARS[USERNAME]}"
|
|
if [[ -z "$HM_PASSWORD" && -v 'HM_CONFIG_VARS[PASSWORD]' ]]; then
|
|
local pw="${HM_CONFIG_VARS[PASSWORD]}"
|
|
if [[ "$pw" == enc:* ]]; then
|
|
HM_PASSWORD=$(_decrypt_value "${pw#enc:}")
|
|
else
|
|
HM_PASSWORD="$pw"
|
|
fi
|
|
fi
|
|
# Cache timeout (only override if not set via CLI)
|
|
if [[ "$HM_CACHE_TIMEOUT" == "$HM_KEYRING_DEFAULT_TIMEOUT" && -v 'HM_CONFIG_VARS[CACHE_TIMEOUT]' ]]; then
|
|
HM_CACHE_TIMEOUT="${HM_CONFIG_VARS[CACHE_TIMEOUT]}"
|
|
fi
|
|
}
|
|
|
|
resolve_registry_alias() {
|
|
# If REGISTRY looks like a short alias (no dots, slashes, or scheme), expand it
|
|
local reg="$HM_REGISTRY"
|
|
if [[ -n "$reg" && "$reg" != *"."* && "$reg" != *"/"* && "$reg" != http* ]]; then
|
|
local alias_upper="${reg^^}"
|
|
alias_upper="${alias_upper//-/_}"
|
|
local url_key="REGISTRY_${alias_upper}_URL"
|
|
if [[ -v "HM_CONFIG_VARS[$url_key]" ]]; then
|
|
local user_key="REGISTRY_${alias_upper}_USERNAME"
|
|
local pass_key="REGISTRY_${alias_upper}_PASSWORD"
|
|
HM_REGISTRY="${HM_CONFIG_VARS[$url_key]}"
|
|
[[ -z "$HM_USERNAME" && -v "HM_CONFIG_VARS[$user_key]" ]] && HM_USERNAME="${HM_CONFIG_VARS[$user_key]}"
|
|
if [[ -z "$HM_PASSWORD" && -v "HM_CONFIG_VARS[$pass_key]" ]]; then
|
|
local pw="${HM_CONFIG_VARS[$pass_key]}"
|
|
if [[ "$pw" == enc:* ]]; then
|
|
HM_PASSWORD=$(_decrypt_value "${pw#enc:}")
|
|
else
|
|
HM_PASSWORD="$pw"
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
# Strip trailing slash
|
|
HM_REGISTRY="${HM_REGISTRY%/}"
|
|
}
|
|
|
|
is_dockerhub() {
|
|
local registry="${1:-$HM_REGISTRY}"
|
|
[[ "$registry" == *"registry-1.docker.io"* || "$registry" == *"docker.io"* ]]
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- HTTP helpers ---
|
|
# =============================================================================
|
|
|
|
# raw_http METHOD URL [curl-options...]
|
|
# Sets: HM_LAST_HTTP_CODE, HM_LAST_HEADERS_FILE
|
|
# Outputs: response body to stdout (empty for HEAD)
|
|
raw_http() {
|
|
local method="$1"
|
|
local url="$2"
|
|
shift 2
|
|
|
|
local body_file header_file
|
|
body_file=$(mktemp); HM_TMPFILES+=("$body_file")
|
|
header_file=$(mktemp); HM_TMPFILES+=("$header_file")
|
|
HM_LAST_HEADERS_FILE="$header_file"
|
|
|
|
local -a method_flags=()
|
|
if [[ "$method" == "HEAD" ]]; then
|
|
method_flags=("--head")
|
|
else
|
|
method_flags=("-X" "$method")
|
|
fi
|
|
|
|
verbose ">>> $method $url"
|
|
|
|
HM_LAST_HTTP_CODE=$(curl -s \
|
|
-w "%{http_code}" \
|
|
"${method_flags[@]}" \
|
|
-D "$header_file" \
|
|
-o "$body_file" \
|
|
"$@" \
|
|
"$url" \
|
|
2>/dev/null) || HM_LAST_HTTP_CODE="000"
|
|
|
|
# Persist code and headers path to files so they survive subshells
|
|
printf '%s' "$HM_LAST_HTTP_CODE" > "$HM_HTTP_CODE_FILE"
|
|
printf '%s' "$header_file" > "$HM_HEADERS_REF_FILE"
|
|
|
|
verbose "<<< HTTP $HM_LAST_HTTP_CODE"
|
|
|
|
[[ "$method" != "HEAD" ]] && cat "$body_file"
|
|
return 0
|
|
}
|
|
|
|
get_response_header() {
|
|
local name="$1"
|
|
grep -i "^${name}:" "$HM_LAST_HEADERS_FILE" 2>/dev/null | tr -d '\r' | head -1 | sed 's/^[^:]*: *//' || true
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Authentication ---
|
|
# =============================================================================
|
|
|
|
probe_registry_auth() {
|
|
local registry="$1"
|
|
[[ -v "HM_AUTH_MODE[$registry]" ]] && return 0
|
|
|
|
local header_file
|
|
header_file=$(mktemp); HM_TMPFILES+=("$header_file")
|
|
|
|
local http_code
|
|
http_code=$(curl -s -o /dev/null -D "$header_file" -w "%{http_code}" \
|
|
"${registry}/v2/" 2>/dev/null) || http_code="000"
|
|
|
|
if [[ "$http_code" == "000" ]]; then
|
|
die_network "Cannot connect to registry at $registry. Check the URL and your network."
|
|
fi
|
|
|
|
local www_auth
|
|
www_auth=$(grep -i "^www-authenticate:" "$header_file" | tr -d '\r' | head -1 || true)
|
|
verbose "Auth probe for $registry: HTTP $http_code, WWW-Authenticate: $www_auth"
|
|
|
|
if echo "$www_auth" | grep -qi "bearer"; then
|
|
HM_AUTH_MODE["$registry"]="bearer"
|
|
HM_AUTH_REALM["$registry"]=$(echo "$www_auth" | grep -oi 'realm="[^"]*"' | cut -d'"' -f2 || true)
|
|
HM_AUTH_SERVICE["$registry"]=$(echo "$www_auth" | grep -oi 'service="[^"]*"' | cut -d'"' -f2 || true)
|
|
elif echo "$www_auth" | grep -qi "basic"; then
|
|
HM_AUTH_MODE["$registry"]="basic"
|
|
else
|
|
HM_AUTH_MODE["$registry"]="none"
|
|
fi
|
|
|
|
verbose "Auth mode for $registry: ${HM_AUTH_MODE[$registry]}"
|
|
}
|
|
|
|
_fetch_bearer_token() {
|
|
local registry="$1"
|
|
local scope="$2"
|
|
local username="${3:-$HM_USERNAME}"
|
|
local password="${4:-$HM_PASSWORD}"
|
|
|
|
local realm="${HM_AUTH_REALM[$registry]:-}"
|
|
local service="${HM_AUTH_SERVICE[$registry]:-}"
|
|
|
|
[[ -n "$realm" ]] || die_auth "No bearer realm configured for $registry. Try probing auth first."
|
|
|
|
local token_url="${realm}?scope=${scope}"
|
|
[[ -n "$service" ]] && token_url+="&service=${service}"
|
|
verbose "Fetching token from: $token_url"
|
|
|
|
local response
|
|
if [[ -n "$username" && -n "$password" ]]; then
|
|
response=$(curl -s -u "${username}:${password}" "$token_url" 2>/dev/null) || \
|
|
die_network "Cannot reach auth server: $realm"
|
|
else
|
|
response=$(curl -s "$token_url" 2>/dev/null) || \
|
|
die_network "Cannot reach auth server: $realm"
|
|
fi
|
|
|
|
local token
|
|
token=$(echo "$response" | jq -r '.token // .access_token // empty' 2>/dev/null || true)
|
|
|
|
if [[ -z "$token" ]]; then
|
|
local err
|
|
err=$(echo "$response" | jq -r '.details // .message // empty' 2>/dev/null || true)
|
|
[[ -n "$err" ]] && die_auth "Authentication failed: $err"
|
|
die_auth "Authentication failed for $registry. Check your credentials."
|
|
fi
|
|
|
|
local expires_in
|
|
expires_in=$(echo "$response" | jq -r '.expires_in // 55' 2>/dev/null || echo "55")
|
|
|
|
local cache_key="${registry}|${scope}"
|
|
HM_TOKEN_CACHE["$cache_key"]="$token"
|
|
HM_TOKEN_EXPIRY["$cache_key"]=$(( $(date +%s) + expires_in - 5 ))
|
|
|
|
echo "$token"
|
|
}
|
|
|
|
get_bearer_token() {
|
|
local registry="$1"
|
|
local scope="$2"
|
|
local username="${3:-$HM_USERNAME}"
|
|
local password="${4:-$HM_PASSWORD}"
|
|
|
|
local cache_key="${registry}|${scope}"
|
|
local now
|
|
now=$(date +%s)
|
|
|
|
if [[ -v "HM_TOKEN_CACHE[$cache_key]" ]]; then
|
|
local expiry="${HM_TOKEN_EXPIRY[$cache_key]:-0}"
|
|
if (( now < expiry )); then
|
|
echo "${HM_TOKEN_CACHE[$cache_key]}"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
_fetch_bearer_token "$registry" "$scope" "$username" "$password"
|
|
}
|
|
|
|
get_basic_auth_header() {
|
|
local username="${1:-$HM_USERNAME}"
|
|
local password="${2:-$HM_PASSWORD}"
|
|
[[ -n "$username" && -n "$password" ]] || return 1
|
|
printf "Authorization: Basic %s" "$(printf "%s:%s" "$username" "$password" | base64 | tr -d '\n')"
|
|
}
|
|
|
|
# Build auth header for a registry+scope; handles bearer and basic transparently
|
|
make_auth_header() {
|
|
local registry="$1"
|
|
local scope="${2:-}"
|
|
local username="${3:-$HM_USERNAME}"
|
|
local password="${4:-$HM_PASSWORD}"
|
|
|
|
probe_registry_auth "$registry"
|
|
local mode="${HM_AUTH_MODE[$registry]:-none}"
|
|
|
|
case "$mode" in
|
|
bearer)
|
|
if [[ -n "$scope" ]]; then
|
|
local token
|
|
token=$(get_bearer_token "$registry" "$scope" "$username" "$password") || return 1
|
|
echo "Authorization: Bearer $token"
|
|
fi
|
|
;;
|
|
basic)
|
|
get_basic_auth_header "$username" "$password" || true
|
|
;;
|
|
none)
|
|
echo ""
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Registry request ---
|
|
# =============================================================================
|
|
|
|
# registry_request METHOD REGISTRY PATH [options]
|
|
# Options:
|
|
# --scope SCOPE auth scope (required for bearer registries)
|
|
# -H "Header: Val" extra request header
|
|
# -o FILE write body to file
|
|
# --data DATA request body string
|
|
# --data-binary @FILE request body from file
|
|
# --no-die skip error handling (check HM_LAST_HTTP_CODE manually)
|
|
# Sets: HM_LAST_HTTP_CODE, HM_LAST_HEADERS_FILE
|
|
# Outputs: response body to stdout
|
|
registry_request() {
|
|
local method="$1"
|
|
local registry="$2"
|
|
local path="$3"
|
|
shift 3
|
|
|
|
local scope=""
|
|
local no_die=false
|
|
local -a headers=()
|
|
local -a extra=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--scope) scope="$2"; shift 2 ;;
|
|
--no-die) no_die=true; shift ;;
|
|
-H) headers+=("-H" "$2"); shift 2 ;;
|
|
-o) extra+=("-o" "$2"); shift 2 ;;
|
|
--data) extra+=("--data" "$2"); shift 2 ;;
|
|
--data-binary) extra+=("--data-binary" "$2"); shift 2 ;;
|
|
*) extra+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
local url="${registry}${path}"
|
|
|
|
# Get auth header
|
|
local auth_header
|
|
auth_header=$(make_auth_header "$registry" "$scope") || auth_header=""
|
|
|
|
local -a auth_args=()
|
|
[[ -n "$auth_header" ]] && auth_args=("-H" "$auth_header")
|
|
|
|
local body
|
|
body=$(raw_http "$method" "$url" "${auth_args[@]}" "${headers[@]}" "${extra[@]}")
|
|
HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE")
|
|
HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE")
|
|
|
|
# On 401: refresh token using the challenge scope and retry once
|
|
if [[ "$HM_LAST_HTTP_CODE" == "401" ]]; then
|
|
local www_auth
|
|
www_auth=$(get_response_header "www-authenticate")
|
|
local challenge_scope
|
|
challenge_scope=$(echo "$www_auth" | grep -oi 'scope="[^"]*"' | cut -d'"' -f2 || true)
|
|
local retry_scope="${scope:-$challenge_scope}"
|
|
|
|
if [[ -n "$retry_scope" && -n "$www_auth" ]]; then
|
|
verbose "401 received, retrying with scope: $retry_scope"
|
|
# Invalidate cache
|
|
unset "HM_TOKEN_CACHE[${registry}|${retry_scope}]" || true
|
|
|
|
local retry_auth
|
|
retry_auth=$(make_auth_header "$registry" "$retry_scope") || retry_auth=""
|
|
local -a retry_auth_args=()
|
|
[[ -n "$retry_auth" ]] && retry_auth_args=("-H" "$retry_auth")
|
|
|
|
body=$(raw_http "$method" "$url" "${retry_auth_args[@]}" "${headers[@]}" "${extra[@]}")
|
|
HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE")
|
|
HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE")
|
|
fi
|
|
fi
|
|
|
|
if [[ "$no_die" == false ]]; then
|
|
_handle_http_error "$HM_LAST_HTTP_CODE" "$body" "$url"
|
|
fi
|
|
|
|
echo "$body"
|
|
}
|
|
|
|
_handle_http_error() {
|
|
local code="$1"
|
|
local body="$2"
|
|
local url="$3"
|
|
local msg
|
|
|
|
case "$code" in
|
|
2*|304) return 0 ;;
|
|
401)
|
|
msg=$(_extract_registry_error "$body")
|
|
die_auth "Authentication required${msg:+: $msg}"
|
|
;;
|
|
403)
|
|
msg=$(_extract_registry_error "$body")
|
|
die_permission "Access denied${msg:+: $msg}"
|
|
;;
|
|
404)
|
|
msg=$(_extract_registry_error "$body")
|
|
die_notfound "Not found: $url${msg:+. $msg}"
|
|
;;
|
|
405)
|
|
die_notsup "Operation not supported by this registry."
|
|
;;
|
|
5*)
|
|
msg=$(_extract_registry_error "$body")
|
|
die_server "Registry server error $code${msg:+: $msg}"
|
|
;;
|
|
000)
|
|
die_network "Cannot connect to ${url%%/v2/*}. Check the URL and your network."
|
|
;;
|
|
*)
|
|
die "Unexpected HTTP $code from $url"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
_extract_registry_error() {
|
|
echo "$1" | jq -r '.errors[0].message // empty' 2>/dev/null || true
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Manifest Accept headers (used by multiple subcommands) ---
|
|
# =============================================================================
|
|
MANIFEST_ACCEPT_HEADERS=(
|
|
-H "Accept: application/vnd.docker.distribution.manifest.v2+json"
|
|
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json"
|
|
-H "Accept: application/vnd.oci.image.manifest.v1+json"
|
|
-H "Accept: application/vnd.oci.image.index.v1+json"
|
|
)
|
|
|
|
# Normalize image name: add library/ prefix for Docker Hub official images
|
|
normalize_image() {
|
|
local image="$1"
|
|
local registry="${2:-$HM_REGISTRY}"
|
|
if is_dockerhub "$registry" && [[ "$image" != *"/"* ]]; then
|
|
echo "library/$image"
|
|
else
|
|
echo "$image"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Docker Hub REST API ---
|
|
# =============================================================================
|
|
get_hub_api_token() {
|
|
[[ -n "$HM_HUB_TOKEN" ]] && { echo "$HM_HUB_TOKEN"; return 0; }
|
|
|
|
[[ -n "$HM_USERNAME" && -n "$HM_PASSWORD" ]] || \
|
|
die_auth "Docker Hub credentials required for this operation. Use --user and --password."
|
|
|
|
local response
|
|
response=$(curl -s -X POST "${HM_DOCKERHUB_API}/v2/users/login" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"username\":\"${HM_USERNAME}\",\"password\":\"${HM_PASSWORD}\"}" \
|
|
2>/dev/null) || die_network "Cannot connect to Docker Hub API."
|
|
|
|
HM_HUB_TOKEN=$(echo "$response" | jq -r '.token // empty' 2>/dev/null || true)
|
|
|
|
if [[ -z "$HM_HUB_TOKEN" ]]; then
|
|
local err
|
|
err=$(echo "$response" | jq -r '.detail // .message // empty' 2>/dev/null || true)
|
|
die_auth "Docker Hub login failed${err:+: $err}"
|
|
fi
|
|
|
|
echo "$HM_HUB_TOKEN"
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: help ---
|
|
# =============================================================================
|
|
show_usage() {
|
|
cat <<EOF
|
|
${C_BOLD}Usage:${C_RESET} hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
|
|
|
|
${C_BOLD}Options:${C_RESET}
|
|
-r, --registry <url> Registry URL (default: https://registry-1.docker.io)
|
|
-u, --user <username> Username
|
|
-p, --password <pass> Password or token
|
|
--config <file> Config file (default: ~/.config/hubmanager.conf)
|
|
--cache-timeout <s> Passphrase cache TTL in seconds (default: 300)
|
|
--json Output raw JSON
|
|
--no-color Disable color output
|
|
-v, --verbose Verbose mode
|
|
-q, --quiet Suppress non-error output
|
|
-h, --help Show this help
|
|
--version Show version
|
|
|
|
${C_BOLD}Commands:${C_RESET}
|
|
list List repositories in the registry
|
|
tags <image> List tags for an image
|
|
inspect <image>:<ref> Show image manifest details
|
|
delete <image>:<ref> Delete an image tag or manifest
|
|
copy <src> <dst> Copy/retag an image
|
|
prune <image> Delete outdated tags for an image
|
|
login Test credentials and optionally save to config (--save [--encrypt])
|
|
unlock Cache master passphrase in kernel keyring (requires keyctl)
|
|
lock Clear cached master passphrase
|
|
|
|
Run ${C_BOLD}hubmanager <command> --help${C_RESET} for command-specific options.
|
|
EOF
|
|
}
|
|
|
|
cmd_help() { show_usage; }
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: version ---
|
|
# =============================================================================
|
|
cmd_version() { echo "hubmanager $HM_VERSION"; }
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: unlock ---
|
|
# =============================================================================
|
|
cmd_unlock() {
|
|
if [[ "${1:-}" == "--help" ]]; then
|
|
cat <<EOF
|
|
${C_BOLD}Usage:${C_RESET} hubmanager unlock [--timeout <seconds>]
|
|
|
|
Cache the master passphrase in the Linux kernel keyring so subsequent
|
|
commands do not re-prompt. Requires ${C_BOLD}keyctl${C_RESET} (keyutils package).
|
|
|
|
--timeout <sec> Cache duration (default: ${HM_KEYRING_DEFAULT_TIMEOUT}s / 5 min)
|
|
EOF
|
|
return 0
|
|
fi
|
|
_keyring_available || die "keyctl (keyutils) is required for passphrase caching. Install keyutils and retry."
|
|
_require_openssl
|
|
# Force a fresh prompt even if already cached
|
|
HM_MASTER_PASS=""
|
|
printf "hubmanager master passphrase: " >/dev/tty
|
|
read -rs HM_MASTER_PASS </dev/tty
|
|
printf "\n" >/dev/tty
|
|
[[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty."
|
|
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
|
|
info "Passphrase cached for ${HM_CACHE_TIMEOUT}s."
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: lock ---
|
|
# =============================================================================
|
|
cmd_lock() {
|
|
if [[ "${1:-}" == "--help" ]]; then
|
|
cat <<EOF
|
|
${C_BOLD}Usage:${C_RESET} hubmanager lock
|
|
|
|
Clear the cached master passphrase from the Linux kernel keyring.
|
|
EOF
|
|
return 0
|
|
fi
|
|
_keyring_clear
|
|
HM_MASTER_PASS=""
|
|
info "Passphrase cache cleared."
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: login ---
|
|
# =============================================================================
|
|
cmd_login() {
|
|
local do_save=false
|
|
local do_encrypt=false
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--save) do_save=true; shift ;;
|
|
--encrypt) do_encrypt=true; shift ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] [--encrypt]
|
|
|
|
Test credentials against the registry.
|
|
|
|
Options:
|
|
--save Write validated credentials to the config file (~/.config/hubmanager.conf)
|
|
--encrypt Encrypt the password in the config file with AES-256-CBC.
|
|
A master passphrase will be prompted; you must enter the same
|
|
passphrase on every subsequent hubmanager invocation that reads
|
|
the config file.
|
|
EOF
|
|
exit 0 ;;
|
|
*) die "Unknown option: $1. Run 'hubmanager login --help'." ;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY"
|
|
|
|
probe_registry_auth "$HM_REGISTRY"
|
|
local mode="${HM_AUTH_MODE[$HM_REGISTRY]}"
|
|
|
|
case "$mode" in
|
|
bearer)
|
|
_fetch_bearer_token "$HM_REGISTRY" "registry:catalog:*" > /dev/null || \
|
|
die_auth "Login failed for $HM_REGISTRY"
|
|
info "${C_GREEN}Login Succeeded${C_RESET} — bearer auth, registry: $HM_REGISTRY"
|
|
;;
|
|
basic)
|
|
raw_http GET "${HM_REGISTRY}/v2/" -H "$(get_basic_auth_header)" > /dev/null
|
|
if [[ "$HM_LAST_HTTP_CODE" == "200" || "$HM_LAST_HTTP_CODE" == "401" ]]; then
|
|
# 401 on basic means wrong creds; 200 means ok
|
|
[[ "$HM_LAST_HTTP_CODE" == "200" ]] || die_auth "Login failed: invalid credentials."
|
|
info "${C_GREEN}Login Succeeded${C_RESET} — basic auth, registry: $HM_REGISTRY"
|
|
else
|
|
die_auth "Login failed (HTTP $HM_LAST_HTTP_CODE)"
|
|
fi
|
|
;;
|
|
none)
|
|
info "${C_GREEN}Login Succeeded${C_RESET} — no auth required, registry: $HM_REGISTRY"
|
|
;;
|
|
esac
|
|
|
|
if [[ "$do_save" == true ]]; then
|
|
local config="$HM_CONFIG_FILE"
|
|
mkdir -p "$(dirname "$config")"
|
|
local pw_entry=""
|
|
if [[ -n "$HM_PASSWORD" ]]; then
|
|
if [[ "$do_encrypt" == true ]]; then
|
|
_prompt_set_master_pass
|
|
local ciphertext
|
|
ciphertext=$(_encrypt_value "$HM_PASSWORD")
|
|
pw_entry="PASSWORD=enc:${ciphertext}"
|
|
else
|
|
pw_entry="PASSWORD=${HM_PASSWORD}"
|
|
fi
|
|
fi
|
|
{
|
|
echo "# hubmanager configuration"
|
|
echo "# Generated by: hubmanager login"
|
|
echo ""
|
|
echo "REGISTRY=${HM_REGISTRY}"
|
|
[[ -n "$HM_USERNAME" ]] && echo "USERNAME=${HM_USERNAME}"
|
|
[[ -n "$pw_entry" ]] && echo "$pw_entry"
|
|
} > "$config"
|
|
chmod 600 "$config"
|
|
info "Credentials saved to $config"
|
|
[[ "$do_encrypt" == true ]] && info "Password stored encrypted (AES-256-CBC). Master passphrase required on each use."
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: list ---
|
|
# =============================================================================
|
|
cmd_list() {
|
|
local limit=100
|
|
local last=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--limit) limit="$2"; shift 2 ;;
|
|
--last) last="$2"; shift 2 ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: hubmanager list [--limit N] [--last REPO]
|
|
|
|
List repositories available in the registry.
|
|
On Docker Hub, lists repositories for the authenticated user.
|
|
|
|
Options:
|
|
--limit N Page size (default: 100)
|
|
--last REPO Start listing after this repository (pagination cursor)
|
|
EOF
|
|
exit 0 ;;
|
|
*) die "Unknown option: $1. Run 'hubmanager list --help'." ;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY"
|
|
|
|
if is_dockerhub; then
|
|
_list_dockerhub "$limit"
|
|
else
|
|
_list_v2 "$limit" "$last"
|
|
fi
|
|
}
|
|
|
|
_list_dockerhub() {
|
|
local page_size="$1"
|
|
|
|
[[ -n "$HM_USERNAME" ]] || die_auth "Docker Hub username required for listing repositories."
|
|
|
|
local hub_token
|
|
hub_token=$(get_hub_api_token)
|
|
|
|
if [[ "$HM_OPT_JSON" == true ]]; then
|
|
curl -s -H "Authorization: JWT $hub_token" \
|
|
"${HM_DOCKERHUB_API}/v2/repositories/${HM_USERNAME}/?page_size=${page_size}" \
|
|
2>/dev/null || die_network "Cannot reach Docker Hub API."
|
|
return
|
|
fi
|
|
|
|
printf "${C_BOLD}%-60s %-12s %s${C_RESET}\n" "REPOSITORY" "PULLS" "STARS"
|
|
|
|
local url="${HM_DOCKERHUB_API}/v2/repositories/${HM_USERNAME}/?page_size=${page_size}"
|
|
while [[ -n "$url" && "$url" != "null" ]]; do
|
|
local response
|
|
response=$(curl -s -H "Authorization: JWT $hub_token" "$url" 2>/dev/null) || \
|
|
die_network "Cannot reach Docker Hub API."
|
|
|
|
echo "$response" | jq -r \
|
|
'.results[] | [.name, (.pull_count // 0 | tostring), (.star_count // 0 | tostring)] | @tsv' \
|
|
2>/dev/null | \
|
|
while IFS=$'\t' read -r name pulls stars; do
|
|
printf "%-60s %-12s %s\n" "${HM_USERNAME}/${name}" "$pulls" "$stars"
|
|
done
|
|
|
|
url=$(echo "$response" | jq -r '.next // empty' 2>/dev/null || true)
|
|
done
|
|
}
|
|
|
|
_list_v2() {
|
|
local limit="$1"
|
|
local last="$2"
|
|
|
|
local path="/v2/_catalog?n=${limit}"
|
|
[[ -n "$last" ]] && path+="&last=${last}"
|
|
|
|
local body
|
|
body=$(registry_request GET "$HM_REGISTRY" "$path" --scope "registry:catalog:*")
|
|
|
|
if [[ "$HM_OPT_JSON" == true ]]; then
|
|
echo "$body"
|
|
return
|
|
fi
|
|
|
|
printf "${C_BOLD}%-60s${C_RESET}\n" "REPOSITORY"
|
|
echo "$body" | jq -r '.repositories[]? // empty'
|
|
|
|
# Follow pagination via Link header
|
|
local link
|
|
link=$(get_response_header "link") || true
|
|
if [[ -n "$link" ]]; then
|
|
local next_last
|
|
next_last=$(echo "$link" | grep -o 'last=[^&>]*' | cut -d= -f2 | head -1 || true)
|
|
[[ -n "$next_last" ]] && _list_v2 "$limit" "$next_last"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: tags ---
|
|
# =============================================================================
|
|
cmd_tags() {
|
|
local image=""
|
|
local limit=100
|
|
local last=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--limit) limit="$2"; shift 2 ;;
|
|
--last) last="$2"; shift 2 ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: hubmanager tags <image> [--limit N] [--last TAG]
|
|
|
|
List all tags for an image.
|
|
|
|
Options:
|
|
--limit N Page size (default: 100)
|
|
--last TAG Pagination cursor
|
|
EOF
|
|
exit 0 ;;
|
|
-*) die "Unknown option: $1. Run 'hubmanager tags --help'." ;;
|
|
*) image="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$image" ]] && die "Image name required. Usage: hubmanager tags <image>"
|
|
[[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY"
|
|
|
|
local name
|
|
name=$(normalize_image "$image")
|
|
|
|
_tags_page "$name" "$limit" "$last"
|
|
}
|
|
|
|
_tags_page() {
|
|
local name="$1"
|
|
local limit="$2"
|
|
local last="$3"
|
|
|
|
local path="/v2/${name}/tags/list?n=${limit}"
|
|
[[ -n "$last" ]] && path+="&last=${last}"
|
|
|
|
local body
|
|
body=$(registry_request GET "$HM_REGISTRY" "$path" \
|
|
--scope "repository:${name}:pull")
|
|
|
|
if [[ "$HM_OPT_JSON" == true ]]; then
|
|
echo "$body"
|
|
return
|
|
fi
|
|
|
|
# Print header only on first page
|
|
[[ -z "$last" ]] && printf "${C_BOLD}%-40s${C_RESET}\n" "TAG"
|
|
echo "$body" | jq -r '.tags[]? // empty'
|
|
|
|
# Follow pagination
|
|
local link
|
|
link=$(get_response_header "link") || true
|
|
if [[ -n "$link" ]]; then
|
|
local next_last
|
|
next_last=$(echo "$link" | grep -o 'last=[^&>]*' | cut -d= -f2 | head -1 || true)
|
|
[[ -n "$next_last" ]] && _tags_page "$name" "$limit" "$next_last"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: inspect ---
|
|
# =============================================================================
|
|
cmd_inspect() {
|
|
local ref=""
|
|
local platform=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--platform) platform="$2"; shift 2 ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: hubmanager inspect <image>:<tag|digest> [--platform OS/ARCH]
|
|
|
|
Show detailed image information: digest, size, layers, OS/arch, labels.
|
|
|
|
Options:
|
|
--platform OS/ARCH For multi-arch images, inspect a specific platform
|
|
Example: --platform linux/amd64
|
|
EOF
|
|
exit 0 ;;
|
|
-*) die "Unknown option: $1. Run 'hubmanager inspect --help'." ;;
|
|
*) ref="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$ref" ]] && die "Image reference required. Usage: hubmanager inspect <image>:<tag>"
|
|
[[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY"
|
|
|
|
_do_inspect "$ref" "$platform"
|
|
}
|
|
|
|
_do_inspect() {
|
|
local ref="$1"
|
|
local platform="${2:-}"
|
|
|
|
# Parse image:tag or image@digest
|
|
local image tag
|
|
if [[ "$ref" == *"@"* ]]; then
|
|
image="${ref%%@*}"; tag="${ref#*@}"
|
|
elif [[ "$ref" == *":"* ]]; then
|
|
image="${ref%%:*}"; tag="${ref#*:}"
|
|
else
|
|
image="$ref"; tag="latest"
|
|
fi
|
|
|
|
image=$(normalize_image "$image")
|
|
local scope="repository:${image}:pull"
|
|
|
|
# Fetch manifest (GET with multi-Accept; headers give us the digest)
|
|
local manifest_body
|
|
manifest_body=$(registry_request GET "$HM_REGISTRY" "/v2/${image}/manifests/${tag}" \
|
|
--scope "$scope" \
|
|
"${MANIFEST_ACCEPT_HEADERS[@]}")
|
|
|
|
local digest
|
|
digest=$(get_response_header "docker-content-digest") || true
|
|
|
|
local content_type
|
|
content_type=$(get_response_header "content-type") || true
|
|
[[ -z "$content_type" ]] && \
|
|
content_type=$(echo "$manifest_body" | jq -r '.mediaType // "unknown"' 2>/dev/null || true)
|
|
|
|
if [[ "$HM_OPT_JSON" == true ]]; then
|
|
echo "$manifest_body" | jq --arg digest "$digest" '. + {_digest: $digest}'
|
|
return
|
|
fi
|
|
|
|
print_kv "Image" "${image}:${tag}"
|
|
[[ -n "$digest" ]] && print_kv "Digest" "$digest"
|
|
print_kv "MediaType" "$content_type"
|
|
|
|
# Multi-arch manifest list?
|
|
local schema_type
|
|
schema_type=$(echo "$manifest_body" | jq -r '.mediaType // ""' 2>/dev/null || true)
|
|
|
|
if echo "$schema_type" | grep -qE "manifest\.list|image\.index"; then
|
|
echo ""
|
|
printf "${C_BOLD}%-22s %-12s %-12s %s${C_RESET}\n" "DIGEST" "OS" "ARCH" "SIZE"
|
|
echo "$manifest_body" | jq -r \
|
|
'.manifests[] | [.digest, (.platform.os // "?"), (.platform.architecture // "?"), (.size // 0 | tostring)] | @tsv' \
|
|
2>/dev/null | \
|
|
while IFS=$'\t' read -r d os arch sz; do
|
|
printf "%.22s %-12s %-12s %s\n" "$d" "$os" "$arch" "$(human_size "$sz")"
|
|
done
|
|
|
|
if [[ -n "$platform" ]]; then
|
|
local plat_os="${platform%%/*}"
|
|
local plat_arch="${platform#*/}"
|
|
local plat_digest
|
|
plat_digest=$(echo "$manifest_body" | jq -r \
|
|
--arg os "$plat_os" --arg arch "$plat_arch" \
|
|
'.manifests[] | select(.platform.os == $os and .platform.architecture == $arch) | .digest' \
|
|
2>/dev/null || true)
|
|
if [[ -n "$plat_digest" ]]; then
|
|
echo ""
|
|
echo "${C_BOLD}Platform: $platform${C_RESET}"
|
|
_do_inspect "${image}@${plat_digest}"
|
|
else
|
|
warn "Platform '$platform' not found in manifest list."
|
|
fi
|
|
fi
|
|
return
|
|
fi
|
|
|
|
# Single-arch manifest
|
|
local total_size
|
|
total_size=$(echo "$manifest_body" | jq '[.layers[].size // 0] | add // 0' 2>/dev/null || echo "0")
|
|
print_kv "CompressedSize" "$(human_size "$total_size") ($total_size bytes)"
|
|
|
|
# Config blob -> OS/arch, created, labels
|
|
local config_digest
|
|
config_digest=$(echo "$manifest_body" | jq -r '.config.digest // empty' 2>/dev/null || true)
|
|
|
|
if [[ -n "$config_digest" ]]; then
|
|
local config_body
|
|
config_body=$(registry_request GET "$HM_REGISTRY" "/v2/${image}/blobs/${config_digest}" \
|
|
--scope "$scope")
|
|
|
|
local os arch created
|
|
os=$(echo "$config_body" | jq -r '.os // empty' 2>/dev/null || true)
|
|
arch=$(echo "$config_body" | jq -r '.architecture // empty' 2>/dev/null || true)
|
|
created=$(echo "$config_body" | jq -r '.created // empty' 2>/dev/null || true)
|
|
|
|
[[ -n "$os" && -n "$arch" ]] && print_kv "OS/Arch" "${os}/${arch}"
|
|
[[ -n "$created" ]] && print_kv "Created" "$created"
|
|
|
|
local labels
|
|
labels=$(echo "$config_body" | \
|
|
jq -r '.config.Labels // {} | to_entries[] | " \(.key)=\(.value)"' 2>/dev/null || true)
|
|
if [[ -n "$labels" ]]; then
|
|
print_kv "Labels" ""
|
|
echo "$labels"
|
|
fi
|
|
fi
|
|
|
|
# Layers
|
|
local layer_count
|
|
layer_count=$(echo "$manifest_body" | jq '.layers | length' 2>/dev/null || echo "0")
|
|
print_kv "Layers" "$layer_count"
|
|
echo "$manifest_body" | jq -r \
|
|
'.layers // [] | to_entries[] |
|
|
" [" + (.key | tostring) + "] " + .value.digest +
|
|
" (" + (.value.size // 0 | tostring) + " bytes)"' \
|
|
2>/dev/null || true
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: delete ---
|
|
# =============================================================================
|
|
cmd_delete() {
|
|
local ref=""
|
|
local yes=false
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-y|--yes) yes=true; shift ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: hubmanager delete <image>:<tag|digest> [--yes]
|
|
|
|
Delete a tag or manifest from the registry.
|
|
Note: requires REGISTRY_STORAGE_DELETE_ENABLED=true on self-hosted registries.
|
|
Note: Docker Hub does not support deletion via the v2 API.
|
|
|
|
Options:
|
|
-y, --yes Skip confirmation prompt
|
|
EOF
|
|
exit 0 ;;
|
|
-*) die "Unknown option: $1. Run 'hubmanager delete --help'." ;;
|
|
*) ref="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$ref" ]] && die "Image reference required. Usage: hubmanager delete <image>:<tag>"
|
|
[[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY"
|
|
|
|
is_dockerhub && die_notsup \
|
|
"Docker Hub does not support image deletion via the v2 API. Use the web UI at https://hub.docker.com."
|
|
|
|
# Parse image:tag or image@digest
|
|
local image tag_or_digest is_digest=false
|
|
if [[ "$ref" == *"@"* ]]; then
|
|
image="${ref%%@*}"; tag_or_digest="${ref#*@}"; is_digest=true
|
|
elif [[ "$ref" == *":"* ]]; then
|
|
image="${ref%%:*}"; tag_or_digest="${ref#*:}"
|
|
else
|
|
die "Reference must include a tag or digest. Example: myimage:latest or myimage@sha256:..."
|
|
fi
|
|
|
|
image=$(normalize_image "$image")
|
|
local scope="repository:${image}:push"
|
|
|
|
# Resolve tag to digest
|
|
local digest
|
|
if [[ "$is_digest" == true ]]; then
|
|
digest="$tag_or_digest"
|
|
else
|
|
registry_request HEAD "$HM_REGISTRY" "/v2/${image}/manifests/${tag_or_digest}" \
|
|
--scope "$scope" "${MANIFEST_ACCEPT_HEADERS[@]}" > /dev/null
|
|
digest=$(get_response_header "docker-content-digest") || true
|
|
[[ -n "$digest" ]] || die "Could not resolve tag '$tag_or_digest' to a digest."
|
|
fi
|
|
|
|
# Confirm
|
|
if [[ "$yes" == false ]]; then
|
|
echo "About to delete: ${C_BOLD}${image}${C_RESET} @ ${C_YELLOW}${digest}${C_RESET}"
|
|
echo "Registry: $HM_REGISTRY"
|
|
printf "Type 'yes' to confirm: "
|
|
local confirm
|
|
read -r confirm
|
|
[[ "$confirm" == "yes" ]] || { info "Aborted."; exit 0; }
|
|
fi
|
|
|
|
registry_request DELETE "$HM_REGISTRY" "/v2/${image}/manifests/${digest}" \
|
|
--scope "$scope" --no-die > /dev/null
|
|
|
|
case "$HM_LAST_HTTP_CODE" in
|
|
200|202)
|
|
if [[ "$HM_OPT_JSON" == true ]]; then
|
|
echo "{\"deleted\":true,\"digest\":\"${digest}\"}"
|
|
else
|
|
info "${C_GREEN}Deleted:${C_RESET} ${image} @ ${digest}"
|
|
fi
|
|
;;
|
|
405)
|
|
die_notsup "Deletion not enabled on this registry. For self-hosted registries, set REGISTRY_STORAGE_DELETE_ENABLED=true."
|
|
;;
|
|
*)
|
|
die "Delete failed (HTTP $HM_LAST_HTTP_CODE)"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: copy ---
|
|
# =============================================================================
|
|
cmd_copy() {
|
|
local src="" dst=""
|
|
local src_registry="" dst_registry=""
|
|
local src_user="" src_password=""
|
|
local dst_user="" dst_password=""
|
|
local platform=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--src-registry) src_registry="$2"; shift 2 ;;
|
|
--dst-registry) dst_registry="$2"; shift 2 ;;
|
|
--src-user) src_user="$2"; shift 2 ;;
|
|
--src-password) src_password="$2"; shift 2 ;;
|
|
--dst-user) dst_user="$2"; shift 2 ;;
|
|
--dst-password) dst_password="$2"; shift 2 ;;
|
|
--platform) platform="$2"; shift 2 ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: hubmanager copy <src-image>:<tag> <dst-image>:<tag> [options]
|
|
|
|
Copy/retag an image within or across registries.
|
|
|
|
Options:
|
|
--src-registry URL Source registry (default: global --registry)
|
|
--dst-registry URL Destination registry (default: global --registry)
|
|
--src-user USER Source registry username
|
|
--src-password PASS Source registry password
|
|
--dst-user USER Destination registry username
|
|
--dst-password PASS Destination registry password
|
|
--platform OS/ARCH Copy a specific platform from a multi-arch image
|
|
EOF
|
|
exit 0 ;;
|
|
-*) die "Unknown option: $1. Run 'hubmanager copy --help'." ;;
|
|
*)
|
|
if [[ -z "$src" ]]; then src="$1"
|
|
elif [[ -z "$dst" ]]; then dst="$1"
|
|
else die "Too many arguments."
|
|
fi
|
|
shift ;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$src" ]] && die "Source image required."
|
|
[[ -z "$dst" ]] && die "Destination image required."
|
|
[[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY"
|
|
|
|
# Apply defaults
|
|
[[ -z "$src_registry" ]] && src_registry="$HM_REGISTRY"
|
|
[[ -z "$dst_registry" ]] && dst_registry="$HM_REGISTRY"
|
|
[[ -z "$src_user" ]] && src_user="$HM_USERNAME"
|
|
[[ -z "$src_password" ]] && src_password="$HM_PASSWORD"
|
|
[[ -z "$dst_user" ]] && dst_user="$HM_USERNAME"
|
|
[[ -z "$dst_password" ]] && dst_password="$HM_PASSWORD"
|
|
|
|
# Parse refs
|
|
local src_image src_tag dst_image dst_tag
|
|
if [[ "$src" == *":"* ]]; then src_image="${src%%:*}"; src_tag="${src#*:}"
|
|
else src_image="$src"; src_tag="latest"; fi
|
|
if [[ "$dst" == *":"* ]]; then dst_image="${dst%%:*}"; dst_tag="${dst#*:}"
|
|
else dst_image="$dst"; dst_tag="latest"; fi
|
|
|
|
src_image=$(normalize_image "$src_image" "$src_registry")
|
|
dst_image=$(normalize_image "$dst_image" "$dst_registry")
|
|
|
|
local src_scope="repository:${src_image}:pull"
|
|
local dst_scope="repository:${dst_image}:push"
|
|
|
|
info "Copying ${C_BOLD}${src_image}:${src_tag}${C_RESET} → ${C_BOLD}${dst_image}:${dst_tag}${C_RESET}"
|
|
|
|
# --- Fetch source manifest ---
|
|
local src_auth_header
|
|
src_auth_header=$(make_auth_header "$src_registry" "$src_scope" "$src_user" "$src_password") || src_auth_header=""
|
|
local -a src_auth=(); [[ -n "$src_auth_header" ]] && src_auth=("-H" "$src_auth_header")
|
|
|
|
local manifest_body
|
|
manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${src_tag}" \
|
|
"${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}")
|
|
HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE")
|
|
HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE")
|
|
[[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch source manifest (HTTP $HM_LAST_HTTP_CODE)"
|
|
|
|
local content_type
|
|
content_type=$(get_response_header "content-type") || true
|
|
[[ -z "$content_type" ]] && \
|
|
content_type=$(echo "$manifest_body" | jq -r '.mediaType // "application/vnd.docker.distribution.manifest.v2+json"' 2>/dev/null || true)
|
|
|
|
# Handle platform selection from a manifest list
|
|
local schema_type
|
|
schema_type=$(echo "$manifest_body" | jq -r '.mediaType // ""' 2>/dev/null || true)
|
|
if echo "$schema_type" | grep -qE "manifest\.list|image\.index" && [[ -n "$platform" ]]; then
|
|
local plat_os="${platform%%/*}" plat_arch="${platform#*/}"
|
|
local plat_digest
|
|
plat_digest=$(echo "$manifest_body" | jq -r \
|
|
--arg os "$plat_os" --arg arch "$plat_arch" \
|
|
'.manifests[] | select(.platform.os == $os and .platform.architecture == $arch) | .digest' \
|
|
2>/dev/null || true)
|
|
[[ -n "$plat_digest" ]] || die "Platform '$platform' not found in manifest list."
|
|
|
|
manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${plat_digest}" \
|
|
"${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}")
|
|
HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE")
|
|
HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE")
|
|
[[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch platform manifest (HTTP $HM_LAST_HTTP_CODE)"
|
|
content_type=$(get_response_header "content-type") || true
|
|
fi
|
|
|
|
# Save manifest bytes exactly (do not let jq reformat)
|
|
local manifest_file
|
|
manifest_file=$(mktemp); HM_TMPFILES+=("$manifest_file")
|
|
printf '%s' "$manifest_body" > "$manifest_file"
|
|
|
|
# --- Destination auth ---
|
|
local dst_auth_header
|
|
dst_auth_header=$(make_auth_header "$dst_registry" "$dst_scope" "$dst_user" "$dst_password") || dst_auth_header=""
|
|
local -a dst_auth=(); [[ -n "$dst_auth_header" ]] && dst_auth=("-H" "$dst_auth_header")
|
|
|
|
local same_registry=false
|
|
[[ "$src_registry" == "$dst_registry" ]] && same_registry=true
|
|
|
|
# --- Transfer blobs (skip for manifest lists) ---
|
|
local final_schema
|
|
final_schema=$(echo "$manifest_body" | jq -r '.mediaType // ""' 2>/dev/null || true)
|
|
if ! echo "$final_schema" | grep -qE "manifest\.list|image\.index"; then
|
|
local blobs
|
|
blobs=$(echo "$manifest_body" | \
|
|
jq -r '([.config] + (.layers // [])) | .[].digest' 2>/dev/null || true)
|
|
|
|
local total_blobs i=1
|
|
total_blobs=$(echo "$blobs" | grep -c . || true)
|
|
|
|
while IFS= read -r blob_digest; do
|
|
[[ -z "$blob_digest" ]] && continue
|
|
info " Blob $i/$total_blobs: ${blob_digest:7:16}…"
|
|
|
|
if [[ "$same_registry" == true && "$src_image" != "$dst_image" ]]; then
|
|
# Attempt cross-repo blob mount (same registry, different repo)
|
|
raw_http POST \
|
|
"${dst_registry}/v2/${dst_image}/blobs/uploads/?mount=${blob_digest}&from=${src_image}" \
|
|
"${dst_auth[@]}" > /dev/null 2>/dev/null || true
|
|
if [[ "$HM_LAST_HTTP_CODE" == "201" ]]; then
|
|
verbose " Blob mounted (no transfer needed)"
|
|
: $(( i++ ))
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
# Check if blob already at destination
|
|
raw_http HEAD "${dst_registry}/v2/${dst_image}/blobs/${blob_digest}" \
|
|
"${dst_auth[@]}" > /dev/null 2>/dev/null || true
|
|
if [[ "$HM_LAST_HTTP_CODE" == "200" ]]; then
|
|
verbose " Blob already exists at destination"
|
|
: $(( i++ ))
|
|
continue
|
|
fi
|
|
|
|
# Download to temp file, then upload
|
|
local blob_file
|
|
blob_file=$(mktemp); HM_TMPFILES+=("$blob_file")
|
|
|
|
verbose " Downloading blob…"
|
|
raw_http GET "${src_registry}/v2/${src_image}/blobs/${blob_digest}" \
|
|
"${src_auth[@]}" -o "$blob_file" > /dev/null
|
|
[[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to download blob $blob_digest (HTTP $HM_LAST_HTTP_CODE)"
|
|
|
|
# Initiate upload
|
|
raw_http POST "${dst_registry}/v2/${dst_image}/blobs/uploads/" \
|
|
"${dst_auth[@]}" > /dev/null
|
|
local upload_url
|
|
upload_url=$(get_response_header "location") || true
|
|
[[ -n "$upload_url" ]] || die "Failed to initiate blob upload (HTTP $HM_LAST_HTTP_CODE)"
|
|
[[ "$upload_url" != http* ]] && upload_url="${dst_registry}${upload_url}"
|
|
|
|
# Upload
|
|
verbose " Uploading blob…"
|
|
local sep="?"; [[ "$upload_url" == *"?"* ]] && sep="&"
|
|
raw_http PUT "${upload_url}${sep}digest=${blob_digest}" \
|
|
"${dst_auth[@]}" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@${blob_file}" > /dev/null
|
|
[[ "$HM_LAST_HTTP_CODE" == "201" ]] || \
|
|
die "Failed to upload blob $blob_digest (HTTP $HM_LAST_HTTP_CODE)"
|
|
|
|
: $(( i++ ))
|
|
done <<< "$blobs"
|
|
fi
|
|
|
|
# --- Push manifest ---
|
|
info " Pushing manifest…"
|
|
raw_http PUT "${dst_registry}/v2/${dst_image}/manifests/${dst_tag}" \
|
|
"${dst_auth[@]}" \
|
|
-H "Content-Type: ${content_type}" \
|
|
--data-binary "@${manifest_file}" > /dev/null
|
|
|
|
[[ "$HM_LAST_HTTP_CODE" == "200" || "$HM_LAST_HTTP_CODE" == "201" ]] || \
|
|
die "Failed to push manifest (HTTP $HM_LAST_HTTP_CODE)"
|
|
|
|
local result_digest
|
|
result_digest=$(get_response_header "docker-content-digest") || true
|
|
|
|
if [[ "$HM_OPT_JSON" == true ]]; then
|
|
echo "{\"copied\":true,\"src\":\"${src_image}:${src_tag}\",\"dst\":\"${dst_image}:${dst_tag}\",\"digest\":\"${result_digest}\"}"
|
|
else
|
|
info "${C_GREEN}Copied successfully${C_RESET}"
|
|
[[ -n "$result_digest" ]] && info "Digest: $result_digest"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Subcommand: prune ---
|
|
# =============================================================================
|
|
cmd_prune() {
|
|
local image=""
|
|
local keep=3
|
|
local older_than=""
|
|
local exclude_pattern="^(latest|stable|main|master|release)$"
|
|
local dry_run=false
|
|
local yes=false
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--keep) keep="$2"; shift 2 ;;
|
|
--older-than) older_than="$2"; shift 2 ;;
|
|
--exclude) exclude_pattern="$2"; shift 2 ;;
|
|
--no-exclude) exclude_pattern=""; shift ;;
|
|
-n|--dry-run) dry_run=true; shift ;;
|
|
-y|--yes) yes=true; shift ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: hubmanager prune <image> [options]
|
|
|
|
Delete outdated tags for an image. Tags are sorted by image creation date
|
|
(newest first). The N most recent are kept; the rest are deleted.
|
|
|
|
Options:
|
|
--keep N Number of tags to keep (default: 3)
|
|
--older-than DAYS Only delete tags older than N days (overrides --keep)
|
|
--exclude PATTERN Extended regex of tag names to never delete
|
|
(default: "^(latest|stable|main|master|release)$")
|
|
--no-exclude Disable the default exclusion pattern
|
|
-n, --dry-run Show what would be deleted without actually deleting
|
|
-y, --yes Skip confirmation prompt
|
|
|
|
Examples:
|
|
hubmanager prune myuser/myapp --keep 5
|
|
hubmanager prune myuser/myapp --older-than 30 --dry-run
|
|
hubmanager prune myuser/myapp --keep 3 --exclude "^(latest|v[0-9]+\.[0-9]+)$"
|
|
EOF
|
|
exit 0 ;;
|
|
-*) die "Unknown option: $1. Run 'hubmanager prune --help'." ;;
|
|
*) image="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$image" ]] && die "Image name required. Usage: hubmanager prune <image>"
|
|
[[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY"
|
|
|
|
is_dockerhub && die_notsup \
|
|
"Docker Hub does not support deletion via the v2 API. Use the web UI at https://hub.docker.com."
|
|
|
|
local name
|
|
name=$(normalize_image "$image")
|
|
|
|
info "Fetching tags for ${C_BOLD}${name}${C_RESET}…"
|
|
|
|
# Collect all tags
|
|
local all_tags=()
|
|
while IFS= read -r tag; do
|
|
[[ -n "$tag" ]] && all_tags+=("$tag")
|
|
done < <(_collect_tags "$name")
|
|
|
|
local total_tags="${#all_tags[@]}"
|
|
[[ "$total_tags" -eq 0 ]] && { info "No tags found."; exit 0; }
|
|
info "Found $total_tags tags."
|
|
|
|
# Apply exclusion filter
|
|
local filtered_tags=()
|
|
local excluded_tags=()
|
|
for tag in "${all_tags[@]}"; do
|
|
if [[ -n "$exclude_pattern" ]] && echo "$tag" | grep -qE "$exclude_pattern"; then
|
|
excluded_tags+=("$tag")
|
|
else
|
|
filtered_tags+=("$tag")
|
|
fi
|
|
done
|
|
|
|
if (( ${#excluded_tags[@]} > 0 )); then
|
|
verbose "Excluded tags (won't be deleted): ${excluded_tags[*]}"
|
|
fi
|
|
|
|
if (( ${#filtered_tags[@]} == 0 )); then
|
|
info "No eligible tags to prune (all excluded)."
|
|
exit 0
|
|
fi
|
|
|
|
# Fetch creation dates for filterable tags
|
|
info "Inspecting ${#filtered_tags[@]} tags to determine age…"
|
|
local scope="repository:${name}:pull"
|
|
|
|
declare -A tag_dates=() # tag -> ISO8601 date string
|
|
declare -A tag_digests=() # tag -> digest
|
|
|
|
local i=1
|
|
for tag in "${filtered_tags[@]}"; do
|
|
printf "\r [%d/%d] %s…%-20s" "$i" "${#filtered_tags[@]}" "$tag" " " >&2
|
|
|
|
# Get manifest to fetch digest and config blob
|
|
local m_body
|
|
m_body=$(registry_request GET "$HM_REGISTRY" "/v2/${name}/manifests/${tag}" \
|
|
--scope "$scope" "${MANIFEST_ACCEPT_HEADERS[@]}" 2>/dev/null) || { : $(( i++ )); continue; }
|
|
|
|
local t_digest
|
|
t_digest=$(get_response_header "docker-content-digest") || true
|
|
[[ -n "$t_digest" ]] && tag_digests["$tag"]="$t_digest"
|
|
|
|
# Get creation date from config blob
|
|
local cfg_digest created=""
|
|
cfg_digest=$(echo "$m_body" | jq -r '.config.digest // empty' 2>/dev/null || true)
|
|
if [[ -n "$cfg_digest" ]]; then
|
|
local cfg_body
|
|
cfg_body=$(registry_request GET "$HM_REGISTRY" "/v2/${name}/blobs/${cfg_digest}" \
|
|
--scope "$scope" 2>/dev/null) || true
|
|
created=$(echo "$cfg_body" | jq -r '.created // empty' 2>/dev/null || true)
|
|
fi
|
|
|
|
tag_dates["$tag"]="${created:-1970-01-01T00:00:00Z}"
|
|
: $(( i++ ))
|
|
done
|
|
echo "" >&2 # newline after progress
|
|
|
|
# Sort tags by date (oldest first)
|
|
local sorted_tags
|
|
sorted_tags=$(for tag in "${!tag_dates[@]}"; do
|
|
echo "${tag_dates[$tag]} $tag"
|
|
done | sort | awk '{print $2}')
|
|
|
|
# Determine tags to delete
|
|
local tags_to_delete=()
|
|
local cutoff_epoch=0
|
|
|
|
if [[ -n "$older_than" ]]; then
|
|
# Delete tags older than N days
|
|
cutoff_epoch=$(( $(date +%s) - older_than * 86400 ))
|
|
while IFS= read -r tag; do
|
|
local tag_date="${tag_dates[$tag]:-1970-01-01T00:00:00Z}"
|
|
local tag_epoch
|
|
tag_epoch=$(date -d "$tag_date" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$tag_date" +%s 2>/dev/null || echo "0")
|
|
if (( tag_epoch < cutoff_epoch )); then
|
|
tags_to_delete+=("$tag")
|
|
fi
|
|
done <<< "$sorted_tags"
|
|
else
|
|
# Keep the N most recent; delete the rest
|
|
local sorted_count
|
|
sorted_count=$(echo "$sorted_tags" | grep -c . || true)
|
|
if (( sorted_count <= keep )); then
|
|
info "Nothing to prune: only $sorted_count eligible tags (keeping $keep)."
|
|
exit 0
|
|
fi
|
|
local to_delete_count=$(( sorted_count - keep ))
|
|
# Take oldest `to_delete_count` tags
|
|
while IFS= read -r tag; do
|
|
tags_to_delete+=("$tag")
|
|
done < <(echo "$sorted_tags" | head -n "$to_delete_count")
|
|
fi
|
|
|
|
if (( ${#tags_to_delete[@]} == 0 )); then
|
|
info "Nothing to prune."
|
|
exit 0
|
|
fi
|
|
|
|
# Show plan
|
|
echo ""
|
|
printf "${C_BOLD}Tags to delete (%d):${C_RESET}\n" "${#tags_to_delete[@]}"
|
|
for tag in "${tags_to_delete[@]}"; do
|
|
local date_str="${tag_dates[$tag]:-unknown}"
|
|
printf " ${C_YELLOW}%-40s${C_RESET} %s\n" "$tag" "$date_str"
|
|
done
|
|
|
|
if [[ "$dry_run" == true ]]; then
|
|
echo ""
|
|
info "${C_CYAN}Dry run — no tags were deleted.${C_RESET}"
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$yes" == false ]]; then
|
|
echo ""
|
|
printf "Delete ${C_BOLD}%d${C_RESET} tag(s) from ${C_BOLD}%s${C_RESET}? Type 'yes' to confirm: " \
|
|
"${#tags_to_delete[@]}" "$name"
|
|
local confirm
|
|
read -r confirm
|
|
[[ "$confirm" == "yes" ]] || { info "Aborted."; exit 0; }
|
|
fi
|
|
|
|
local delete_scope="repository:${name}:push"
|
|
local deleted=0 failed=0
|
|
|
|
for tag in "${tags_to_delete[@]}"; do
|
|
local digest="${tag_digests[$tag]:-}"
|
|
|
|
# If we didn't capture the digest earlier, resolve it now
|
|
if [[ -z "$digest" ]]; then
|
|
registry_request HEAD "$HM_REGISTRY" "/v2/${name}/manifests/${tag}" \
|
|
--scope "$delete_scope" "${MANIFEST_ACCEPT_HEADERS[@]}" > /dev/null 2>/dev/null || true
|
|
digest=$(get_response_header "docker-content-digest") || true
|
|
fi
|
|
|
|
if [[ -z "$digest" ]]; then
|
|
warn "Could not resolve digest for tag '$tag', skipping."
|
|
: $(( failed++ ))
|
|
continue
|
|
fi
|
|
|
|
registry_request DELETE "$HM_REGISTRY" "/v2/${name}/manifests/${digest}" \
|
|
--scope "$delete_scope" --no-die > /dev/null
|
|
|
|
case "$HM_LAST_HTTP_CODE" in
|
|
200|202)
|
|
info "${C_GREEN}Deleted:${C_RESET} ${name}:${tag}"
|
|
: $(( deleted++ ))
|
|
;;
|
|
405)
|
|
die_notsup "Deletion not enabled on this registry. Set REGISTRY_STORAGE_DELETE_ENABLED=true."
|
|
;;
|
|
*)
|
|
warn "Failed to delete ${name}:${tag} (HTTP $HM_LAST_HTTP_CODE)"
|
|
: $(( failed++ ))
|
|
;;
|
|
esac
|
|
done
|
|
|
|
echo ""
|
|
if [[ "$HM_OPT_JSON" == true ]]; then
|
|
echo "{\"deleted\":$deleted,\"failed\":$failed}"
|
|
else
|
|
info "Done. Deleted: ${C_GREEN}${deleted}${C_RESET}, Failed: ${failed}"
|
|
fi
|
|
}
|
|
|
|
_collect_tags() {
|
|
local name="$1"
|
|
local scope="repository:${name}:pull"
|
|
local last=""
|
|
|
|
while true; do
|
|
local path="/v2/${name}/tags/list?n=100"
|
|
[[ -n "$last" ]] && path+="&last=${last}"
|
|
|
|
local body
|
|
body=$(registry_request GET "$HM_REGISTRY" "$path" --scope "$scope")
|
|
echo "$body" | jq -r '.tags[]? // empty' 2>/dev/null || true
|
|
|
|
local link
|
|
link=$(get_response_header "link") || true
|
|
[[ -z "$link" ]] && break
|
|
|
|
local next_last
|
|
next_last=$(echo "$link" | grep -o 'last=[^&>]*' | cut -d= -f2 | head -1 || true)
|
|
[[ -z "$next_last" ]] && break
|
|
last="$next_last"
|
|
done
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Global argument parsing ---
|
|
# =============================================================================
|
|
parse_global_args() {
|
|
local -a remaining=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-r|--registry) HM_REGISTRY="$2"; shift 2 ;;
|
|
-u|--user) HM_USERNAME="$2"; shift 2 ;;
|
|
-p|--password) HM_PASSWORD="$2"; shift 2 ;;
|
|
--config) HM_CONFIG_FILE="$2"; shift 2 ;;
|
|
--cache-timeout) HM_CACHE_TIMEOUT="$2"; shift 2 ;;
|
|
--json) HM_OPT_JSON=true; shift ;;
|
|
--no-color) HM_OPT_NO_COLOR=true; shift ;;
|
|
-v|--verbose) HM_OPT_VERBOSE=true; shift ;;
|
|
-q|--quiet) HM_OPT_QUIET=true; shift ;;
|
|
-h|--help) show_usage; exit 0 ;;
|
|
--version) cmd_version; exit 0 ;;
|
|
--) shift; remaining+=("$@"); break ;;
|
|
# Pass non-global args through, but keep parsing remaining args
|
|
*) remaining+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
HM_REMAINING=("${remaining[@]+"${remaining[@]}"}")
|
|
}
|
|
|
|
# =============================================================================
|
|
# --- Main dispatcher ---
|
|
# =============================================================================
|
|
main() {
|
|
check_deps
|
|
|
|
declare -ga HM_REMAINING=()
|
|
parse_global_args "$@"
|
|
set -- "${HM_REMAINING[@]+"${HM_REMAINING[@]}"}"
|
|
|
|
load_config
|
|
[[ -n "$HM_REGISTRY" ]] && resolve_registry_alias
|
|
|
|
setup_colors
|
|
|
|
local command="${1:-help}"
|
|
[[ $# -gt 0 ]] && shift
|
|
|
|
case "$command" in
|
|
list) cmd_list "$@" ;;
|
|
tags) cmd_tags "$@" ;;
|
|
inspect) cmd_inspect "$@" ;;
|
|
delete) cmd_delete "$@" ;;
|
|
copy) cmd_copy "$@" ;;
|
|
prune) cmd_prune "$@" ;;
|
|
login) cmd_login "$@" ;;
|
|
unlock) cmd_unlock "$@" ;;
|
|
lock) cmd_lock "$@" ;;
|
|
help|-h|--help) cmd_help ;;
|
|
version|--version) cmd_version ;;
|
|
*)
|
|
die "Unknown command: '$command'. Run 'hubmanager --help' for usage."
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|