mirror of
https://github.com/andrey271192/safe-monthly-maintenance.git
synced 2026-09-20 11:55:33 +00:00
Add safe monthly maintenance installer
This commit is contained in:
379
install.sh
Executable file
379
install.sh
Executable file
@@ -0,0 +1,379 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user