Files
hubmanager/hubmanager
magdev a59e416789 feat: add AES-256-CBC encrypted password storage (v0.2.0)
Add `login --save --encrypt` flag: passwords are encrypted with
openssl AES-256-CBC (PBKDF2) and stored as `enc:<base64>` in the
config file. A master passphrase is prompted once per session and
cached in memory. Both load_config() and resolve_registry_alias()
detect the enc: prefix and decrypt transparently. The passphrase is
passed to openssl via a temp file to avoid argv/env exposure.
openssl is an optional dependency, checked on demand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:52:48 +01:00

1633 lines
58 KiB
Bash
Executable File

#!/usr/bin/env bash
# hubmanager - Manage Docker Registry images remotely
# Version: 0.2.0
# Dependencies: curl, jq, bash 4+
# Usage: hubmanager --help
set -euo pipefail
# =============================================================================
# --- Constants ---
# =============================================================================
readonly HM_VERSION="0.2.0"
readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io"
readonly HM_DEFAULT_CONFIG="${HOME}/.hubmanager.conf"
readonly HM_DOCKERHUB_API="https://hub.docker.com"
# =============================================================================
# --- 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_TMPFILES=()
HM_LAST_HTTP_CODE=""
HM_LAST_HEADERS_FILE=""
trap '_hm_cleanup' EXIT INT TERM
_hm_cleanup() { rm -f "${HM_TMPFILES[@]}" 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."
}
# Prompt once per session; result cached in HM_MASTER_PASS
_prompt_master_pass() {
[[ -n "$HM_MASTER_PASS" ]] && return 0
_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."
}
# 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"
}
# 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' "$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
# Strip inline comments and trim whitespace from value
value="${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
}
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"
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[@]}")
# 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[@]}")
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: ~/.hubmanager.conf)
--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])
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: 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 (~/.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"
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" == "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" == "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 ;;
--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 ;;
# Stop global parsing at first non-flag arg or unknown flag
*) remaining+=("$@"); break ;;
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 "$@" ;;
help|-h|--help) cmd_help ;;
version|--version) cmd_version ;;
*)
die "Unknown command: '$command'. Run 'hubmanager --help' for usage."
;;
esac
}
main "$@"