diff --git a/auto-updates-TESTING.sh b/auto-updates-TESTING.sh index 50e8f9f..6899e62 100644 --- a/auto-updates-TESTING.sh +++ b/auto-updates-TESTING.sh @@ -2,479 +2,263 @@ # # setup-auto-updates.sh # -# Universal Debian/Ubuntu unattended-upgrades setup for mixed environments: -# - Robust repository detection (handles stable/codename/future releases) -# - Works across 30+ VMs/LXCs with different configurations -# - Future-proof for Trixie and beyond -# - Fast and reliable detection algorithms -# - Validates configuration before completion +# Universal unattended-upgrades setup for Debian & Ubuntu (VMs, LXCs, mixed fleets). +# - Derives Allowed-Origins from `apt-cache policy` to handle "stable" vs codename cleanly +# - Supports Debian (stable/testing) incl. security pockets; forward-compatible with Trixie +# - Supports Ubuntu (noble, etc.) incl. -updates, -security, and optional ESM pockets +# - Works with classic sources.list and deb822 `.sources` files +# - Validates config with a dry run and enables systemd timers when available +# +# Notes: +# * Backports are intentionally NOT auto-updated. +# * On minimal containers without systemd, the APT::Periodic cron path is used. set -Eeuo pipefail trap 'echo "[ERROR] Line $LINENO failed" >&2' ERR -REBOOT_TIME="04:00" -TARGET_LOCALE="en_US.UTF-8" +REBOOT_TIME="${REBOOT_TIME:-04:00}" -# Repository detection results (global variables) -declare -g MAIN_ARCHIVE="" -declare -g UPDATES_ARCHIVE="" -declare -g SECURITY_ARCHIVE="" -declare -g DETECTED_ORIGINS=() +OS="" +CODENAME="" +DETECTED_ORIGINS=() # e.g., ("Debian:bookworm" "Debian:bookworm-security" ...) +POLICY_PAIRS=() # raw pairs as "Origin:Archive" extracted from apt-cache require_root() { if [[ $EUID -ne 0 ]]; then - echo "[ERROR] Run as root (sudo)" >&2 + echo "[ERROR] Run as root (sudo)." >&2 exit 1 fi } detect_os() { if [[ ! -f /etc/os-release ]]; then - echo "[ERROR] /etc/os-release not found" >&2 + echo "[ERROR] /etc/os-release not found." >&2 exit 1 fi + # shellcheck disable=SC1091 . /etc/os-release - case "$ID" in - ubuntu) OS="ubuntu" ;; + case "${ID,,}" in debian) OS="debian" ;; - *) echo "[ERROR] Unsupported OS: $ID" >&2; exit 1 ;; - esac - CODENAME="${VERSION_CODENAME:-$UBUNTU_CODENAME}" - VERSION_ID="${VERSION_ID:-unknown}" - echo "[INFO] Detected: $PRETTY_NAME ($OS / $CODENAME)" -} - -# Extract repository info from sources -parse_apt_sources() { - local sources_data="" - - # Collect all active deb lines from sources.list and sources.list.d - if [[ -f /etc/apt/sources.list ]]; then - sources_data+=$(grep -E "^deb\s+" /etc/apt/sources.list 2>/dev/null || true) - sources_data+=$'\n' - fi - - if [[ -d /etc/apt/sources.list.d ]]; then - sources_data+=$(find /etc/apt/sources.list.d -name "*.list" -exec grep -E "^deb\s+" {} \; 2>/dev/null || true) - fi - - echo "$sources_data" -} - -# Detect repositories for Ubuntu -detect_ubuntu_repos() { - echo "[INFO] Detecting Ubuntu repository configuration..." - - local sources_data - sources_data=$(parse_apt_sources) - - # Ubuntu typically uses consistent codename-based archives - MAIN_ARCHIVE="$CODENAME" - UPDATES_ARCHIVE="${CODENAME}-updates" - SECURITY_ARCHIVE="${CODENAME}-security" - - # Verify these exist in sources - if echo "$sources_data" | grep -q "ubuntu.*$CODENAME\s"; then - echo "[INFO] Found Ubuntu $CODENAME repositories" - - # Check for ESM (Extended Security Maintenance) repos for LTS versions - local esm_origins=() - if echo "$sources_data" | grep -q "esm.ubuntu.com"; then - esm_origins+=("Ubuntu ESM:${CODENAME}-infra-security") - esm_origins+=("Ubuntu ESM Apps:${CODENAME}-apps-security") - echo "[INFO] Found Ubuntu ESM repositories" - fi - - # Build origins array - DETECTED_ORIGINS=( - "Ubuntu:${MAIN_ARCHIVE}" - "Ubuntu:${UPDATES_ARCHIVE}" - "Ubuntu:${SECURITY_ARCHIVE}" - ) - - # Add ESM if present - DETECTED_ORIGINS+=("${esm_origins[@]}") - - else - echo "[WARN] Standard Ubuntu repositories not found, using defaults" - fi -} - -# Detect repositories for Debian with comprehensive fallback logic -detect_debian_repos() { - echo "[INFO] Detecting Debian repository configuration..." - - # Update package lists for accurate detection - apt-get update -qq >/dev/null 2>&1 || true - - local sources_data - sources_data=$(parse_apt_sources) - - # Strategy 1: Parse sources.list for archive names - local candidate_archives=() - - # Extract archives from main Debian repos (not security) - while IFS= read -r line; do - if [[ "$line" =~ deb[[:space:]]+[^[:space:]]+[[:space:]]+([^[:space:]]+) ]] && - [[ "$line" == *"debian"* ]] && - [[ "$line" != *"security"* ]]; then - local archive="${BASH_REMATCH[1]}" - if [[ "$archive" =~ ^(stable|testing|unstable|[a-z]+)$ ]]; then - candidate_archives+=("$archive") - fi - fi - done <<< "$sources_data" - - # Strategy 2: Check apt-cache policy for active origins - local policy_archives=() - if command -v apt-cache >/dev/null; then - while IFS= read -r line; do - if [[ "$line" =~ o=Debian,a=([^,]+) ]]; then - local archive="${BASH_REMATCH[1]}" - policy_archives+=("$archive") - fi - done < <(apt-cache policy 2>/dev/null | grep -E "^\s*[0-9]+\s+.*o=Debian,a=" || true) - fi - - # Strategy 3: Test which archives actually have packages - local working_archives=() - local test_archives=("${candidate_archives[@]}" "${policy_archives[@]}" "stable" "$CODENAME") - - # Remove duplicates and test each - local unique_archives=($(printf '%s\n' "${test_archives[@]}" | sort -u)) - - for archive in "${unique_archives[@]}"; do - if [[ -z "$archive" ]]; then continue; fi - - # Test if this archive has packages available - if apt-cache policy 2>/dev/null | grep -q "o=Debian,a=$archive" || - echo "$sources_data" | grep -q "debian.*$archive"; then - working_archives+=("$archive") - echo "[DEBUG] Found working archive: $archive" - fi - done - - # Strategy 4: Choose the best archive - if [[ ${#working_archives[@]} -gt 0 ]]; then - # Prefer stable > codename > testing > others - for preferred in "stable" "$CODENAME" "testing"; do - for archive in "${working_archives[@]}"; do - if [[ "$archive" == "$preferred" ]]; then - MAIN_ARCHIVE="$archive" - break 2 - fi - done - done - - # If no preferred found, use first working archive - if [[ -z "$MAIN_ARCHIVE" ]]; then - MAIN_ARCHIVE="${working_archives[0]}" - fi - else - # Fallback to codename - echo "[WARN] No working archives detected, using codename: $CODENAME" - MAIN_ARCHIVE="$CODENAME" - fi - - # Set related archives - UPDATES_ARCHIVE="${MAIN_ARCHIVE}-updates" - SECURITY_ARCHIVE="${MAIN_ARCHIVE}-security" - - # Special handling for suite names - if [[ "$MAIN_ARCHIVE" == "testing" ]]; then - SECURITY_ARCHIVE="testing-security" - elif [[ "$MAIN_ARCHIVE" == "unstable" ]]; then - UPDATES_ARCHIVE="" # Unstable doesn't have updates - SECURITY_ARCHIVE="" # Unstable doesn't have security - fi - - # Verify security archive exists - if [[ -n "$SECURITY_ARCHIVE" ]]; then - if ! (apt-cache policy 2>/dev/null | grep -q "o=Debian,a=$SECURITY_ARCHIVE" || - echo "$sources_data" | grep -q "security.*$SECURITY_ARCHIVE"); then - echo "[WARN] Security archive $SECURITY_ARCHIVE not found" - # Try alternative security archive naming - if [[ "$MAIN_ARCHIVE" != "stable" ]] && - (apt-cache policy 2>/dev/null | grep -q "o=Debian,a=stable-security" || - echo "$sources_data" | grep -q "security.*stable-security"); then - SECURITY_ARCHIVE="stable-security" - echo "[INFO] Using stable-security instead" - fi - fi - fi - - # Build origins array - DETECTED_ORIGINS=("Debian:${MAIN_ARCHIVE}") - - if [[ -n "$UPDATES_ARCHIVE" ]]; then - DETECTED_ORIGINS+=("Debian:${UPDATES_ARCHIVE}") - fi - - if [[ -n "$SECURITY_ARCHIVE" ]]; then - DETECTED_ORIGINS+=("Debian-Security:${SECURITY_ARCHIVE}") - fi - - echo "[INFO] Debian archives selected:" - echo " Main: $MAIN_ARCHIVE" - [[ -n "$UPDATES_ARCHIVE" ]] && echo " Updates: $UPDATES_ARCHIVE" - [[ -n "$SECURITY_ARCHIVE" ]] && echo " Security: $SECURITY_ARCHIVE" -} - -# Main repository detection function -detect_repositories() { - echo "[INFO] Detecting repository configuration for $OS..." - - case "$OS" in - ubuntu) - detect_ubuntu_repos - ;; - debian) - detect_debian_repos - ;; + ubuntu) OS="ubuntu" ;; *) - echo "[ERROR] Unsupported OS for repository detection: $OS" - exit 1 + # Fall back via ID_LIKE if helpful + if [[ "${ID_LIKE:-}" =~ debian ]]; then + OS="debian" + else + echo "[ERROR] Unsupported OS: ${ID}." >&2 + exit 1 + fi ;; esac - - if [[ ${#DETECTED_ORIGINS[@]} -eq 0 ]]; then - echo "[ERROR] No valid repository origins detected!" - exit 1 + + # CODENAME detection with fallbacks + CODENAME="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}" + if [[ -z "$CODENAME" ]] && command -v lsb_release >/dev/null 2>&1; then + CODENAME="$(lsb_release -cs 2>/dev/null || true)" fi - - echo "[INFO] Final detected origins:" + if [[ -z "$CODENAME" ]]; then + # Last-resort: attempt to infer from apt-cache (n= field) + CODENAME="$(apt-cache policy 2>/dev/null | sed -n 's/.*n=\([^,]*\).*/\1/p' | grep -E '^[a-z]+$' | head -n1 || true)" + fi + + echo "[INFO] Detected OS: $OS codename: ${CODENAME:-unknown}" +} + +apt_refresh_and_install() { + echo "[INFO] Updating APT cache…" + apt-get update -qq || true + echo "[INFO] Installing unattended-upgrades…" + DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades >/dev/null +} + +extract_policy_pairs() { + # Extract distinct Origin:Archive pairs that APT knows about. + # Examples: "Debian:bookworm", "Debian:stable-security", "Ubuntu:noble-updates", "UbuntuESM:noble-infra-security" + mapfile -t POLICY_PAIRS < <( + apt-cache policy 2>/dev/null \ + | sed -n 's/.*o=\([^,]*\),a=\([^,]*\).*/\1:\2/p' \ + | awk 'NF' \ + | sort -u + ) +} + +build_allowed_origins() { + DETECTED_ORIGINS=() + + # Helper: add if present in POLICY_PAIRS + have_pair() { + local needle="$1" + printf '%s\n' "${POLICY_PAIRS[@]}" | grep -Fxq -- "$needle" + } + add_if_present() { + local pair="$1" + if have_pair "$pair"; then + DETECTED_ORIGINS+=("$pair") + fi + } + + # Debian: prefer codename pockets; also include stable/testing aliases if present. + if [[ "$OS" == "debian" ]]; then + # Codename-based pockets (typical) + add_if_present "Debian:${CODENAME}" + add_if_present "Debian:${CODENAME}-updates" + add_if_present "Debian:${CODENAME}-security" + + # Suite aliases (if admin uses 'stable' or 'testing' in sources) + add_if_present "Debian:stable" + add_if_present "Debian:stable-updates" + add_if_present "Debian:stable-security" + add_if_present "Debian:testing" + add_if_present "Debian:testing-security" + + # If nothing matched (e.g., first run, cache stale), fall back to sensible defaults + if [[ ${#DETECTED_ORIGINS[@]} -eq 0 ]]; then + DETECTED_ORIGINS=( + "Debian:${CODENAME}" + "Debian:${CODENAME}-updates" + "Debian:${CODENAME}-security" + ) + fi + fi + + # Ubuntu: pockets + optional ESM (if enabled on the host) + if [[ "$OS" == "ubuntu" ]]; then + add_if_present "Ubuntu:${CODENAME}" + add_if_present "Ubuntu:${CODENAME}-updates" + add_if_present "Ubuntu:${CODENAME}-security" + + # ESM origins (if the machine is attached to Ubuntu Pro/Advantage) + add_if_present "UbuntuESM:${CODENAME}-infra-security" + add_if_present "UbuntuESMApps:${CODENAME}-apps-security" + + if [[ ${#DETECTED_ORIGINS[@]} -eq 0 ]]; then + # As with Debian, fall back to standard pockets + DETECTED_ORIGINS=( + "Ubuntu:${CODENAME}" + "Ubuntu:${CODENAME}-updates" + "Ubuntu:${CODENAME}-security" + ) + fi + fi + + echo "[INFO] Allowed-Origins to be written:" printf ' %s\n' "${DETECTED_ORIGINS[@]}" } -fix_locale() { - echo "[INFO] Ensuring locale ${TARGET_LOCALE} is generated…" - apt-get update -y >/dev/null 2>&1 || true - DEBIAN_FRONTEND=noninteractive apt-get install -y locales >/dev/null 2>&1 || true - - if [[ -f /etc/locale.gen ]]; then - if grep -qE "^\s*#\s*${TARGET_LOCALE}\s+UTF-8" /etc/locale.gen; then - sed -ri "s/^\s*#\s*(${TARGET_LOCALE}\s+UTF-8)/\1/" /etc/locale.gen - elif ! grep -qE "^\s*${TARGET_LOCALE}\s+UTF-8" /etc/locale.gen; then - echo "${TARGET_LOCALE} UTF-8" >> /etc/locale.gen - fi - else - echo "${TARGET_LOCALE} UTF-8" > /etc/locale.gen - fi - - locale-gen >/dev/null 2>&1 - update-locale LANG=${TARGET_LOCALE} LC_ALL=${TARGET_LOCALE} - export LANG=${TARGET_LOCALE} - export LC_ALL=${TARGET_LOCALE} -} - -install_unattended() { - echo "[INFO] Updating apt cache…" - apt update - echo "[INFO] Installing unattended-upgrades…" - DEBIAN_FRONTEND=noninteractive apt install -y unattended-upgrades -} - -enable_unattended() { - echo "[INFO] Enabling unattended-upgrades via debconf…" - echo 'unattended-upgrades unattended-upgrades/enable_auto_updates boolean true' | debconf-set-selections - DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive unattended-upgrades -} - -# Generate origins configuration from detected origins -write_50_conf_universal() { - local origins_config="" - for origin in "${DETECTED_ORIGINS[@]}"; do - origins_config+=" \"${origin}\";"$'\n' +write_50unattended() { + local origins_block="" + for o in "${DETECTED_ORIGINS[@]}"; do + origins_block+=" \"${o}\";\n" done - + cat > /etc/apt/apt.conf.d/50unattended-upgrades < /etc/apt/apt.conf.d/20auto-upgrades <<'EOF' APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Download-Upgradeable-Packages "1"; -APT::Periodic::AutocleanInterval "7"; APT::Periodic::Unattended-Upgrade "1"; +APT::Periodic::AutocleanInterval "7"; EOF } -# Comprehensive validation with detailed error diagnosis -validate_config() { - echo "[INFO] Validating unattended-upgrades configuration..." - - local temp_log="/tmp/unattended-upgrades-validation-$.log" - - # Test the configuration - if ! timeout 60 unattended-upgrades --dry-run --debug > "$temp_log" 2>&1; then - echo "[ERROR] Configuration validation failed (timeout or error)!" - echo "[ERROR] Last 20 lines of debug output:" - tail -n 20 "$temp_log" - rm -f "$temp_log" +enable_timers_if_systemd() { + if command -v systemctl >/dev/null 2>&1; then + # Not all containers have systemd; ignore failures. + systemctl enable --now apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true + fi +} + +validate_with_dryrun() { + echo "[INFO] Validating unattended-upgrades with a dry run…" + local log="/tmp/unattended-upgrades-dryrun.$$" + if ! timeout 90 unattended-upgrades --dry-run --debug >"$log" 2>&1; then + echo "[WARN] Dry run timed out or failed; see $log" return 1 fi - - # Check for critical errors - local critical_errors=( - "not enough values to unpack" - "Unable to parse.*Allowed-Origins" - "ValueError:" - "AttributeError:" - "ImportError:" - ) - - for error_pattern in "${critical_errors[@]}"; do - if grep -qi "$error_pattern" "$temp_log"; then - echo "[ERROR] Critical configuration error detected:" - grep -i "$error_pattern" "$temp_log" || true - rm -f "$temp_log" - return 1 - fi - done - - # Analyze the results - if grep -q "Allowed origins are:" "$temp_log"; then - local allowed_origins - allowed_origins=$(grep "Allowed origins are:" "$temp_log" | head -n1) - echo "[INFO] $allowed_origins" - - # Convert our expected origins to the debug output format for comparison - # "Debian:stable" becomes "o=Debian,a=stable" - # "Debian-Security:stable-security" becomes "o=Debian-Security,a=stable-security" - local configs_working=0 - for origin in "${DETECTED_ORIGINS[@]}"; do - if [[ "$origin" =~ ^([^:]+):(.+)$ ]]; then - local expected_format="o=${BASH_REMATCH[1]},a=${BASH_REMATCH[2]}" - if [[ "$allowed_origins" == *"$expected_format"* ]]; then - ((configs_working++)) - echo "[DEBUG] Found expected origin: $expected_format" + + # Show recognized allowed origins summary + grep -E "Allowed origins are" "$log" | head -n1 || true + + # If nothing to upgrade, that's fine; ensure there is no obvious parse error. + if grep -qiE "Unable to parse|ValueError|AttributeError|ImportError" "$log"; then + echo "[ERROR] Parsing error detected; see $log" + return 1 + fi + + # Confirm at least one of our pairs appears in the "Allowed origins are" line + local ok=0 + local line + line="$(grep -m1 "Allowed origins are" "$log" || true)" + if [[ -n "$line" ]]; then + for pair in "${DETECTED_ORIGINS[@]}"; do + # Translate "Origin:Archive" -> "o=Origin,a=Archive" for comparison + if [[ "$pair" =~ ^([^:]+):(.+)$ ]]; then + local o="o=${BASH_REMATCH[1]},a=${BASH_REMATCH[2]}" + if [[ "$line" == *"$o"* ]]; then + ok=1; break fi fi done - - if [[ $configs_working -eq 0 ]]; then - echo "[ERROR] None of the configured origins are being recognized!" - echo "[ERROR] Expected origins (in config format): ${DETECTED_ORIGINS[*]}" - echo "[ERROR] Debug log may show format differences - check /tmp/unattended-upgrades-test.log" - rm -f "$temp_log" - return 1 - elif [[ $configs_working -lt ${#DETECTED_ORIGINS[@]} ]]; then - echo "[WARN] Only $configs_working/${#DETECTED_ORIGINS[@]} origins recognized, but continuing..." - else - echo "[OK] All $configs_working configured origins are being recognized" - fi fi - - # Check for blocking issues - if grep -q "No packages found that can be upgraded unattended" "$temp_log"; then - if grep -q "Marking not allowed.*-32768" "$temp_log"; then - # Count how many packages are being blocked - local blocked_count - blocked_count=$(grep -c "Marking not allowed.*-32768" "$temp_log" || echo "0") - echo "[INFO] $blocked_count non-official packages are correctly being blocked" - echo "[OK] Configuration is working - no official packages need updates" - else - echo "[OK] No packages need updating - configuration appears correct" - fi - else - echo "[OK] Configuration validation passed" - fi - - # Save full log for debugging - cp "$temp_log" "/tmp/unattended-upgrades-test.log" - rm -f "$temp_log" - return 0 -} -dry_run_test() { - echo "[INFO] Testing unattended-upgrades configuration…" - - if validate_config; then - echo "[OK] Configuration test passed" - return 0 - else - echo "[ERROR] Configuration test failed - see details above" - echo "[INFO] Full debug log saved to /tmp/unattended-upgrades-test.log" - echo "[INFO] You can retry with: sudo unattended-upgrades --dry-run --debug" - return 1 + if (( ok == 0 )); then + echo "[WARN] None of the configured origins were echoed in the debug header; this can be benign if the host has no updates yet or APT lists are stale. See $log for context." fi } show_status() { echo - echo "[INFO] Service and timer status:" - systemctl list-timers --all | grep -E 'apt-(daily|daily-upgrade)\.timer' || true + echo "[INFO] Config files:" + ls -la /etc/apt/apt.conf.d/20auto-upgrades /etc/apt/apt.conf.d/50unattended-upgrades 2>/dev/null || true echo - echo "[INFO] Configuration files:" - ls -la /etc/apt/apt.conf.d/{20auto-upgrades,50unattended-upgrades} - echo - echo "[INFO] Detected configuration summary:" - echo " OS: $OS $CODENAME" - echo " Origins: ${DETECTED_ORIGINS[*]}" - if [[ "$OS" == "debian" ]]; then - echo " Main archive: $MAIN_ARCHIVE" - [[ -n "$UPDATES_ARCHIVE" ]] && echo " Updates: $UPDATES_ARCHIVE" - [[ -n "$SECURITY_ARCHIVE" ]] && echo " Security: $SECURITY_ARCHIVE" + if command -v systemctl >/dev/null 2>&1; then + echo "[INFO] Timers:" + systemctl list-timers --all | grep -E 'apt-(daily|daily-upgrade)\.timer' || true + else + echo "[INFO] systemd not present; relying on APT::Periodic via cron." fi } -prompt_self_delete() { - echo - read -r -p "Remove this script? [Y/n] " confirm - case "$confirm" in - [nN]*) echo "[INFO] Script not removed." ;; - *) echo "[INFO] Removing script: $0"; rm -- "$0" ;; - esac -} - main() { - echo "[INFO] Universal Unattended-Upgrades Setup Script" - echo "[INFO] Supports mixed Debian/Ubuntu environments with auto-detection" - echo - + echo "[INFO] Unattended-upgrades configurator (Debian/Ubuntu)" + require_root detect_os - detect_repositories - fix_locale - install_unattended - enable_unattended - - echo "[INFO] Writing configuration files…" - write_50_conf_universal - write_20_conf - - if ! dry_run_test; then - echo "[ERROR] Setup completed but configuration validation failed" - echo "[ERROR] Manual review of /etc/apt/apt.conf.d/50unattended-upgrades may be needed" - show_status - exit 1 - fi - + apt_refresh_and_install + extract_policy_pairs + build_allowed_origins + write_50unattended + write_20auto + enable_timers_if_systemd + validate_with_dryrun show_status echo - echo "[OK] ✅ Unattended updates configured successfully!" - echo "[OK] Security + regular updates enabled, reboot @ ${REBOOT_TIME} if needed" - echo "[OK] Compatible with current and future Debian/Ubuntu releases" - - prompt_self_delete + echo "[OK] Unattended updates configured. Regular + security updates will apply automatically; reboot at ${REBOOT_TIME} if needed." } -main "$@" \ No newline at end of file +main "$@"