diff --git a/auto-updates-TESTING.sh b/auto-updates-TESTING.sh index 6899e62..193a4d3 100644 --- a/auto-updates-TESTING.sh +++ b/auto-updates-TESTING.sh @@ -2,16 +2,15 @@ # # setup-auto-updates.sh # -# 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 +# Universal unattended-upgrades setup for Debian & Ubuntu. +# - Uses Origins-Pattern (not legacy Allowed-Origins) for forward-compatibility +# - Works with sources.list and deb822 .sources +# - Enables security + updates pockets; optional Ubuntu ESM if present +# - Validates with a dry-run; resilient to apt/dpkg locks # # Notes: -# * Backports are intentionally NOT auto-updated. -# * On minimal containers without systemd, the APT::Periodic cron path is used. +# * Backports/proposed are intentionally not enabled. +# * On non-systemd hosts, APT::Periodic drives execution via cron. set -Eeuo pipefail trap 'echo "[ERROR] Line $LINENO failed" >&2' ERR @@ -20,8 +19,8 @@ REBOOT_TIME="${REBOOT_TIME:-04:00}" OS="" CODENAME="" -DETECTED_ORIGINS=() # e.g., ("Debian:bookworm" "Debian:bookworm-security" ...) -POLICY_PAIRS=() # raw pairs as "Origin:Archive" extracted from apt-cache +declare -a POLICY_PAIRS=() # "Origin:Archive" from apt-cache +declare -a ORIGIN_PATTERNS=() # "origin=...,archive=..." require_root() { if [[ $EUID -ne 0 ]]; then @@ -31,132 +30,119 @@ require_root() { } detect_os() { - if [[ ! -f /etc/os-release ]]; then - echo "[ERROR] /etc/os-release not found." >&2 - exit 1 - fi + [[ -f /etc/os-release ]] || { echo "[ERROR] /etc/os-release not found." >&2; exit 1; } # shellcheck disable=SC1091 . /etc/os-release case "${ID,,}" in debian) OS="debian" ;; ubuntu) OS="ubuntu" ;; - *) - # Fall back via ID_LIKE if helpful - if [[ "${ID_LIKE:-}" =~ debian ]]; then - OS="debian" - else - echo "[ERROR] Unsupported OS: ${ID}." >&2 - exit 1 - fi - ;; + *) [[ "${ID_LIKE:-}" =~ debian ]] && OS="debian" || { echo "[ERROR] Unsupported OS: ${ID}."; exit 1; } ;; esac - # 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 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}" } +wait_for_apt() { + echo "[INFO] Checking for apt/dpkg locks…" + local locks=(/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock) + for i in {1..90}; do + if ! fuser "${locks[@]}" >/dev/null 2>&1; then + echo "[INFO] No locks detected." + return 0 + fi + (( i % 10 == 0 )) && echo "[INFO] apt/dpkg busy…waiting $(($i*2))s" + sleep 2 + done + echo "[WARN] apt/dpkg still busy after ~180s; continuing anyway." + return 1 +} + apt_refresh_and_install() { + wait_for_apt || true echo "[INFO] Updating APT cache…" apt-get update -qq || true + + wait_for_apt || true echo "[INFO] Installing unattended-upgrades…" - DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades >/dev/null + DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades >/dev/null || true + + # Ensure periodic is enabled + echo 'unattended-upgrades unattended-upgrades/enable_auto_updates boolean true' | debconf-set-selections || true + DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive unattended-upgrades >/dev/null || true } 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" + # Extract unique Origin:Archive pairs from apt-cache policy lines mapfile -t POLICY_PAIRS < <( apt-cache policy 2>/dev/null \ | sed -n 's/.*o=\([^,]*\),a=\([^,]*\).*/\1:\2/p' \ - | awk 'NF' \ - | sort -u + | awk 'NF' | sort -u ) } -build_allowed_origins() { - DETECTED_ORIGINS=() +build_origin_patterns() { + ORIGIN_PATTERNS=() - # 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 - } + have_pair() { printf '%s\n' "${POLICY_PAIRS[@]}" | grep -Fxq -- "$1"; } + add_pat() { ORIGIN_PATTERNS+=("origin=$1,archive=$2"); } - # 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" + # Consider common Debian suites (codename/stable/testing variants) + local want=( "$CODENAME" "$CODENAME-updates" "$CODENAME-security" + stable stable-updates stable-security + testing testing-security ) + for a in "${want[@]}"; do + have_pair "Debian:$a" && add_pat "Debian" "$a" + done + # Fallback if apt-cache didn’t show anything yet + if [[ ${#ORIGIN_PATTERNS[@]} -eq 0 ]]; then + ORIGIN_PATTERNS+=("origin=Debian,archive=$CODENAME" + "origin=Debian,archive=${CODENAME}-updates" + "origin=Debian,archive=${CODENAME}-security") + fi + else # ubuntu + # Standard pockets + for a in "$CODENAME" "${CODENAME}-updates" "${CODENAME}-security"; do + have_pair "Ubuntu:$a" && add_pat "Ubuntu" "$a" + done + # Optional ESM, only if attached and present + have_pair "UbuntuESM:${CODENAME}-infra-security" && add_pat "UbuntuESM" "${CODENAME}-infra-security" + have_pair "UbuntuESMApps:${CODENAME}-apps-security" && add_pat "UbuntuESMApps" "${CODENAME}-apps-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" - ) + if [[ ${#ORIGIN_PATTERNS[@]} -eq 0 ]]; then + ORIGIN_PATTERNS+=("origin=Ubuntu,archive=$CODENAME" + "origin=Ubuntu,archive=${CODENAME}-updates" + "origin=Ubuntu,archive=${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[@]}" + echo "[INFO] Origins-Pattern to be written:" + printf ' %s\n' "${ORIGIN_PATTERNS[@]}" } write_50unattended() { - local origins_block="" - for o in "${DETECTED_ORIGINS[@]}"; do - origins_block+=" \"${o}\";\n" + local patterns="" + for p in "${ORIGIN_PATTERNS[@]}"; do + patterns+=" \"$p\";\n" done cat > /etc/apt/apt.conf.d/50unattended-upgrades </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() { + wait_for_apt || true 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 + if ! timeout 180 unattended-upgrades --dry-run --debug >"$log" 2>&1; then echo "[WARN] Dry run timed out or failed; see $log" return 1 fi - - # 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 - fi - - 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() { @@ -245,18 +205,24 @@ show_status() { main() { echo "[INFO] Unattended-upgrades configurator (Debian/Ubuntu)" - require_root detect_os apt_refresh_and_install extract_policy_pairs - build_allowed_origins + build_origin_patterns write_50unattended write_20auto enable_timers_if_systemd - validate_with_dryrun - show_status + set +e + validate_with_dryrun + vr=$? + set -e + if (( vr != 0 )); then + echo "[WARN] Validation failed or timed out. Config is written; normal timers/periodic will still run. Inspect the log above if needed." + fi + + show_status echo echo "[OK] Unattended updates configured. Regular + security updates will apply automatically; reboot at ${REBOOT_TIME} if needed." }