commit 8890a4110cdd8b9cdd2c2cd779535122497e7746 Author: andrey271192 <76248502+andrey271192@users.noreply.github.com> Date: Fri Jul 31 12:14:48 2026 +0300 Add safe monthly maintenance installer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22e77d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.log +*.tmp +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..362c4c7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 1.0.2 — 2026-07-31 + +- Первый публичный релиз. +- Ежемесячный systemd timer с случайной задержкой. +- Безопасное APT-обновление без удаления пакетов. +- Проверки `dpkg`, свободного места и APT-lock. +- Очистка APT cache, systemd journal и временных файлов. +- Автоматическое определение нового ядра без автоматического reboot. +- `autoremove` и Docker prune выключены по умолчанию. +- Подробная документация и troubleshooting. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0574e63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andrey (andrey271192) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c595aa7 --- /dev/null +++ b/README.md @@ -0,0 +1,298 @@ +# Safe Monthly Maintenance + +Безопасное ежемесячное обслуживание Ubuntu/Debian одной командой. + +Скрипт устанавливает systemd timer, обновляет пакеты без `dist-upgrade`, чистит +безопасные кэши и журналы, проверяет состояние `dpkg`, свободное место и +необходимость перезагрузки. Автоматическая перезагрузка, `autoremove` и очистка +Docker выключены по умолчанию. + +> Проверено на Debian 12. Скрипт рассчитан на Ubuntu и Debian с systemd и APT. +> Перед массовым развертыванием проверьте его на одной тестовой VM. + +## Установка одной командой + +Закрепленная версия: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/safe-monthly-maintenance/v1.0.2/install.sh | sudo bash -s -- --run-now +``` + +Команда: + +1. скачивает `install.sh`; +2. устанавливает maintenance-скрипт и systemd units; +3. включает ежемесячный timer; +4. сразу выполняет первое обслуживание. + +Установка без немедленного запуска: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/safe-monthly-maintenance/v1.0.2/install.sh | sudo bash +``` + +Если `curl` отсутствует: + +```bash +wget -qO- https://raw.githubusercontent.com/andrey271192/safe-monthly-maintenance/v1.0.2/install.sh | sudo bash -s -- --run-now +``` + +## Более безопасная установка с проверкой + +Не запускайте неизвестный код от `root` вслепую. Скачайте, проверьте, затем +выполните: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/safe-monthly-maintenance/v1.0.2/install.sh -o /tmp/install-safe-maintenance.sh +sha256sum /tmp/install-safe-maintenance.sh +less /tmp/install-safe-maintenance.sh +sudo bash /tmp/install-safe-maintenance.sh --run-now +``` + +SHA-256 версии `v1.0.2`: + +```text +247312aaca5eb5b40c8893edb065203b2e4f0121c47421d85b46f9ae46770367 +``` + +## Что устанавливается + +| Путь | Назначение | +|---|---| +| `/usr/local/sbin/safe-monthly-maintenance` | Основной maintenance-скрипт | +| `/etc/default/safe-monthly-maintenance` | Настройки конкретной VM | +| `/etc/systemd/system/safe-monthly-maintenance.service` | Одноразовый systemd service | +| `/etc/systemd/system/safe-monthly-maintenance.timer` | Ежемесячный запуск | +| `/var/backups/safe-monthly-maintenance/` | Снимки списка пакетов | + +Повторный запуск installer обновляет скрипт и units, но сохраняет существующий +файл настроек. + +## Расписание + +Timer запускается первого числа каждого месяца в 03:00 с случайной задержкой до +шести часов: + +```ini +OnCalendar=*-*-01 03:00:00 +RandomizedDelaySec=6h +Persistent=true +``` + +Случайная задержка уменьшает одновременную нагрузку на репозитории, если один +installer установлен на много VM. `Persistent=true` выполняет пропущенный запуск +после включения сервера. + +## Что делает обслуживание + +### 1. Проверяет окружение + +- требует права `root`; +- разрешает только Ubuntu/Debian и Debian-подобные системы; +- использует `flock`, поэтому два запуска не идут одновременно; +- использует ожидание APT-lock до 600 секунд; +- повторяет сетевые обращения APT до трех раз. + +### 2. Сохраняет список пакетов + +Перед обновлением выполняется: + +```bash +dpkg --get-selections +``` + +Снимок записывается в `/var/backups/safe-monthly-maintenance/`. Снимки старше +120 дней удаляются. Это не полный rollback системы, но помогает восстановить +состав пакетов. + +### 3. Чистит только безопасные данные + +- `systemd-tmpfiles --clean` — применяет штатные политики очистки временных + файлов; +- `logrotate` — запускает штатную ротацию логов; +- `journalctl --vacuum-time=30d` — удаляет старый journal; +- `journalctl --vacuum-size=500M` — ограничивает размер journal; +- `apt-get autoclean` — удаляет устаревшие package-файлы; +- `apt-get clean` — очищает скачанный APT-кэш после обновления. + +Скрипт не выполняет ручной `rm -rf` для `/tmp`, `/var/log`, Docker volumes или +пользовательских каталогов. + +### 4. Проверяет пакетную систему + +`dpkg --audit` запускается до обновления. Если пакетная операция была прервана, +обновление пропускается. Скрипт не пытается автоматически ремонтировать +неоднозначное состояние. + +После обновления выполняется: + +```bash +apt-get check +``` + +### 5. Проверяет свободное место + +Обновление пропускается, если: + +- на `/` осталось меньше 1 ГиБ; +- на отдельном `/boot` осталось меньше 200 МиБ. + +Порог можно изменить в конфигурации. + +### 6. Обновляет пакеты + +Используется: + +```bash +apt-get -y \ + -o Dpkg::Options::=--force-confold \ + --with-new-pkgs \ + --no-remove \ + upgrade +``` + +Почему: + +- `upgrade`, не `dist-upgrade`/`full-upgrade`: пакеты не удаляются; +- `--with-new-pkgs`: разрешает новые зависимости, включая новое ядро; +- `--no-remove`: аварийно останавливает операцию, если APT захочет удалить + пакет; +- `--force-confold`: сохраняет локальные конфиги вместо тихой замены; +- `NEEDRESTART_MODE=l`: показывает сервисы со старыми библиотеками, но не + перезапускает все сервисы автоматически. + +Пакетные maintainer scripts все равно могут перезапустить собственный сервис. +Абсолютно гарантировать отсутствие краткого service interruption при системном +обновлении невозможно. + +### 7. Не удаляет зависимости автоматически + +По умолчанию выполняется только dry-run: + +```bash +apt-get --dry-run autoremove +``` + +Кандидаты попадают в журнал. Реальный `autoremove` включается только вручную +через конфигурацию. + +### 8. Не перезагружает сервер + +Скрипт проверяет `/var/run/reboot-required` и сравнивает текущее ядро с +`/vmlinuz`. При необходимости пишет: + +```text +REBOOT REQUIRED. Automatic reboot disabled. +``` + +Решение о reboot остается у администратора. + +### 9. Показывает итог + +В конце журнал содержит: + +- failed systemd units; +- использование диска; +- необходимость reboot; +- успешные и неуспешные этапы. + +## Настройка + +Файл: + +```bash +sudo nano /etc/default/safe-monthly-maintenance +``` + +Параметры: + +| Параметр | По умолчанию | Значение | +|---|---:|---| +| `ENABLE_AUTOREMOVE` | `0` | `1` разрешает `apt-get autoremove --purge` | +| `ENABLE_DOCKER_PRUNE` | `0` | `1` удаляет только dangling Docker images | +| `JOURNAL_MAX_AGE` | `30d` | Максимальный возраст systemd journal | +| `JOURNAL_MAX_SIZE` | `500M` | Максимальный размер journal | +| `MIN_ROOT_FREE_KB` | `1048576` | Минимум свободного места на `/` | +| `MIN_BOOT_FREE_KB` | `204800` | Минимум на отдельном `/boot` | + +Для серверов с важными stopped-контейнерами и локальными Docker-образами +оставляйте `ENABLE_DOCKER_PRUNE=0`. + +## Управление + +Следующий запуск: + +```bash +systemctl list-timers safe-monthly-maintenance.timer --all +``` + +Запустить вручную: + +```bash +sudo systemctl start safe-monthly-maintenance.service +``` + +Последний статус: + +```bash +systemctl status safe-monthly-maintenance.service +``` + +Логи: + +```bash +journalctl -u safe-monthly-maintenance.service +``` + +Логи текущего запуска: + +```bash +journalctl -u safe-monthly-maintenance.service --since today +``` + +Отключить автоматический запуск: + +```bash +sudo systemctl disable --now safe-monthly-maintenance.timer +``` + +Удалить: + +```bash +sudo systemctl disable --now safe-monthly-maintenance.timer +sudo rm -f /etc/systemd/system/safe-monthly-maintenance.timer +sudo rm -f /etc/systemd/system/safe-monthly-maintenance.service +sudo rm -f /usr/local/sbin/safe-monthly-maintenance +sudo systemctl daemon-reload +``` + +Конфигурация и package snapshots при удалении сохраняются намеренно. + +## Массовое развертывание + +Пример с локальным списком серверов: + +```bash +for host in vm1.example.com vm2.example.com vm3.example.com; do + ssh "$host" \ + 'curl -fsSL https://raw.githubusercontent.com/andrey271192/safe-monthly-maintenance/v1.0.2/install.sh | sudo bash -s -- --run-now' +done +``` + +Сначала используйте одну тестовую VM. Для большой инфраструктуры лучше Ansible, +Salt или другой configuration-management инструмент. + +## Ограничения + +- Скрипт не делает filesystem/provider snapshot. +- Скрипт не исправляет сломанный `dpkg` автоматически. +- Скрипт не обновляет Ubuntu/Debian между major releases. +- Скрипт не управляет сторонними package managers. +- Скрипт не гарантирует отсутствие service restart от maintainer scripts. +- Скрипт не заменяет мониторинг, резервные копии и тестирование обновлений. + +Подробная модель безопасности: [docs/SAFETY.md](docs/SAFETY.md). + +## Лицензия + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..96bb162 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Reporting + +Не публикуйте токены, пароли, SSH-ключи, приватные IP-схемы или конфигурации +серверов в issue. + +Для потенциальной уязвимости используйте GitHub Private Vulnerability Reporting, +если функция доступна для репозитория. + +## Running as root + +Installer и maintenance-скрипт работают с правами `root`, потому что APT, +systemd и journal требуют системного доступа. + +Перед запуском: + +1. используйте URL закрепленного release tag; +2. проверяйте SHA-256; +3. просматривайте скачанный файл; +4. тестируйте на отдельной VM; +5. имейте snapshot или резервную копию. + +Никогда не подставляйте секреты в URL installer-команды. diff --git a/docs/SAFETY.md b/docs/SAFETY.md new file mode 100644 index 0000000..2fe63e9 --- /dev/null +++ b/docs/SAFETY.md @@ -0,0 +1,82 @@ +# Модель безопасности + +Цель проекта — выполнять повторяемое обслуживание Ubuntu/Debian с минимальным +риском. «Безопасно» не означает «невозможно сломать»: любое системное обновление +может содержать ошибку пакета, несовместимость приложения или service restart. + +## Принципы + +### Без удаления пакетов + +Основное обновление использует `apt-get upgrade --no-remove`. +`dist-upgrade` и `full-upgrade` не используются. + +`autoremove` выключен. Причина: пакет, нужный локальному приложению, иногда +ошибочно остается помеченным как автоматически установленный. + +### Без автоматического reboot + +Новое ядро устанавливается, но активируется только после решения администратора. +Это предотвращает незапланированный разрыв VPN, базы данных, SSH или другого +сервиса. + +### Без опасной очистки файлов + +Очистка временных данных делегируется `systemd-tmpfiles`, `logrotate`, +`journalctl` и APT. Проект не содержит рекурсивного удаления широких каталогов. + +### Без очистки Docker по умолчанию + +Stopped containers, tagged images, volumes и networks могут содержать нужное +состояние. Поэтому Docker cleanup выключен. Опция удаляет только dangling images. + +### Сохранение конфигов + +`Dpkg::Options::=--force-confold` сохраняет установленную локальную конфигурацию. +Новая версия файла пакета обычно остается рядом как `.dpkg-dist`; ее нужно +сравнить вручную. + +### Проверки до изменения + +APT upgrade не запускается при: + +- поврежденном/незавершенном состоянии `dpkg`; +- критически малом свободном месте; +- ошибке `apt-get update`. + +### Один процесс + +`flock` предотвращает параллельные maintenance-запуски. APT-lock ожидается до +10 минут, что уменьшает конфликт с `apt-daily`. + +## Рекомендуемая эксплуатация + +1. Создайте snapshot VM перед первым запуском. +2. Проверьте installer и checksum. +3. Запустите на тестовой VM. +4. Проверьте journal, failed units и приложение. +5. Только потом развертывайте на остальные серверы. +6. Перезагружайте сервер в согласованное maintenance window. + +## Что проверить после запуска + +```bash +systemctl --failed +dpkg --audit +apt list --upgradable +journalctl -u safe-monthly-maintenance.service -n 200 +test -e /var/run/reboot-required && echo "reboot required" +``` + +Для VPN, reverse proxy, базы данных и других сервисов добавьте собственные +health checks после системного обслуживания. + +## Rollback + +Проект сохраняет список пакетов, но не создает полноценный rollback. + +Для надежного возврата нужны: + +- snapshot гипервизора/VPS-провайдера; +- резервная копия `/etc` и данных приложений; +- отдельно проверенная процедура восстановления. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..ae4b329 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,85 @@ +# Диагностика + +## Service завершился с ошибкой + +```bash +systemctl status safe-monthly-maintenance.service +journalctl -u safe-monthly-maintenance.service -n 300 --no-pager +``` + +Скрипт продолжает независимые безопасные этапы и в конце возвращает ненулевой +код, если хотя бы один этап завершился ошибкой. + +## APT занят + +Скрипт ожидает lock до 600 секунд. Найдите владельца lock: + +```bash +ps aux | grep -E 'apt|dpkg' +systemctl status apt-daily.service apt-daily-upgrade.service +``` + +Не удаляйте lock-файлы вручную, пока работает APT/DPKG. + +## `dpkg --audit` показывает проблему + +Проверьте вывод: + +```bash +sudo dpkg --audit +``` + +Типовой ремонт: + +```bash +sudo dpkg --configure -a +sudo apt-get -f install +``` + +Читайте список изменений перед подтверждением. + +## Недостаточно места + +```bash +df -h / /boot +du -xhd1 /var | sort -h +journalctl --disk-usage +``` + +Не уменьшайте защитный порог без понимания причины заполнения. + +## Требуется reboot + +```bash +uname -r +readlink -f /vmlinuz +cat /var/run/reboot-required 2>/dev/null +``` + +Перезагрузите в maintenance window: + +```bash +sudo systemctl reboot +``` + +После reboot: + +```bash +uname -r +systemctl --failed +``` + +## Timer не запускается + +```bash +systemctl is-enabled safe-monthly-maintenance.timer +systemctl is-active safe-monthly-maintenance.timer +systemctl list-timers safe-monthly-maintenance.timer --all +``` + +Восстановить: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now safe-monthly-maintenance.timer +``` diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..577965e --- /dev/null +++ b/install.sh @@ -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