Files
docker-tools/bash/docker-manager.sh
2026-03-22 00:54:34 -07:00

747 lines
22 KiB
Bash
Executable File

#!/bin/bash
# Docker Stack Manager Script
# Combines stack management and update functionality
set -euo pipefail
# Version
VERSION="2.6.2"
# Resolve script directory and source shared library
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
# Enable debug trace after sourcing common.sh (so DEBUG variable is available)
enable_debug_trace
# Default configuration
LOG_FILE="${LOG_FILE:-/tmp/docwell/docker-manager.log}"
# CLI flags
LIST_ONLY=false
START_STACK=""
STOP_STACK=""
RESTART_STACK=""
STATUS_STACK=""
LOGS_STACK=""
CHECK_UPDATES=false
UPDATE_ALL=false
UPDATE_STACK=""
AUTO_UPDATE=false
# Load config file overrides
load_config
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
--version)
echo "docker-manager.sh v$VERSION"
exit 0
;;
--quiet|-q)
QUIET=true
shift
;;
--yes|-y)
YES=true
shift
;;
--list|-l)
LIST_ONLY=true
shift
;;
--start)
START_STACK="$2"
shift 2
;;
--stop)
STOP_STACK="$2"
shift 2
;;
--restart)
RESTART_STACK="$2"
shift 2
;;
--status)
STATUS_STACK="$2"
shift 2
;;
--logs)
LOGS_STACK="$2"
shift 2
;;
--check)
CHECK_UPDATES=true
shift
;;
--update-all|-a)
UPDATE_ALL=true
shift
;;
--update|-u)
UPDATE_STACK="$2"
shift 2
;;
--auto)
AUTO_UPDATE=true
shift
;;
--install-deps)
AUTO_INSTALL=true
shift
;;
--log)
LOG_FILE="$2"
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
--debug|-d)
DEBUG=true
shift
;;
--verbose|-v)
VERBOSE=true
shift
;;
--help|-h)
show_help
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}" >&2
show_help
exit 1
;;
esac
done
}
show_help() {
cat << EOF
Docker Stack Manager Script v$VERSION
Combines stack lifecycle management and update functionality.
Usage: $0 [OPTIONS]
Stack Management:
--list, -l List all stacks and their status
--start STACK Start a specific stack
--stop STACK Stop a specific stack
--restart STACK Restart a specific stack
--status STACK Get status of a specific stack
--logs STACK View logs for a specific stack
Update Operations:
--check Check for available image updates
--update-all, -a Update all stacks
--update STACK, -u Update a specific stack
--auto Auto-update mode with zero-downtime
General Options:
--quiet, -q Suppress non-error output
--yes, -y Auto-confirm all prompts
--dry-run Show what would be done without actually doing it
--debug, -d Enable debug mode (verbose output + xtrace)
--verbose, -v Enable verbose logging
--install-deps Auto-install missing dependencies (requires root)
--log FILE Log file path (default: /tmp/docwell/docker-manager.log)
--version Show version information
--help, -h Show this help message
Examples:
$0 --list # List all stacks
$0 --start myapp # Start a stack
$0 --check # Check for updates
$0 --update-all # Update all stacks
$0 --update myapp # Update specific stack
$0 --auto --yes # Auto-update all stacks
$0 --update-all --dry-run # Preview update without executing
EOF
}
# ─── Stack Management Functions ──────────────────────────────────────────────
list_stacks_display() {
show_spinner "Loading stacks..."
local stacks
mapfile -t stacks < <(get_stacks)
hide_spinner
if [[ ${#stacks[@]} -eq 0 ]]; then
log_info "No stacks found"
return 1
fi
for stack in "${stacks[@]}"; do
local status="stopped"
if is_stack_running "$stack"; then
status="running"
fi
echo -e "$stack\t$status"
done
}
start_stack_cmd() {
local stack="$1"
local stack_path="$STACKS_DIR/$stack"
local compose_file
compose_file=$(get_compose_file "$stack_path") || {
log_error "No compose file found"
return 1
}
if run_or_dry "start $stack" docker compose -f "$stack_path/$compose_file" up -d >/dev/null 2>&1; then
log_success "Started $stack"
return 0
else
log_error "Failed to start $stack"
return 1
fi
}
stop_stack_cmd() {
local stack="$1"
local stack_path="$STACKS_DIR/$stack"
local compose_file
compose_file=$(get_compose_file "$stack_path") || {
log_error "No compose file found"
return 1
}
if run_or_dry "stop $stack" docker compose -f "$stack_path/$compose_file" down >/dev/null 2>&1; then
log_success "Stopped $stack"
return 0
else
log_error "Failed to stop $stack"
return 1
fi
}
restart_stack_cmd() {
local stack="$1"
local stack_path="$STACKS_DIR/$stack"
local compose_file
compose_file=$(get_compose_file "$stack_path") || {
log_error "No compose file found"
return 1
}
if run_or_dry "restart $stack" docker compose -f "$stack_path/$compose_file" restart >/dev/null 2>&1; then
log_success "Restarted $stack"
return 0
else
log_error "Failed to restart $stack"
return 1
fi
}
get_stack_status_cmd() {
local stack="$1"
if is_stack_running "$stack"; then
echo "running"
else
echo "stopped"
fi
}
view_logs() {
local stack="$1"
local stack_path="$STACKS_DIR/$stack"
local compose_file
compose_file=$(get_compose_file "$stack_path") || {
log_error "No compose file found"
return 1
}
docker compose -f "$stack_path/$compose_file" logs -f
}
# ─── Update Functions ────────────────────────────────────────────────────────
get_stack_images() {
local stack_path="$1"
local compose_file
compose_file=$(get_compose_file "$stack_path") || return 1
docker compose -f "$stack_path/$compose_file" config --images 2>/dev/null | grep -v '^$' || true
}
get_image_digest() {
local image="$1"
docker inspect --format='{{.Id}}' "$image" 2>/dev/null || echo ""
}
get_remote_digest() {
local image="$1"
local manifest_output
manifest_output=$(docker manifest inspect "$image" 2>/dev/null) || true
if [[ -n "$manifest_output" ]]; then
# Check for manifest list (multi-platform)
if echo "$manifest_output" | grep -q '"mediaType"[[:space:]]*:[[:space:]]*"application/vnd.docker.distribution.manifest.list.v2+json"'; then
local platform_digest
platform_digest=$(echo "$manifest_output" | \
grep -A 20 '"platform"' | \
grep -B 5 -E '"architecture"[[:space:]]*:[[:space:]]*"(amd64|x86_64)"' | \
grep '"digest"' | head -1 | cut -d'"' -f4)
if [[ -z "$platform_digest" ]]; then
platform_digest=$(echo "$manifest_output" | \
grep -A 10 '"platform"' | \
grep '"digest"' | head -1 | cut -d'"' -f4)
fi
if [[ -n "$platform_digest" ]]; then
local platform_manifest
platform_manifest=$(docker manifest inspect "$image@$platform_digest" 2>/dev/null)
if [[ -n "$platform_manifest" ]]; then
local config_digest
config_digest=$(echo "$platform_manifest" | \
grep -A 5 '"config"' | \
grep '"digest"' | head -1 | cut -d'"' -f4)
if [[ -n "$config_digest" ]]; then
echo "$config_digest"
return 0
fi
fi
fi
else
local config_digest
config_digest=$(echo "$manifest_output" | \
grep -A 5 '"config"' | \
grep '"digest"' | head -1 | cut -d'"' -f4)
if [[ -n "$config_digest" ]]; then
echo "$config_digest"
return 0
fi
fi
fi
return 1
}
check_stack_updates_display() {
local stack_path="$1"
local stack_name="$2"
local images
mapfile -t images < <(get_stack_images "$stack_path")
local has_updates=false
local temp_dir
temp_dir=$(mktemp -d /tmp/docwell_update_check.XXXXXX)
register_cleanup "rm -rf '$temp_dir'"
local pids=()
local image_index=0
for image in "${images[@]}"; do
[[ -z "$image" ]] && continue
(
local output_file="$temp_dir/image_${image_index}.out"
local status_file="$temp_dir/image_${image_index}.status"
local local_digest
local_digest=$(get_image_digest "$image")
if [[ -z "$local_digest" ]]; then
echo -e " ${YELLOW}?${NC} $image ${GRAY}[not present locally]${NC}" > "$output_file"
echo "has_update" > "$status_file"
exit 0
fi
local remote_digest
remote_digest=$(get_remote_digest "$image") || true
local used_manifest=true
if [[ -z "$remote_digest" ]]; then
used_manifest=false
if ! docker pull "$image" >/dev/null 2>&1; then
echo -e " ${RED}${NC} $image ${GRAY}[check failed]${NC}" > "$output_file"
exit 0
fi
remote_digest=$(get_image_digest "$image")
fi
if [[ -n "$local_digest" && -n "$remote_digest" && "$local_digest" != "$remote_digest" ]]; then
echo -e " ${YELLOW}${NC} $image ${GRAY}[update available]${NC}" > "$output_file"
echo "has_update" > "$status_file"
else
local method_str=""
[[ "$used_manifest" == "true" ]] && method_str=" [manifest]"
echo -e " ${GREEN}${NC} $image ${GRAY}[up to date${method_str}]${NC}" > "$output_file"
fi
) &
pids+=($!)
((image_index++))
done
for pid in "${pids[@]}"; do
wait "$pid" 2>/dev/null || true
done
for ((i=0; i<image_index; i++)); do
local output_file="$temp_dir/image_${i}.out"
local status_file="$temp_dir/image_${i}.status"
[[ -f "$output_file" ]] && cat "$output_file"
if [[ -f "$status_file" ]] && [[ "$(cat "$status_file")" == "has_update" ]]; then
has_updates=true
fi
done
[[ "$has_updates" == "true" ]]
}
check_for_updates() {
show_spinner "Scanning stacks for available updates..."
local stacks
mapfile -t stacks < <(get_stacks)
hide_spinner
local updates_available=()
local total=${#stacks[@]}
local idx=0
for stack in "${stacks[@]}"; do
((idx++))
local stack_path="$STACKS_DIR/$stack"
if ! has_compose_file "$stack_path"; then
continue
fi
echo -e "${CYAN}[$idx/$total]${NC} ${BOLD}$stack:${NC}"
if check_stack_updates_display "$stack_path" "$stack"; then
updates_available+=("$stack")
fi
done
echo
if [[ ${#updates_available[@]} -gt 0 ]]; then
log_warn "${#updates_available[@]} stack(s) have updates available:"
for stack in "${updates_available[@]}"; do
echo " - $stack"
done
else
log_success "All stacks are up to date"
fi
}
update_stack_cmd() {
local stack="$1"
local stack_path="$STACKS_DIR/$stack"
if ! has_compose_file "$stack_path"; then
log_warn "No compose file found for $stack"
return 1
fi
local compose_file
compose_file=$(get_compose_file "$stack_path")
local was_running=false
if is_stack_running "$stack"; then
was_running=true
fi
log_info "Updating $stack..."
if ! run_or_dry "pull images for $stack" docker compose -f "$stack_path/$compose_file" pull >/dev/null 2>&1; then
log_warn "Failed to pull images for $stack"
return 1
fi
if [[ "$was_running" == "true" ]]; then
if ! run_or_dry "recreate $stack" docker compose -f "$stack_path/$compose_file" up -d --force-recreate >/dev/null 2>&1; then
log_error "Failed to recreate containers for $stack"
log_warn "Images pulled but containers not recreated"
return 1
fi
log_success "Updated $stack"
else
log_info "Stack was stopped, not starting $stack"
fi
return 0
}
update_all_stacks() {
log_warn "This will pull latest images and recreate all containers"
if ! confirm "Continue?"; then
return 1
fi
local stacks
mapfile -t stacks < <(get_stacks)
local failed_stacks=()
local total=${#stacks[@]}
local idx=0
local start_time=$SECONDS
for stack in "${stacks[@]}"; do
((idx++))
echo -e "${CYAN}[$idx/$total]${NC} Processing $stack..."
if ! update_stack_cmd "$stack"; then
failed_stacks+=("$stack")
fi
done
local elapsed=$(( SECONDS - start_time ))
if [[ ${#failed_stacks[@]} -gt 0 ]]; then
log_warn "Failed to update ${#failed_stacks[@]} stack(s): ${failed_stacks[*]}"
else
log_success "All stacks updated"
fi
log_info "Completed in $(format_elapsed "$elapsed")"
}
auto_update_mode() {
log_warn "This mode will:"
echo " 1. Check for updates"
echo " 2. Update stacks with available updates"
echo
if ! confirm "Enable auto-update mode?"; then
return 1
fi
local stacks
mapfile -t stacks < <(get_stacks)
local total=${#stacks[@]}
local idx=0
local start_time=$SECONDS
for stack in "${stacks[@]}"; do
((idx++))
local stack_path="$STACKS_DIR/$stack"
if ! has_compose_file "$stack_path"; then
continue
fi
echo -e "${CYAN}[$idx/$total]${NC} Processing $stack..."
local images
mapfile -t images < <(get_stack_images "$stack_path")
local needs_update=false
for image in "${images[@]}"; do
[[ -z "$image" ]] && continue
local local_digest
local_digest=$(get_image_digest "$image")
[[ -z "$local_digest" ]] && continue
local remote_digest
remote_digest=$(get_remote_digest "$image") || true
if [[ -z "$remote_digest" ]]; then
docker pull "$image" >/dev/null 2>&1 || continue
remote_digest=$(get_image_digest "$image")
fi
if [[ -n "$local_digest" && -n "$remote_digest" && "$local_digest" != "$remote_digest" ]]; then
needs_update=true
break
fi
done
if [[ "$needs_update" == "false" ]]; then
log_success "$stack already up to date"
continue
fi
log_warn "Updates available for $stack, updating..."
if update_stack_cmd "$stack"; then
sleep 3
if is_stack_running "$stack"; then
log_success "Update successful"
else
log_error "Update may have failed, check logs"
fi
fi
echo
done
local elapsed=$(( SECONDS - start_time ))
log_success "Auto-update complete in $(format_elapsed "$elapsed")"
}
# ─── Interactive UI Functions ────────────────────────────────────────────────
interactive_stack_menu() {
local selected_stack="$1"
while true; do
clear_screen
log_header "Manage: $selected_stack"
echo -e "${GRAY}DocWell Manager v$VERSION${NC}"
echo
local status_str="${GRAY}${NC} Stopped"
if is_stack_running "$selected_stack"; then
status_str="${GREEN}${NC} Running"
fi
echo "Status: $status_str"
echo
echo "Stack Management:"
echo " 1) Start"
echo " 2) Stop"
echo " 3) Restart"
echo
echo "Updates:"
echo " 4) Update images"
echo " 5) Check for updates"
echo
echo "Other:"
echo " 6) View logs"
echo " 7) View compose file"
echo " 0) Back"
echo
read -rp "Select action: " action
case $action in
1) start_stack_cmd "$selected_stack" ;;
2) stop_stack_cmd "$selected_stack" ;;
3) restart_stack_cmd "$selected_stack" ;;
4) update_stack_cmd "$selected_stack" ;;
5)
local stack_path="$STACKS_DIR/$selected_stack"
if has_compose_file "$stack_path"; then
echo -e "${BOLD}$selected_stack:${NC}"
check_stack_updates_display "$stack_path" "$selected_stack" || true
fi
;;
6) view_logs "$selected_stack" ;;
7)
local stack_path="$STACKS_DIR/$selected_stack"
local compose_file
compose_file=$(get_compose_file "$stack_path") && \
less "$stack_path/$compose_file" || \
log_error "No compose file found"
;;
0) break ;;
*) log_error "Invalid selection" ;;
esac
echo
read -rp "Press Enter to continue..."
done
}
interactive_main_menu() {
while true; do
clear_screen
log_header "Docker Stack Manager"
echo -e "${GRAY}DocWell Manager v$VERSION${NC}"
echo
show_spinner "Loading stacks..."
local stacks
mapfile -t stacks < <(get_stacks)
hide_spinner
if [[ ${#stacks[@]} -eq 0 ]]; then
log_error "No stacks found"
exit 1
fi
echo "Available stacks:"
for i in "${!stacks[@]}"; do
local stack="${stacks[$i]}"
local status_str="${GRAY}[stopped]${NC}"
if is_stack_running "$stack"; then
status_str="${GREEN}[running]${NC}"
fi
echo -e " ${BOLD}$((i+1))${NC}) ${stack:0:30} $status_str"
done
echo
log_separator
echo -e " ${BOLD}r${NC}) Restart all"
echo -e " ${BOLD}s${NC}) Stop all"
echo -e " ${BOLD}u${NC}) Update all"
echo -e " ${BOLD}c${NC}) Check for updates"
echo -e " ${BOLD}0${NC}) Exit"
echo
read -rp "Select stack number or action: " choice
if [[ "$choice" == "0" ]] || [[ "$choice" == "q" ]] || [[ "$choice" == "quit" ]]; then
exit 0
fi
case "$choice" in
r)
for stack in "${stacks[@]}"; do
restart_stack_cmd "$stack" || true
done
read -rp "Press Enter to continue..."
;;
s)
for stack in "${stacks[@]}"; do
stop_stack_cmd "$stack" || true
done
read -rp "Press Enter to continue..."
;;
u)
update_all_stacks
read -rp "Press Enter to continue..."
;;
c)
check_for_updates
read -rp "Press Enter to continue..."
;;
*)
if [[ "$choice" =~ ^[0-9]+$ ]] && [[ "$choice" -ge 1 ]] && [[ "$choice" -le "${#stacks[@]}" ]]; then
local idx=$((choice-1))
interactive_stack_menu "${stacks[$idx]}"
else
log_error "Invalid selection"
read -rp "Press Enter to continue..."
fi
;;
esac
done
}
# ─── Main ────────────────────────────────────────────────────────────────────
main() {
parse_args "$@"
if ! check_docker; then
exit 1
fi
# Handle list only
[[ "$LIST_ONLY" == "true" ]] && { list_stacks_display; exit 0; }
# Handle stack management CLI operations
[[ -n "$START_STACK" ]] && { start_stack_cmd "$START_STACK"; exit $?; }
[[ -n "$STOP_STACK" ]] && { stop_stack_cmd "$STOP_STACK"; exit $?; }
[[ -n "$RESTART_STACK" ]] && { restart_stack_cmd "$RESTART_STACK"; exit $?; }
[[ -n "$STATUS_STACK" ]] && { get_stack_status_cmd "$STATUS_STACK"; exit 0; }
[[ -n "$LOGS_STACK" ]] && { view_logs "$LOGS_STACK"; exit $?; }
# Handle update CLI operations
[[ "$CHECK_UPDATES" == "true" ]] && { check_for_updates; exit 0; }
[[ "$UPDATE_ALL" == "true" ]] && { update_all_stacks; exit 0; }
[[ -n "$UPDATE_STACK" ]] && { update_stack_cmd "$UPDATE_STACK"; exit $?; }
[[ "$AUTO_UPDATE" == "true" ]] && { auto_update_mode; exit 0; }
# Interactive mode
interactive_main_menu
}
main "$@"