From 393e6095fae994bd5a240aed45ad19a1e59ffc55 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 31 Oct 2026 10:31:47 +0100 Subject: [PATCH] fix(backup): repair host backup schedules systemd rejects --- .../tests/test_backup_schedule_validation.py | 58 +++++++++++++++++++ install_proxmenux.sh | 2 + install_proxmenux_beta.sh | 2 + lang/es.json | 3 +- lang/sk.json | 3 +- scripts/backup_restore/backup_scheduler.sh | 52 +++++++++++++++-- 6 files changed, 111 insertions(+), 9 deletions(-) diff --git a/.github/scripts/tests/test_backup_schedule_validation.py b/.github/scripts/tests/test_backup_schedule_validation.py index 1fb2046b..8b37e290 100644 --- a/.github/scripts/tests/test_backup_schedule_validation.py +++ b/.github/scripts/tests/test_backup_schedule_validation.py @@ -40,5 +40,63 @@ class ScheduleValidation(unittest.TestCase): self.assertGreater(check, text.index('on_calendar=$(dialog')) +STUB_ANALYZE = '#!/bin/sh\ncase "$2" in daily\\ *|weekly\\ *|monthly\\ *) exit 1;; esac\nexit 0\n' +JOBS = { + 'nas': 'ON_CALENDAR=daily\\ 02:00\nENABLED=1\n', + 'week': 'ON_CALENDAR=weekly\\ 03:15\nENABLED=1\n', + 'month': 'ON_CALENDAR=monthly\\ 04:00\nENABLED=0\n', + 'good': 'ON_CALENDAR=Mon..Fri\\ 03:00\nENABLED=1\n', + 'manual': 'ON_CALENDAR=daily\\ 05:00\nMANUAL_RUN=1\n', + 'attached': 'ON_CALENDAR=daily\\ 06:00\nPVE_STORAGE=local\n', + 'custom': 'ON_CALENDAR=every\\ night\nENABLED=1\n', +} + + +class ScheduleRepair(unittest.TestCase): + def run_repair(self): + names = ('_calendar_is_valid', '_scheduler_systemctl', '_job_env_get', '_job_is_attached', + '_write_job_units', '_repaired_calendar', '_repair_job_schedules') + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + root = Path(self.folder.name) + for name, body in (('systemd-analyze', STUB_ANALYZE), + ('systemctl', f'#!/bin/sh\necho "$@" >> {root}/systemctl.log\n')): + (root / name).write_text(body) + (root / name).chmod(0o755) + jobs, units = root / 'jobs', root / 'units' + jobs.mkdir() + for job, body in JOBS.items(): + (jobs / f'{job}.env').write_text(f'JOB_ID={job}\n{body}') + script = (f'JOBS_DIR={jobs}\nSYSTEMD_DIR={units}\nLOCAL_SCRIPTS={root}\nSCRIPT_DIR={root}\n' + '_job_file() { echo "${JOBS_DIR}/$1.env"; }\n' + '_service_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.service"; }\n' + '_timer_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.timer"; }\n' + + ''.join(function(name) for name in names) + '_repair_job_schedules\n') + subprocess.run(['/bin/bash', '-c', script], check=True, + env={'PATH': f'{root}:/usr/bin:/bin'}) + return jobs, units, root + + def test_a_time_after_daily_weekly_or_monthly_is_repaired(self): + jobs, units, root = self.run_repair() + self.assertIn('ON_CALENDAR=02:00\n', (jobs / 'nas.env').read_text()) + self.assertIn('OnCalendar=02:00\n', (units / 'proxmenux-backup-nas.timer').read_text()) + self.assertIn('OnCalendar=Mon 03:15\n', (units / 'proxmenux-backup-week.timer').read_text()) + self.assertIn('OnCalendar=*-*-01 04:00\n', (units / 'proxmenux-backup-month.timer').read_text()) + log = (root / 'systemctl.log').read_text() + self.assertIn('enable --now proxmenux-backup-nas.timer', log) + self.assertNotIn('proxmenux-backup-month.timer', log) + + def test_the_rest_of_the_jobs_are_left_as_they_are(self): + jobs, units, _ = self.run_repair() + for job in ('good', 'manual', 'attached', 'custom'): + self.assertEqual((jobs / f'{job}.env').read_text(), f'JOB_ID={job}\n{JOBS[job]}', job) + self.assertFalse((units / f'proxmenux-backup-{job}.timer').exists(), job) + + def test_both_installers_repair_on_update(self): + root = SCHEDULER.parents[2] + for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'): + self.assertIn('backup_scheduler.sh" --repair-schedules', (root / installer).read_text()) + + if __name__ == '__main__': unittest.main() diff --git a/install_proxmenux.sh b/install_proxmenux.sh index 8c28b9a3..8e7b12f9 100755 --- a/install_proxmenux.sh +++ b/install_proxmenux.sh @@ -903,6 +903,8 @@ install_normal_version() { bash "$BASE_DIR/oci/engine/remote/stack_dependency_hook.sh" --refresh >/dev/null 2>&1 || true systemctl try-restart proxmenux-oci-watchdog.service >/dev/null 2>&1 || true fi + # A host backup job saved with a schedule systemd rejects never runs. + bash "$BASE_DIR/scripts/backup_restore/backup_scheduler.sh" --repair-schedules >/dev/null 2>&1 || true chmod +x "$BASE_DIR/install_proxmenux.sh" msg_ok "Necessary files created." diff --git a/install_proxmenux_beta.sh b/install_proxmenux_beta.sh index eeb6fe62..7c8e3eef 100644 --- a/install_proxmenux_beta.sh +++ b/install_proxmenux_beta.sh @@ -793,6 +793,8 @@ install_beta() { bash "$BASE_DIR/oci/engine/remote/stack_dependency_hook.sh" --refresh >/dev/null 2>&1 || true systemctl try-restart proxmenux-oci-watchdog.service >/dev/null 2>&1 || true fi + # A host backup job saved with a schedule systemd rejects never runs. + bash "$BASE_DIR/scripts/backup_restore/backup_scheduler.sh" --repair-schedules >/dev/null 2>&1 || true chmod +x "$INSTALL_DIR/$MENU_SCRIPT" [ -f "$BASE_DIR/install_proxmenux.sh" ] && chmod +x "$BASE_DIR/install_proxmenux.sh" [ -f "$BASE_DIR/install_proxmenux_beta.sh" ] && chmod +x "$BASE_DIR/install_proxmenux_beta.sh" diff --git a/lang/es.json b/lang/es.json index 194834d6..2eac31db 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2385,7 +2385,6 @@ "Example: cp /etc/pve/lxc/110.conf /etc/pve/lxc/110.conf.bak": "Ejemplo: cp /etc/pve/lxc/110.conf /etc/pve/lxc/110.conf.bak", "Example: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak": "Ejemplo: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak", "Example: daily or Mon..Fri 03:00": "Ejemplo: diario o lunes a viernes 03:00", - "Example: daily, 02:30 or Mon..Fri 03:00": "Ejemplo: daily, 02:30 o Mon..Fri 03:00", "Example: mkdir -p /tmp/lxc_convert_114": "Ejemplo: mkdir -p /tmp/lxc_convert_114", "Example: pct config 110 | grep unprivileged": "Ejemplo: pct config 110 | grep unprivileged", "Example: pct stop 110": "Ejemplo: pct stop 110", @@ -7488,7 +7487,7 @@ "This removes the storage from Proxmox. The iSCSI target is not affected.": "Esto elimina el almacenamiento de Proxmox. El destino iSCSI no se ve afectado.", "This replaces the currently installed keyfile.": "Esto reemplaza el archivo de claves instalado actualmente.", "This returns available IQNs.": "Esto devuelve los IQN disponibles.", - "This schedule is not valid. Examples: daily, 02:30 or Mon..Fri 03:00": "Esta programación no es válida. Ejemplos: daily, 02:30 o Mon..Fri 03:00", + "This schedule is not valid.": "Esta programación no es válida.", "This script cannot be executed from the Proxmox web terminal.": "Este script no se puede ejecutar desde la terminal web de Proxmox.", "This script must be run as root.": "Este script debe ejecutarse como root.", "This script must be run on a Proxmox host.": "Este script debe ejecutarse en un host Proxmox.", diff --git a/lang/sk.json b/lang/sk.json index 42920444..8c040ad9 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -2388,7 +2388,6 @@ "Example: cp /etc/pve/lxc/110.conf /etc/pve/lxc/110.conf.bak": "Príklad: cp /etc/pve/lxc/110.conf /etc/pve/lxc/110.conf.bak", "Example: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak": "Príklad: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak", "Example: daily or Mon..Fri 03:00": "Príklad: daily alebo Mon..Fri 03:00", - "Example: daily, 02:30 or Mon..Fri 03:00": "Príklad: daily, 02:30 alebo Mon..Fri 03:00", "Example: mkdir -p /tmp/lxc_convert_114": "Príklad: mkdir -p /tmp/lxc_convert_114", "Example: pct config 110 | grep unprivileged": "Príklad: pct config 110 | grep unprivileged", "Example: pct stop 110": "Príklad: pct stop 110", @@ -7515,7 +7514,7 @@ "This removes the storage from Proxmox. The iSCSI target is not affected.": "Týmto sa úložisko odstráni z Proxmoxu. iSCSI target sa nezmení.", "This replaces the currently installed keyfile.": "Týmto sa nahradí aktuálne nainštalovaný keyfile.", "This returns available IQNs.": "Týmto sa zobrazia dostupné IQN.", - "This schedule is not valid. Examples: daily, 02:30 or Mon..Fri 03:00": "Tento plán nie je platný. Príklady: daily, 02:30 alebo Mon..Fri 03:00", + "This schedule is not valid.": "Tento plán nie je platný.", "This script cannot be executed from the Proxmox web terminal.": "Tento skript sa nedá spustiť z webového terminálu Proxmoxu.", "This script must be run as root.": "Tento skript musí byť spustený ako root.", "This script must be run on a Proxmox host.": "Tento skript musí byť spustený na Proxmox serveri.", diff --git a/scripts/backup_restore/backup_scheduler.sh b/scripts/backup_restore/backup_scheduler.sh index 0c78fb58..35133bc1 100755 --- a/scripts/backup_restore/backup_scheduler.sh +++ b/scripts/backup_restore/backup_scheduler.sh @@ -105,14 +105,17 @@ _job_is_attached() { [[ -n "$storage" ]] } -# Reads a key=val pair from the job .env file (handles `printf %q` -# quoting that _write_job_env produces). +# Literal systemd expressions, never translated. +CALENDAR_EXAMPLES="daily, 02:30, Mon..Fri 03:00" + _calendar_is_valid() { [[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" == "1" ]] && return 0 command -v systemd-analyze >/dev/null 2>&1 || return 0 systemd-analyze calendar "$1" >/dev/null 2>&1 } +# Reads a key=val pair from the job .env file (handles `printf %q` +# quoting that _write_job_env produces). _job_env_get() { local id="$1" key="$2" f raw f=$(_job_file "$id") @@ -354,6 +357,40 @@ _reconcile_restored_jobs() { (( invalid == 0 )) } +# systemd rejects a time after daily, weekly or monthly, and a job saved that +# way has a timer that never fires. Returns the expression systemd accepts. +_repaired_calendar() { + [[ "$1" =~ ^(daily|weekly|monthly)[[:space:]]+([0-9]{1,2}:[0-9]{2}(:[0-9]{2})?)$ ]] || return 1 + case "${BASH_REMATCH[1]}" in + daily) echo "${BASH_REMATCH[2]}" ;; + weekly) echo "Mon ${BASH_REMATCH[2]}" ;; + monthly) echo "*-*-01 ${BASH_REMATCH[2]}" ;; + esac +} + +_repair_job_schedules() { + local env_file id on_calendar fixed line + for env_file in "$JOBS_DIR"/*.env; do + [[ -f "$env_file" ]] || continue + id="$(basename "$env_file" .env)" + [[ "$(_job_env_get "$id" MANUAL_RUN || echo 0)" == "1" ]] && continue + _job_is_attached "$id" && continue + on_calendar=$(_job_env_get "$id" ON_CALENDAR || echo "") + [[ -n "$on_calendar" ]] || continue + _calendar_is_valid "$on_calendar" && continue + fixed=$(_repaired_calendar "$on_calendar") || continue + _calendar_is_valid "$fixed" || continue + + printf -v line 'ON_CALENDAR=%q' "$fixed" + LINE="$line" awk '/^ON_CALENDAR=/ { print ENVIRON["LINE"]; next } { print }' \ + "$env_file" > "${env_file}.tmp" && mv "${env_file}.tmp" "$env_file" || continue + _write_job_units "$id" "$fixed" || continue + if [[ "$(_job_env_get "$id" ENABLED || echo 0)" == "1" ]]; then + _scheduler_systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true + fi + done +} + _prompt_retention() { local __out_var="$1" local last hourly daily weekly monthly yearly @@ -586,15 +623,15 @@ _create_job() { fi on_calendar=$(dialog --backtitle "ProxMenux" --title "$(translate "Schedule")" \ - --inputbox "$(translate "systemd OnCalendar expression")"$'\n'"$(translate "Example: daily, 02:30 or Mon..Fri 03:00")" \ + --inputbox "$(translate "systemd OnCalendar expression")"$'\n'"$(translate "Examples:") ${CALENDAR_EXAMPLES}" \ 11 72 "daily" 3>&1 1>&2 2>&3) || return 1 [[ -z "$on_calendar" ]] && return 1 # A timer with an invalid expression never fires and reports no error. while ! _calendar_is_valid "$on_calendar"; do dialog --backtitle "ProxMenux" --title "$(translate "Schedule")" \ - --msgbox "$(translate "This schedule is not valid. Examples: daily, 02:30 or Mon..Fri 03:00")" 8 72 + --msgbox "$(translate "This schedule is not valid.")"$'\n'"$(translate "Examples:") ${CALENDAR_EXAMPLES}" 9 72 on_calendar=$(dialog --backtitle "ProxMenux" --title "$(translate "Schedule")" \ - --inputbox "$(translate "systemd OnCalendar expression")"$'\n'"$(translate "Example: daily, 02:30 or Mon..Fri 03:00")" \ + --inputbox "$(translate "systemd OnCalendar expression")"$'\n'"$(translate "Examples:") ${CALENDAR_EXAMPLES}" \ 11 72 "$on_calendar" 3>&1 1>&2 2>&3) || return 1 [[ -z "$on_calendar" ]] && return 1 done @@ -997,6 +1034,11 @@ if [[ "${1:-}" == "--reconcile-restored" ]]; then exit $? fi +if [[ "${1:-}" == "--repair-schedules" ]]; then + _repair_job_schedules + exit 0 +fi + load_language initialize_cache main_menu