Add safe monthly maintenance installer

This commit is contained in:
andrey271192
2026-07-31 12:14:48 +03:00
commit 8890a4110c
8 changed files with 904 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.log
*.tmp
.DS_Store

12
CHANGELOG.md Normal file
View File

@@ -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.

21
LICENSE Normal file
View File

@@ -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.

298
README.md Normal file
View File

@@ -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)

24
SECURITY.md Normal file
View File

@@ -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-команды.

82
docs/SAFETY.md Normal file
View File

@@ -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` и данных приложений;
- отдельно проверенная процедура восстановления.

85
docs/TROUBLESHOOTING.md Normal file
View File

@@ -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
```

379
install.sh Executable file
View 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