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

+73
View File
@@ -309,3 +309,76 @@ def test_stack_status_is_stored_as_json(monkeypatch, db):
run(env.svc.run_schedule(env.session, schedule))
stored = json.loads(schedule.stack_status)
assert stored[env.ids["a"]]["status"] == "ok" and "at" in stored[env.ids["a"]]
# --------------------------------------------------------------------------- #
# Local time
# --------------------------------------------------------------------------- #
def _next(freq, hour, minute, weekday, after, tz):
from datetime import datetime
from services import schedule_service
return schedule_service.compute_next_run(
freq, hour, minute, weekday, datetime.fromisoformat(after), tz
).isoformat()
def test_daily_time_is_wall_clock_time_in_the_schedule_zone():
# 03:00 in Berlin is 02:00 UTC in winter...
assert _next("daily", 3, 0, 0, "2026-01-10T12:00", "Europe/Berlin") == "2026-01-11T02:00:00"
# ...and 01:00 UTC in summer.
assert _next("daily", 3, 0, 0, "2026-07-10T12:00", "Europe/Berlin") == "2026-07-11T01:00:00"
def test_daily_time_stays_put_across_daylight_saving_changes():
# Clocks go forward on 29 March 2026 and back on 25 October 2026.
assert _next("daily", 3, 0, 0, "2026-03-28T02:00", "Europe/Berlin") == "2026-03-29T01:00:00"
assert _next("daily", 3, 0, 0, "2026-03-29T01:00", "Europe/Berlin") == "2026-03-30T01:00:00"
assert _next("daily", 3, 0, 0, "2026-10-24T01:00", "Europe/Berlin") == "2026-10-25T02:00:00"
def test_a_time_skipped_by_the_clock_change_runs_an_hour_later():
# 02:30 does not exist on 29 March in Berlin; it runs at 03:30 local.
assert _next("daily", 2, 30, 0, "2026-03-28T12:00", "Europe/Berlin") == "2026-03-29T01:30:00"
def test_weekly_is_on_the_local_weekday():
# Sunday 00:30 in Berlin is still Saturday in UTC.
assert _next("weekly", 0, 30, 6, "2026-01-05T12:00", "Europe/Berlin") == "2026-01-10T23:30:00"
def test_hourly_minute_is_local_minute():
# India is UTC+5:30, so :00 local is :30 UTC.
assert _next("hourly", 0, 0, 0, "2026-01-10T12:00", "Asia/Kolkata") == "2026-01-10T12:30:00"
assert _next("hourly", 0, 15, 0, "2026-01-10T12:00", "UTC") == "2026-01-10T12:15:00"
def test_utc_is_the_default_and_unknown_zones_fall_back_to_it():
assert _next("daily", 3, 0, 0, "2026-01-10T12:00", None) == "2026-01-11T03:00:00"
assert _next("daily", 3, 0, 0, "2026-01-10T12:00", "Mars/Olympus") == "2026-01-11T03:00:00"
def test_api_keeps_the_zone_and_refuses_bad_values(api_env):
env, client = api_env
r = client.post("/api/backups/schedules", json={
"stack_ids": [env.ids["a"]], "destination_id": env.dest.id,
"hour": 3, "minute": 0, "timezone": "Europe/Berlin",
})
assert r.status_code == 201
body = r.json()
assert body["timezone"] == "Europe/Berlin"
from datetime import datetime
from zoneinfo import ZoneInfo
local = datetime.fromisoformat(body["next_run"]).replace(tzinfo=ZoneInfo("UTC")).astimezone(
ZoneInfo("Europe/Berlin")
)
assert (local.hour, local.minute) == (3, 0)
r = client.put(f"/api/backups/schedules/{body['id']}", json={"timezone": "Mars/Olympus"})
assert r.status_code == 400
r = client.put(f"/api/backups/schedules/{body['id']}", json={"hour": 24})
assert r.status_code == 400