#!/usr/bin/env bash set -Eeuo pipefail INSTALLER_VERSION="1.0.2" SCRIPT_PATH="/usr/local/sbin/safe-monthly-maintenance" CONFIG_PATH="/etc/default/safe-monthly-maintenance" SERVICE_PATH="/etc/systemd/system/safe-monthly-maintenance.service" TIMER_PATH="/etc/systemd/system/safe-monthly-maintenance.timer" usage() { cat <<'EOF' Usage: sudo bash install-safe-monthly-maintenance.sh [--run-now] Options: --run-now Install timer, then run maintenance immediately. --help Show help. EOF } RUN_NOW=0 case "${1:-}" in "") ;; --run-now) RUN_NOW=1 ;; --help|-h) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 2 ;; esac if (( EUID != 0 )); then echo "Run as root: sudo bash $0 ${1:-}" >&2 exit 1 fi if [[ ! -r /etc/os-release ]]; then echo "Unsupported system: /etc/os-release missing" >&2 exit 1 fi # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}:${ID_LIKE:-}" in ubuntu:*|debian:*|*:*debian*) ;; *) echo "Unsupported distribution: ${PRETTY_NAME:-unknown}. Ubuntu/Debian required." >&2 exit 1 ;; esac for command_name in apt-get dpkg systemctl systemd-analyze journalctl flock systemd-tmpfiles; do if ! command -v "$command_name" >/dev/null 2>&1; then echo "Required command missing: $command_name" >&2 exit 1 fi done work_dir="$(mktemp -d)" cleanup() { rm -rf -- "$work_dir" } trap cleanup EXIT cat >"$work_dir/safe-monthly-maintenance" <<'MAINTENANCE_SCRIPT' #!/usr/bin/env bash set -Eeuo pipefail umask 027 MAINTENANCE_VERSION="1.0.2" CONFIG_PATH="/etc/default/safe-monthly-maintenance" LOCK_PATH="/run/lock/safe-monthly-maintenance.lock" BACKUP_DIR="/var/backups/safe-monthly-maintenance" # Safe defaults. Override through /etc/default/safe-monthly-maintenance. JOURNAL_MAX_AGE="30d" JOURNAL_MAX_SIZE="500M" MIN_ROOT_FREE_KB="1048576" MIN_BOOT_FREE_KB="204800" ENABLE_AUTOREMOVE="0" ENABLE_DOCKER_PRUNE="0" case "${1:-}" in "") ;; --help|-h) cat <<'EOF' Safe monthly Ubuntu/Debian maintenance. Configuration: /etc/default/safe-monthly-maintenance Logs: journalctl -u safe-monthly-maintenance.service No automatic reboot. Autoremove and Docker prune are disabled by default. EOF exit 0 ;; *) echo "Unknown option: $1" >&2 exit 2 ;; esac if [[ -r "$CONFIG_PATH" ]]; then # shellcheck disable=SC1090 . "$CONFIG_PATH" fi log() { printf '[safe-maintenance] %s\n' "$*" } failures=() run_step() { local label="$1" local rc shift log "START: $label" if "$@"; then log "OK: $label" return 0 else rc=$? failures+=("$label (exit $rc)") log "ERROR: $label (exit $rc)" return "$rc" fi } if (( EUID != 0 )); then log "ERROR: root privileges required" exit 1 fi exec 9>"$LOCK_PATH" if ! flock -n 9; then log "Another maintenance run is active. Exiting." exit 0 fi if [[ ! -r /etc/os-release ]]; then log "ERROR: /etc/os-release missing" exit 1 fi # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}:${ID_LIKE:-}" in ubuntu:*|debian:*|*:*debian*) ;; *) log "ERROR: unsupported distribution ${PRETTY_NAME:-unknown}" exit 1 ;; esac export DEBIAN_FRONTEND=noninteractive export APT_LISTCHANGES_FRONTEND=none # List services using old libraries; never restart every service automatically. export NEEDRESTART_MODE=l APT_OPTIONS=( -o Dpkg::Lock::Timeout=600 -o Acquire::Retries=3 ) log "Version $MAINTENANCE_VERSION" log "System: ${PRETTY_NAME:-unknown}" log "Started: $(date --iso-8601=seconds)" install -d -m 0750 "$BACKUP_DIR" snapshot_path="$BACKUP_DIR/dpkg-selections-$(date +%Y%m%d-%H%M%S).txt" if dpkg --get-selections >"$snapshot_path"; then log "Package selection snapshot: $snapshot_path" else failures+=("package selection snapshot") rm -f -- "$snapshot_path" fi find "$BACKUP_DIR" -maxdepth 1 -type f -name 'dpkg-selections-*.txt' -mtime +120 -delete # Use system policies for temporary files and logs. No manual rm in /tmp or /var/log. run_step "systemd temporary-file cleanup" systemd-tmpfiles --clean || true if command -v logrotate >/dev/null 2>&1; then run_step "scheduled log rotation" logrotate /etc/logrotate.conf || true fi run_step "journal rotation" journalctl --rotate || true run_step "journal age cleanup" journalctl "--vacuum-time=$JOURNAL_MAX_AGE" || true run_step "journal size cleanup" journalctl "--vacuum-size=$JOURNAL_MAX_SIZE" || true if ! dpkg_audit="$(dpkg --audit 2>&1)"; then failures+=("dpkg audit command") log "ERROR: dpkg audit failed: $dpkg_audit" apt_ready=0 elif [[ -n "$dpkg_audit" ]]; then failures+=("dpkg state incomplete") log "ERROR: interrupted/incomplete package state detected:" printf '%s\n' "$dpkg_audit" log "APT upgrade skipped. Repair manually with: dpkg --configure -a" apt_ready=0 else apt_ready=1 fi run_step "APT autoclean" apt-get "${APT_OPTIONS[@]}" -y autoclean || true if (( apt_ready == 1 )); then if run_step "APT metadata update" apt-get "${APT_OPTIONS[@]}" update; then root_free_kb="$(df -Pk / | awk 'NR == 2 {print $4}')" log "Root free space: ${root_free_kb} KiB" space_ok=1 if (( root_free_kb < MIN_ROOT_FREE_KB )); then failures+=("insufficient root free space") log "ERROR: root free space below ${MIN_ROOT_FREE_KB} KiB; upgrade skipped" space_ok=0 fi if mountpoint -q /boot; then boot_free_kb="$(df -Pk /boot | awk 'NR == 2 {print $4}')" log "/boot free space: ${boot_free_kb} KiB" if (( boot_free_kb < MIN_BOOT_FREE_KB )); then failures+=("insufficient /boot free space") log "ERROR: /boot free space below ${MIN_BOOT_FREE_KB} KiB; upgrade skipped" space_ok=0 fi fi if (( space_ok == 1 )); then # Never use dist-upgrade/full-upgrade. Never remove packages. # --with-new-pkgs permits kernel/security dependencies without removals. run_step "safe package upgrade" \ apt-get "${APT_OPTIONS[@]}" -y \ -o Dpkg::Options::=--force-confold \ --with-new-pkgs --no-remove upgrade || true run_step "APT consistency check" apt-get "${APT_OPTIONS[@]}" check || true fi else log "APT upgrade skipped because metadata update failed" fi fi if [[ "$ENABLE_AUTOREMOVE" == "1" ]]; then log "Autoremove explicitly enabled by config" run_step "APT autoremove" \ apt-get "${APT_OPTIONS[@]}" -y --purge autoremove || true else log "Autoremove disabled. Dry-run candidates:" apt-get "${APT_OPTIONS[@]}" --dry-run autoremove || true fi run_step "APT cache cleanup" apt-get "${APT_OPTIONS[@]}" clean || true if [[ "$ENABLE_DOCKER_PRUNE" == "1" ]] && command -v docker >/dev/null 2>&1; then log "Docker dangling-image prune explicitly enabled by config" run_step "Docker dangling-image prune" docker image prune -f || true else log "Docker prune disabled" fi reboot_required=0 if [[ -e /var/run/reboot-required ]]; then reboot_required=1 fi running_kernel="$(uname -r)" latest_kernel="" if [[ -e /vmlinuz ]]; then latest_kernel="$(basename "$(readlink -f /vmlinuz)")" latest_kernel="${latest_kernel#vmlinuz-}" fi if [[ -n "$latest_kernel" && "$latest_kernel" != "$running_kernel" ]]; then reboot_required=1 log "Installed kernel differs from running kernel: $running_kernel -> $latest_kernel" fi if (( reboot_required == 1 )); then log "REBOOT REQUIRED. Automatic reboot disabled." if [[ -r /var/run/reboot-required.pkgs ]]; then sed 's/^/[safe-maintenance] reboot package: /' /var/run/reboot-required.pkgs fi else log "Reboot not required" fi log "Failed systemd units:" systemctl --failed --no-pager || true log "Disk usage:" df -h / /boot 2>/dev/null | awk '!seen[$1]++' || true log "Finished: $(date --iso-8601=seconds)" if (( ${#failures[@]} > 0 )); then log "Completed with ${#failures[@]} warning/error(s):" printf '[safe-maintenance] - %s\n' "${failures[@]}" exit 1 fi log "Completed successfully" MAINTENANCE_SCRIPT cat >"$work_dir/safe-monthly-maintenance.default" <<'MAINTENANCE_CONFIG' # Safe defaults. Set to 1 only after reviewing each VM. ENABLE_AUTOREMOVE=0 ENABLE_DOCKER_PRUNE=0 # Journal retention limits. JOURNAL_MAX_AGE=30d JOURNAL_MAX_SIZE=500M # Skip package upgrade below these free-space thresholds. MIN_ROOT_FREE_KB=1048576 MIN_BOOT_FREE_KB=204800 MAINTENANCE_CONFIG cat >"$work_dir/safe-monthly-maintenance.service" <<'SERVICE_UNIT' [Unit] Description=Safe monthly Ubuntu/Debian maintenance Documentation=man:apt-get(8) Wants=network-online.target After=network-online.target [Service] Type=oneshot ExecStart=/usr/local/sbin/safe-monthly-maintenance Nice=10 IOSchedulingClass=best-effort IOSchedulingPriority=7 TimeoutStartSec=2h SERVICE_UNIT cat >"$work_dir/safe-monthly-maintenance.timer" <<'TIMER_UNIT' [Unit] Description=Run safe monthly Ubuntu/Debian maintenance [Timer] OnCalendar=*-*-01 03:00:00 RandomizedDelaySec=6h Persistent=true AccuracySec=15m Unit=safe-monthly-maintenance.service [Install] WantedBy=timers.target TIMER_UNIT bash -n "$work_dir/safe-monthly-maintenance" install -m 0755 "$work_dir/safe-monthly-maintenance" "$SCRIPT_PATH" systemd-analyze verify \ "$work_dir/safe-monthly-maintenance.service" \ "$work_dir/safe-monthly-maintenance.timer" if [[ ! -e "$CONFIG_PATH" ]]; then install -m 0644 "$work_dir/safe-monthly-maintenance.default" "$CONFIG_PATH" else echo "Preserving existing config: $CONFIG_PATH" fi install -m 0644 "$work_dir/safe-monthly-maintenance.service" "$SERVICE_PATH" install -m 0644 "$work_dir/safe-monthly-maintenance.timer" "$TIMER_PATH" systemctl daemon-reload systemctl enable --now safe-monthly-maintenance.timer echo "Installed safe monthly maintenance v$INSTALLER_VERSION" systemctl list-timers safe-monthly-maintenance.timer --all --no-pager echo "Config: $CONFIG_PATH" echo "Logs: journalctl -u safe-monthly-maintenance.service" if (( RUN_NOW == 1 )); then echo "Running maintenance now..." systemctl reset-failed safe-monthly-maintenance.service 2>/dev/null || true systemctl start safe-monthly-maintenance.service systemctl status safe-monthly-maintenance.service --no-pager -l fi