Created
December 16, 2025 00:08
-
-
Save Sly777/b1f2f9163faa3f5be7de5454ecf999be to your computer and use it in GitHub Desktop.
Update all LXC containers (Debian/Ubuntu/Alpine) on Proxmox 9.x
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Update all LXC containers (Debian/Ubuntu/Alpine) | |
| # Location: /usr/local/bin/update-all-lxc | |
| set -uo pipefail | |
| # Configuration | |
| LOG_DIR="/var/log/lxc-updates" | |
| REPORT_FILE="$LOG_DIR/update-$(date '+%Y%m%d-%H%M%S').log" | |
| LATEST_LINK="$LOG_DIR/latest.log" | |
| MAX_PARALLEL=4 | |
| # Colors for terminal output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| CYAN='\033[0;36m' | |
| NC='\033[0m' # No Color | |
| # Containers to always skip (space-separated CTIDs) | |
| SKIP_CONTAINERS="${SKIP_CONTAINERS:-}" | |
| # Default options | |
| DRY_RUN=false | |
| UPGRADE_TYPE="upgrade" | |
| AUTOREMOVE=false | |
| SEQUENTIAL=true # Default to sequential (safer) | |
| INCLUDE_OFFLINE=false | |
| QUIET=false | |
| # Counters | |
| TOTAL=0 | |
| UPDATED=0 | |
| FAILED=0 | |
| SKIPPED=0 | |
| STARTED=0 | |
| # Arrays for tracking | |
| declare -a FAILED_CTS=() | |
| declare -a UPDATED_CTS=() | |
| declare -a SKIPPED_CTS=() | |
| declare -a STARTED_CTS=() | |
| declare -a REBOOT_REQUIRED_CTS=() | |
| # Ensure log directory exists | |
| mkdir -p "$LOG_DIR" | |
| # Logging functions | |
| log() { | |
| local msg="$1" | |
| echo -e "$msg" | |
| echo -e "$msg" | sed 's/\x1b\[[0-9;]*m//g' >> "$REPORT_FILE" | |
| } | |
| log_only() { | |
| echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g' >> "$REPORT_FILE" | |
| } | |
| print_usage() { | |
| cat << EOF | |
| Usage: $(basename "$0") [OPTIONS] | |
| Update all LXC containers (Debian/Ubuntu/Alpine) on Proxmox. | |
| Options: | |
| -n, --dry-run Show what would be updated without making changes | |
| -d, --dist-upgrade Use dist-upgrade instead of upgrade (Debian/Ubuntu only) | |
| -a, --autoremove Run autoremove and autoclean after upgrade | |
| -p, --parallel Update containers in parallel (default: sequential) | |
| -o, --include-offline Include offline containers (will start them temporarily) | |
| -q, --quiet Minimal terminal output (full details in log) | |
| -h, --help Show this help message | |
| Environment Variables: | |
| SKIP_CONTAINERS Space-separated CTIDs to skip (e.g., '108 117') | |
| Reports: | |
| All reports saved to: $LOG_DIR/ | |
| Latest report linked: $LATEST_LINK | |
| Examples: | |
| $(basename "$0") # Update all running containers | |
| $(basename "$0") --dry-run # Preview updates without applying | |
| $(basename "$0") --include-offline # Include stopped containers | |
| $(basename "$0") -p -a # Parallel with cleanup | |
| SKIP_CONTAINERS="108 117" $(basename "$0") # Skip specific containers | |
| EOF | |
| } | |
| parse_args() { | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -n|--dry-run) | |
| DRY_RUN=true | |
| shift | |
| ;; | |
| -d|--dist-upgrade) | |
| UPGRADE_TYPE="dist-upgrade" | |
| shift | |
| ;; | |
| -a|--autoremove) | |
| AUTOREMOVE=true | |
| shift | |
| ;; | |
| -p|--parallel) | |
| SEQUENTIAL=false | |
| shift | |
| ;; | |
| -o|--include-offline) | |
| INCLUDE_OFFLINE=true | |
| shift | |
| ;; | |
| -q|--quiet) | |
| QUIET=true | |
| shift | |
| ;; | |
| -h|--help) | |
| print_usage | |
| exit 0 | |
| ;; | |
| *) | |
| echo "Unknown option: $1" | |
| print_usage | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| } | |
| get_container_name() { | |
| local ctid=$1 | |
| pct list | awk -v id="$ctid" '$1==id {print $3}' | |
| } | |
| get_container_status() { | |
| local ctid=$1 | |
| pct list | awk -v id="$ctid" '$1==id {print $2}' | |
| } | |
| detect_os_type() { | |
| local ctid=$1 | |
| # Check for apt (Debian/Ubuntu) | |
| if pct exec "$ctid" -- which apt-get &>/dev/null 2>&1; then | |
| echo "debian" | |
| return 0 | |
| fi | |
| # Check for apk (Alpine) | |
| if pct exec "$ctid" -- which apk &>/dev/null 2>&1; then | |
| echo "alpine" | |
| return 0 | |
| fi | |
| # Check for dnf/yum (RHEL/Fedora) - future support | |
| if pct exec "$ctid" -- which dnf &>/dev/null 2>&1; then | |
| echo "rhel" | |
| return 0 | |
| fi | |
| echo "unknown" | |
| } | |
| check_reboot_required() { | |
| local ctid=$1 | |
| pct exec "$ctid" -- test -f /var/run/reboot-required 2>/dev/null && return 0 || return 1 | |
| } | |
| start_container() { | |
| local ctid=$1 | |
| local name=$2 | |
| log " ${CYAN}Starting container...${NC}" | |
| if pct start "$ctid" &>/dev/null; then | |
| # Wait for container to be ready | |
| local retries=15 | |
| while [[ $retries -gt 0 ]]; do | |
| if pct exec "$ctid" -- true &>/dev/null; then | |
| STARTED_CTS+=("$ctid:$name") | |
| ((STARTED++)) | |
| return 0 | |
| fi | |
| sleep 1 | |
| ((retries--)) | |
| done | |
| log " ${RED}Container started but not responding${NC}" | |
| return 1 | |
| else | |
| log " ${RED}Failed to start container${NC}" | |
| return 1 | |
| fi | |
| } | |
| stop_container() { | |
| local ctid=$1 | |
| log " ${CYAN}Stopping container (was offline)...${NC}" | |
| pct stop "$ctid" &>/dev/null || true | |
| } | |
| get_debian_upgradable() { | |
| local ctid=$1 | |
| local count | |
| count=$(pct exec "$ctid" -- sh -c "apt list --upgradable 2>/dev/null | grep -v '^Listing' | wc -l" 2>/dev/null || echo "0") | |
| echo "$((count + 0))" | |
| } | |
| get_alpine_upgradable() { | |
| local ctid=$1 | |
| local count | |
| count=$(pct exec "$ctid" -- sh -c "apk list -u 2>/dev/null | wc -l" 2>/dev/null || echo "0") | |
| echo "$((count + 0))" | |
| } | |
| update_debian() { | |
| local ctid=$1 | |
| local name=$2 | |
| local upgradable=0 | |
| if $DRY_RUN; then | |
| log " ${YELLOW}[DRY RUN] Would run: apt-get update && apt-get $UPGRADE_TYPE${NC}" | |
| pct exec "$ctid" -- apt-get update -qq &>/dev/null || true | |
| upgradable=$(get_debian_upgradable "$ctid") | |
| if [[ $upgradable -gt 0 ]]; then | |
| log " ${GREEN}$upgradable package(s) available for upgrade:${NC}" | |
| pct exec "$ctid" -- apt list --upgradable 2>/dev/null | grep -v "^Listing" | head -10 | while read -r line; do | |
| log " - $line" | |
| done | |
| local remaining=$((upgradable - 10)) | |
| [[ $remaining -gt 0 ]] && log " ... and $remaining more" | |
| else | |
| log " ${GREEN}Already up to date${NC}" | |
| fi | |
| return 0 | |
| fi | |
| # Actual update | |
| log " Updating package lists..." | |
| if ! pct exec "$ctid" -- apt-get update -qq &>/dev/null; then | |
| log " ${RED}Failed to update package lists${NC}" | |
| return 1 | |
| fi | |
| upgradable=$(get_debian_upgradable "$ctid") | |
| if [[ $upgradable -eq 0 ]]; then | |
| log " ${GREEN}Already up to date${NC}" | |
| UPDATED_CTS+=("$ctid:$name:up-to-date") | |
| return 0 | |
| fi | |
| log " Running $UPGRADE_TYPE ($upgradable packages)..." | |
| log_only " Packages to upgrade:" | |
| pct exec "$ctid" -- apt list --upgradable 2>/dev/null | grep -v "^Listing" | while read -r line; do | |
| log_only " - $line" | |
| done | |
| if ! pct exec "$ctid" -- apt-get "$UPGRADE_TYPE" -y -qq &>/dev/null; then | |
| log " ${RED}Failed to upgrade packages${NC}" | |
| return 1 | |
| fi | |
| if $AUTOREMOVE; then | |
| log " Cleaning up..." | |
| pct exec "$ctid" -- apt-get autoremove -y -qq &>/dev/null || true | |
| pct exec "$ctid" -- apt-get autoclean -qq &>/dev/null || true | |
| fi | |
| log " ${GREEN}✓ Updated successfully ($upgradable packages)${NC}" | |
| UPDATED_CTS+=("$ctid:$name:$upgradable-packages") | |
| # Check if reboot is required | |
| if check_reboot_required "$ctid"; then | |
| log " ${YELLOW}⚠ Reboot required${NC}" | |
| REBOOT_REQUIRED_CTS+=("$ctid:$name") | |
| fi | |
| return 0 | |
| } | |
| update_alpine() { | |
| local ctid=$1 | |
| local name=$2 | |
| local upgradable=0 | |
| if $DRY_RUN; then | |
| log " ${YELLOW}[DRY RUN] Would run: apk update && apk upgrade${NC}" | |
| pct exec "$ctid" -- apk update &>/dev/null || true | |
| upgradable=$(get_alpine_upgradable "$ctid") | |
| if [[ $upgradable -gt 0 ]]; then | |
| log " ${GREEN}$upgradable package(s) available for upgrade:${NC}" | |
| pct exec "$ctid" -- apk list -u 2>/dev/null | head -10 | while read -r line; do | |
| log " - $line" | |
| done | |
| local remaining=$((upgradable - 10)) | |
| [[ $remaining -gt 0 ]] && log " ... and $remaining more" | |
| else | |
| log " ${GREEN}Already up to date${NC}" | |
| fi | |
| return 0 | |
| fi | |
| # Actual update | |
| log " Updating package index..." | |
| if ! pct exec "$ctid" -- apk update &>/dev/null; then | |
| log " ${RED}Failed to update package index${NC}" | |
| return 1 | |
| fi | |
| upgradable=$(get_alpine_upgradable "$ctid") | |
| if [[ $upgradable -eq 0 ]]; then | |
| log " ${GREEN}Already up to date${NC}" | |
| UPDATED_CTS+=("$ctid:$name:up-to-date") | |
| return 0 | |
| fi | |
| log " Running apk upgrade ($upgradable packages)..." | |
| log_only " Packages to upgrade:" | |
| pct exec "$ctid" -- apk list -u 2>/dev/null | while read -r line; do | |
| log_only " - $line" | |
| done | |
| if ! pct exec "$ctid" -- apk upgrade --no-cache &>/dev/null; then | |
| log " ${RED}Failed to upgrade packages${NC}" | |
| return 1 | |
| fi | |
| if $AUTOREMOVE; then | |
| log " Cleaning up..." | |
| pct exec "$ctid" -- apk cache clean &>/dev/null || true | |
| fi | |
| log " ${GREEN}✓ Updated successfully ($upgradable packages)${NC}" | |
| UPDATED_CTS+=("$ctid:$name:$upgradable-packages") | |
| return 0 | |
| } | |
| update_container() { | |
| local ctid=$1 | |
| local name | |
| local status | |
| local os_type | |
| local was_offline=false | |
| name=$(get_container_name "$ctid") | |
| status=$(get_container_status "$ctid") | |
| # Check if container should be skipped | |
| if [[ " $SKIP_CONTAINERS " =~ " $ctid " ]]; then | |
| log "${YELLOW}[$ctid] $name - SKIPPED (in skip list)${NC}" | |
| SKIPPED_CTS+=("$ctid:$name:skip-list") | |
| ((SKIPPED++)) | |
| return 0 | |
| fi | |
| # Handle offline containers | |
| if [[ "$status" != "running" ]]; then | |
| if $INCLUDE_OFFLINE; then | |
| log "${BLUE}────────────────────────────────────────${NC}" | |
| log "${BLUE}[$ctid] $name ${YELLOW}(was offline)${NC}" | |
| log "${BLUE}────────────────────────────────────────${NC}" | |
| was_offline=true | |
| if ! $DRY_RUN; then | |
| if ! start_container "$ctid" "$name"; then | |
| FAILED_CTS+=("$ctid:$name:failed-to-start") | |
| ((FAILED++)) | |
| return 1 | |
| fi | |
| else | |
| log " ${YELLOW}Would start container, update, then stop${NC}" | |
| fi | |
| else | |
| if ! $QUIET; then | |
| log "${YELLOW}[$ctid] $name - SKIPPED (offline)${NC}" | |
| fi | |
| log_only "[$ctid] $name - SKIPPED (offline)" | |
| SKIPPED_CTS+=("$ctid:$name:offline") | |
| ((SKIPPED++)) | |
| return 0 | |
| fi | |
| else | |
| log "${BLUE}────────────────────────────────────────${NC}" | |
| log "${BLUE}[$ctid] $name${NC}" | |
| log "${BLUE}────────────────────────────────────────${NC}" | |
| fi | |
| # Detect OS type | |
| os_type=$(detect_os_type "$ctid") | |
| log " ${CYAN}Detected OS: $os_type${NC}" | |
| case "$os_type" in | |
| debian) | |
| if update_debian "$ctid" "$name"; then | |
| ((UPDATED++)) | |
| else | |
| FAILED_CTS+=("$ctid:$name:update-failed") | |
| ((FAILED++)) | |
| fi | |
| ;; | |
| alpine) | |
| if update_alpine "$ctid" "$name"; then | |
| ((UPDATED++)) | |
| else | |
| FAILED_CTS+=("$ctid:$name:update-failed") | |
| ((FAILED++)) | |
| fi | |
| ;; | |
| *) | |
| log " ${YELLOW}Unsupported OS type - skipping${NC}" | |
| SKIPPED_CTS+=("$ctid:$name:unsupported-os") | |
| ((SKIPPED++)) | |
| ;; | |
| esac | |
| # Stop container if it was originally offline | |
| # Stop container if it was originally offline | |
| if [[ "$was_offline" == true ]] && ! $DRY_RUN; then | |
| stop_container "$ctid" | |
| fi | |
| return 0 | |
| } | |
| print_summary() { | |
| log "" | |
| log "${BLUE}════════════════════════════════════════════════════════════${NC}" | |
| log "${BLUE} SUMMARY${NC}" | |
| log "${BLUE}════════════════════════════════════════════════════════════${NC}" | |
| log "" | |
| log " Run completed: $(date '+%Y-%m-%d %H:%M:%S')" | |
| log " Mode: $( $DRY_RUN && echo 'DRY RUN' || echo 'LIVE' )" | |
| log " Upgrade type: $UPGRADE_TYPE" | |
| log " Include offline: $INCLUDE_OFFLINE" | |
| log " Execution: $( $SEQUENTIAL && echo 'Sequential' || echo 'Parallel' )" | |
| log "" | |
| log " ${CYAN}Total containers: $TOTAL${NC}" | |
| log " ${GREEN}Updated: $UPDATED${NC}" | |
| log " ${YELLOW}Skipped: $SKIPPED${NC}" | |
| log " ${RED}Failed: $FAILED${NC}" | |
| if [[ $STARTED -gt 0 ]]; then | |
| log " ${CYAN}Temporarily started: $STARTED${NC}" | |
| fi | |
| # Detailed breakdown | |
| if [[ ${#UPDATED_CTS[@]} -gt 0 ]]; then | |
| log "" | |
| log " ${GREEN}Updated containers:${NC}" | |
| for entry in "${UPDATED_CTS[@]}"; do | |
| IFS=':' read -r ctid name info <<< "$entry" | |
| log " ✓ [$ctid] $name ${CYAN}($info)${NC}" | |
| done | |
| fi | |
| if [[ ${#SKIPPED_CTS[@]} -gt 0 ]]; then | |
| log "" | |
| log " ${YELLOW}Skipped containers:${NC}" | |
| for entry in "${SKIPPED_CTS[@]}"; do | |
| IFS=':' read -r ctid name reason <<< "$entry" | |
| log " - [$ctid] $name ${CYAN}($reason)${NC}" | |
| done | |
| fi | |
| if [[ ${#FAILED_CTS[@]} -gt 0 ]]; then | |
| log "" | |
| log " ${RED}Failed containers:${NC}" | |
| for entry in "${FAILED_CTS[@]}"; do | |
| IFS=':' read -r ctid name reason <<< "$entry" | |
| log " ✗ [$ctid] $name ${CYAN}($reason)${NC}" | |
| done | |
| fi | |
| if [[ ${#REBOOT_REQUIRED_CTS[@]} -gt 0 ]]; then | |
| log "" | |
| log " ${YELLOW}⚠ Reboot required:${NC}" | |
| for entry in "${REBOOT_REQUIRED_CTS[@]}"; do | |
| IFS=':' read -r ctid name <<< "$entry" | |
| log " ⚠ [$ctid] $name" | |
| done | |
| fi | |
| log "" | |
| log " ${CYAN}Report saved to: $REPORT_FILE${NC}" | |
| log "" | |
| } | |
| cleanup_old_logs() { | |
| # Keep only last 30 reports | |
| local count | |
| count=$(find "$LOG_DIR" -name "update-*.log" -type f 2>/dev/null | wc -l) | |
| if [[ $count -gt 30 ]]; then | |
| find "$LOG_DIR" -name "update-*.log" -type f -printf '%T+ %p\n' 2>/dev/null | \ | |
| sort | head -n $((count - 30)) | cut -d' ' -f2- | xargs rm -f 2>/dev/null | |
| fi | |
| } | |
| main() { | |
| parse_args "$@" | |
| # Initialize report file | |
| cat > "$REPORT_FILE" << EOF | |
| ================================================================================ | |
| LXC Container Update Report | |
| ================================================================================ | |
| Generated: $(date '+%Y-%m-%d %H:%M:%S') | |
| Hostname: $(hostname) | |
| Mode: $( $DRY_RUN && echo 'DRY RUN' || echo 'LIVE' ) | |
| Options: upgrade=$UPGRADE_TYPE autoremove=$AUTOREMOVE sequential=$SEQUENTIAL include-offline=$INCLUDE_OFFLINE | |
| Skip list: ${SKIP_CONTAINERS:-none} | |
| ================================================================================ | |
| EOF | |
| log "${BLUE}════════════════════════════════════════════════════════════${NC}" | |
| log "${BLUE} LXC Container Update Script${NC}" | |
| log "${BLUE} $(date '+%Y-%m-%d %H:%M:%S')${NC}" | |
| log "${BLUE}════════════════════════════════════════════════════════════${NC}" | |
| log "" | |
| if $DRY_RUN; then | |
| log "${YELLOW}*** DRY RUN MODE - No changes will be made ***${NC}" | |
| log "" | |
| fi | |
| # Get list of LXC containers only (not VMs) | |
| # pct list only shows LXC containers, not VMs | |
| local container_list | |
| if $INCLUDE_OFFLINE; then | |
| container_list=$(pct list 2>/dev/null | awk 'NR>1 {print $1}' | sort -n) | |
| else | |
| container_list=$(pct list 2>/dev/null | awk 'NR>1 && $2=="running" {print $1}' | sort -n) | |
| fi | |
| if [[ -z "$container_list" ]]; then | |
| log "${YELLOW}No LXC containers found.${NC}" | |
| log "" | |
| log "Note: This script only updates LXC containers, not VMs." | |
| exit 0 | |
| fi | |
| # Count total containers | |
| TOTAL=$(echo "$container_list" | wc -w) | |
| if $INCLUDE_OFFLINE; then | |
| log "${GREEN}Mode: Including offline containers${NC}" | |
| else | |
| log "${GREEN}Mode: Running containers only${NC}" | |
| fi | |
| log "${GREEN}Found $TOTAL LXC container(s) to process${NC}" | |
| log "" | |
| # Process containers | |
| if $SEQUENTIAL; then | |
| log "${CYAN}Processing sequentially...${NC}" | |
| log "" | |
| for ctid in $container_list; do | |
| update_container "$ctid" | |
| done | |
| else | |
| log "${CYAN}Processing in parallel (max $MAX_PARALLEL concurrent)...${NC}" | |
| log "" | |
| local pids=() | |
| for ctid in $container_list; do | |
| # Start update in background | |
| update_container "$ctid" & | |
| pids+=($!) | |
| # Limit parallel jobs | |
| while [[ ${#pids[@]} -ge $MAX_PARALLEL ]]; do | |
| # Wait for any job to finish | |
| local new_pids=() | |
| for pid in "${pids[@]}"; do | |
| if kill -0 "$pid" 2>/dev/null; then | |
| new_pids+=("$pid") | |
| fi | |
| done | |
| pids=("${new_pids[@]}") | |
| [[ ${#pids[@]} -ge $MAX_PARALLEL ]] && sleep 1 | |
| done | |
| done | |
| # Wait for remaining jobs | |
| for pid in "${pids[@]}"; do | |
| wait "$pid" 2>/dev/null || true | |
| done | |
| fi | |
| # Print summary | |
| print_summary | |
| # Update latest symlink | |
| ln -sf "$REPORT_FILE" "$LATEST_LINK" | |
| # Cleanup old logs | |
| cleanup_old_logs | |
| # Exit with appropriate code | |
| if [[ $FAILED -gt 0 ]]; then | |
| exit 1 | |
| fi | |
| exit 0 | |
| } | |
| # Run main function | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment