#!/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/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 "$@"