Schedule backups in local time instead of UTC (0.66.0)
CI / check (push) Successful in 13m5s
CI / build-and-push (push) Successful in 3m3s

Schedule times were UTC, so a nightly 03:00 backup had to be entered as
01:00 or 02:00 depending on the season, and moved by an hour twice a year.

Each schedule now has an IANA time zone, preset in the form to the
browser's (e.g. Europe/Berlin), and its hour/minute/weekday are wall-clock
time there. compute_next_run builds each candidate day from its date in
that zone rather than adding 24 hours, so the time stays put across
daylight saving changes; a time skipped by the spring change runs an hour
later that day. next_run is still stored as naive UTC.

- Existing schedules default to UTC and keep their run times until edited.
- The API validates the zone and hour/minute/weekday ranges (400) instead
  of silently wrapping them.
- tzdata is a dependency: the slim base image has no system tz database.

Tests cover Berlin winter and summer time, both 2026 change days, the
skipped hour, a weekly run across the UTC date line, hourly at a +5:30
offset, the UTC fallback and the API checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzeljandClaude Opus 5 committed 2026-09-18 12:50:46 +02:00
1 parent 8a0d673798
commit 9a357d2b92
10 files changed
+192 -27

No files matched your search

+13 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
@@ -45,6 +46,7 @@ def _to_read(session: Session, s: BackupSchedule) -> ScheduleRead:
hour=s.hour,
minute=s.minute,
weekday=s.weekday,
timezone=s.timezone or "UTC",
include_volumes=s.include_volumes,
stop_first=s.stop_first,
keep=s.keep,
@@ -68,6 +70,14 @@ def _get_or_404(session: Session, schedule_id: int) -> BackupSchedule:
def _validate(session: Session, schedule: BackupSchedule) -> None:
if schedule.frequency not in FREQUENCIES:
raise HTTPException(status_code=400, detail=f"Unknown frequency '{schedule.frequency}'")
if not (0 <= schedule.hour <= 23 and 0 <= schedule.minute <= 59 and 0 <= schedule.weekday <= 6):
raise HTTPException(status_code=400, detail="Hour must be 0-23, minute 0-59, weekday 0-6")
try:
ZoneInfo(schedule.timezone or "UTC")
except (ZoneInfoNotFoundError, ValueError) as exc:
raise HTTPException(
status_code=400, detail=f"Unknown time zone '{schedule.timezone}'"
) from exc
if not session.get(BackupDestination, schedule.destination_id):
raise HTTPException(status_code=404, detail=f"Destination {schedule.destination_id} not found")
if schedule.all_stacks:
@@ -101,7 +111,7 @@ def create_schedule(
set_stacks(s, body.stack_ids or ([body.stack_id] if body.stack_id else []))
_validate(session, s)
s.next_run = schedule_service.compute_next_run(
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now()
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now(), s.timezone
)
session.add(s)
session.commit()
@@ -130,9 +140,9 @@ def update_schedule(
set_stacks(s, stack_ids)
_validate(session, s)
# Recompute next run when timing fields change.
if {"frequency", "hour", "minute", "weekday"} & set(data) or s.next_run is None:
if {"frequency", "hour", "minute", "weekday", "timezone"} & set(data) or s.next_run is None:
s.next_run = schedule_service.compute_next_run(
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now()
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now(), s.timezone
)
session.add(s)
session.commit()