From 9a357d2b92aecb29f09baa0a63f444beb9610acf Mon Sep 17 00:00:00 2001 From: menzelj Date: Fri, 18 Sep 2026 12:50:46 +0200 Subject: [PATCH] Schedule backups in local time instead of UTC (0.66.0) 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 --- README.md | 6 ++- backend/models/backup_schedule.py | 8 ++- backend/requirements.txt | 2 + backend/routers/schedules.py | 16 ++++-- backend/services/schedule_service.py | 69 +++++++++++++++++------- backend/tests/test_backup_schedules.py | 73 ++++++++++++++++++++++++++ backend/version.py | 2 +- frontend/package.json | 2 +- frontend/src/api/schedules.ts | 3 ++ frontend/src/pages/Settings.tsx | 38 +++++++++++++- 10 files changed, 192 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e1b9008..355b8e9 100644 --- a/README.md +++ b/README.md @@ -644,8 +644,12 @@ it is what your saved destination credentials are encrypted with. ### Phase 7 — Scheduled backups - **Recurring backups**: schedule stacks to back up to a destination **hourly, - daily, or weekly** (UTC) under **Settings → Scheduled backups**. A background + daily, or weekly** under **Settings → Scheduled backups**. A background scheduler runs due jobs every minute and records last/next run + status. +- **Local time** (0.66.0): each schedule has a time zone, preset to the + browser's (e.g. `Europe/Berlin`), and its time is wall-clock time there — a + 03:00 backup stays at 03:00 across daylight saving changes. Schedules made + before were in UTC and stay so until edited. - **Several stacks per schedule** (0.65.0): tick any number of stacks, or *All stacks* to include stacks added later without editing the schedule. They are backed up one after another, each to its own file with its own retention diff --git a/backend/models/backup_schedule.py b/backend/models/backup_schedule.py index 13cc195..12785c0 100644 --- a/backend/models/backup_schedule.py +++ b/backend/models/backup_schedule.py @@ -30,9 +30,12 @@ class BackupSchedule(SQLModel, table=True): all_stacks: bool = False destination_id: int frequency: str = "daily" # one of FREQUENCIES - hour: int = 3 # UTC, used for daily/weekly + hour: int = 3 # wall-clock time in ``timezone``, used for daily/weekly minute: int = 0 weekday: int = 0 # 0=Mon .. 6=Sun, used for weekly + #: IANA zone the time is in ("Europe/Berlin"). Schedules from before + #: 0.66.0 were UTC and stay so until edited. + timezone: str = "UTC" include_volumes: bool = True stop_first: bool = True keep: int = 7 # retention: keep newest N for this stack on the dest (0 = all) @@ -88,6 +91,7 @@ class ScheduleCreate(SQLModel): hour: int = 3 minute: int = 0 weekday: int = 0 + timezone: str = "UTC" include_volumes: bool = True stop_first: bool = True keep: int = 7 @@ -102,6 +106,7 @@ class ScheduleUpdate(SQLModel): hour: Optional[int] = None minute: Optional[int] = None weekday: Optional[int] = None + timezone: Optional[str] = None include_volumes: Optional[bool] = None stop_first: Optional[bool] = None keep: Optional[int] = None @@ -120,6 +125,7 @@ class ScheduleRead(SQLModel): hour: int minute: int weekday: int + timezone: str include_volumes: bool stop_first: bool keep: int diff --git a/backend/requirements.txt b/backend/requirements.txt index 4daf80e..943b36d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,3 +15,5 @@ httpx==0.28.1 PyYAML==6.0.2 paramiko==3.5.0 boto3==1.35.99 +# Time zones for backup schedules: the slim base image has no system tz database. +tzdata==2026.4 diff --git a/backend/routers/schedules.py b/backend/routers/schedules.py index 08f6126..f4bff40 100644 --- a/backend/routers/schedules.py +++ b/backend/routers/schedules.py @@ -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() diff --git a/backend/services/schedule_service.py b/backend/services/schedule_service.py index 03ff1f7..363ec0d 100644 --- a/backend/services/schedule_service.py +++ b/backend/services/schedule_service.py @@ -15,6 +15,7 @@ import json import logging import os from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlmodel import Session, select @@ -55,28 +56,57 @@ def _now() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) +def zone(name: str | None) -> ZoneInfo: + """The schedule's time zone; UTC for an empty or unknown name.""" + try: + return ZoneInfo(name or "UTC") + except (ZoneInfoNotFoundError, ValueError): + return ZoneInfo("UTC") + + def compute_next_run( - frequency: str, hour: int, minute: int, weekday: int, after: datetime + frequency: str, + hour: int, + minute: int, + weekday: int, + after: datetime, + tz: str | None = "UTC", ) -> datetime: - """Next run strictly after ``after`` (naive UTC).""" + """Next run strictly after ``after`` (naive UTC), returned as naive UTC. + + ``hour``/``minute``/``weekday`` are wall-clock time in ``tz``, so a 03:00 + backup stays at 03:00 across daylight saving changes. Each candidate day is + built from its own date rather than by adding 24 hours, which is what keeps + it on the wall clock. A time that does not exist on the day clocks go + forward (02:30) runs an hour later that day. + """ + zi = zone(tz) + after_utc = after.replace(tzinfo=timezone.utc) + local = after_utc.astimezone(zi) + + def utc(dt: datetime) -> datetime: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + if frequency == "hourly": - nxt = after.replace(minute=minute % 60, second=0, microsecond=0) - if nxt <= after: + cand = local.replace(minute=minute % 60, second=0, microsecond=0) + # Step in UTC: an hour is an hour, whatever the wall clock does. + nxt = cand.astimezone(timezone.utc) + while nxt <= after_utc: nxt += timedelta(hours=1) - return nxt + return nxt.replace(tzinfo=None) - base = after.replace(hour=hour % 24, minute=minute % 60, second=0, microsecond=0) + def at(day) -> datetime: + return datetime(day.year, day.month, day.day, hour % 24, minute % 60, tzinfo=zi) + + day = local.date() if frequency == "weekly": - days_ahead = (weekday - base.weekday()) % 7 - cand = base + timedelta(days=days_ahead) - if cand <= after: - cand += timedelta(days=7) - return cand - - # daily (default) - if base <= after: - base += timedelta(days=1) - return base + day += timedelta(days=(weekday - day.weekday()) % 7) + step = timedelta(days=7) + else: # daily (default) + step = timedelta(days=1) + while at(day).astimezone(timezone.utc) <= after_utc: + day += step + return utc(at(day)) def _prune(dest: BackupDestination, stack_id: str, keep: int) -> int: @@ -216,7 +246,8 @@ async def run_schedule(session: Session, schedule: BackupSchedule) -> dict: ) schedule.last_run = now schedule.next_run = compute_next_run( - schedule.frequency, schedule.hour, schedule.minute, schedule.weekday, now + schedule.frequency, schedule.hour, schedule.minute, schedule.weekday, now, + schedule.timezone, ) session.add(schedule) session.commit() @@ -241,7 +272,9 @@ async def run_due() -> None: due = [] for s in schedules: if s.next_run is None: - s.next_run = compute_next_run(s.frequency, s.hour, s.minute, s.weekday, now) + s.next_run = compute_next_run( + s.frequency, s.hour, s.minute, s.weekday, now, s.timezone + ) session.add(s) elif s.next_run <= now: due.append(s) diff --git a/backend/tests/test_backup_schedules.py b/backend/tests/test_backup_schedules.py index 6832333..1ae71ef 100644 --- a/backend/tests/test_backup_schedules.py +++ b/backend/tests/test_backup_schedules.py @@ -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 diff --git a/backend/version.py b/backend/version.py index 3895630..734a2e5 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.65.0" +APP_VERSION = "0.66.0" diff --git a/frontend/package.json b/frontend/package.json index 6218eab..a59dbf9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.65.0", + "version": "0.66.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index 5235a2e..d455e1c 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -12,6 +12,8 @@ export interface BackupSchedule { hour: number; minute: number; weekday: number; + /** IANA zone the time is in, e.g. "Europe/Berlin". */ + timezone: string; include_volumes: boolean; stop_first: boolean; keep: number; @@ -34,6 +36,7 @@ export interface ScheduleInput { hour: number; minute: number; weekday: number; + timezone: string; include_volumes: boolean; stop_first: boolean; keep: number; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 9f1b033..8d1077c 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -77,8 +77,23 @@ export function Settings() { const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +/** The browser's zone: what "local time" means to whoever sets the schedule. */ +const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + +/** Every zone the browser knows (ES2022), or a short fallback list. */ +const TIME_ZONES: string[] = (() => { + const all = ( + Intl as unknown as { supportedValuesOf?: (key: string) => string[] } + ).supportedValuesOf?.("timeZone"); + const zones = all?.length + ? all + : ["Europe/Berlin", "Europe/Vienna", "Europe/Zurich", "Europe/London", "America/New_York"]; + return Array.from(new Set(["UTC", BROWSER_TZ, ...zones])); +})(); + function describeSchedule(s: BackupSchedule): string { - const t = `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")} UTC`; + const tz = s.timezone || "UTC"; + const t = `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")} ${tz}`; if (s.frequency === "hourly") return `Hourly at :${String(s.minute).padStart(2, "0")}`; if (s.frequency === "weekly") return `Weekly · ${WEEKDAYS[s.weekday] ?? "?"} ${t}`; return `Daily · ${t}`; @@ -312,6 +327,8 @@ function ScheduleForm({ hour: schedule?.hour ?? 3, minute: schedule?.minute ?? 0, weekday: schedule?.weekday ?? 0, + // New schedules are in the browser's local time; existing ones keep theirs. + timezone: schedule?.timezone ?? BROWSER_TZ, include_volumes: schedule?.include_volumes ?? true, stop_first: schedule?.stop_first ?? true, keep: schedule?.keep ?? 7, @@ -429,7 +446,7 @@ function ScheduleForm({ )} {form.frequency !== "hourly" && ( )} @@ -437,6 +454,23 @@ function ScheduleForm({ Minute set("minute", Number(e.target.value))} /> +