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

+5 -1
View File
@@ -644,8 +644,12 @@ it is what your saved destination credentials are encrypted with.
### Phase 7 — Scheduled backups ### Phase 7 — Scheduled backups
- **Recurring backups**: schedule stacks to back up to a destination **hourly, - **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. 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 - **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 *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 are backed up one after another, each to its own file with its own retention
+7 -1
View File
@@ -30,9 +30,12 @@ class BackupSchedule(SQLModel, table=True):
all_stacks: bool = False all_stacks: bool = False
destination_id: int destination_id: int
frequency: str = "daily" # one of FREQUENCIES 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 minute: int = 0
weekday: int = 0 # 0=Mon .. 6=Sun, used for weekly 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 include_volumes: bool = True
stop_first: bool = True stop_first: bool = True
keep: int = 7 # retention: keep newest N for this stack on the dest (0 = all) 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 hour: int = 3
minute: int = 0 minute: int = 0
weekday: int = 0 weekday: int = 0
timezone: str = "UTC"
include_volumes: bool = True include_volumes: bool = True
stop_first: bool = True stop_first: bool = True
keep: int = 7 keep: int = 7
@@ -102,6 +106,7 @@ class ScheduleUpdate(SQLModel):
hour: Optional[int] = None hour: Optional[int] = None
minute: Optional[int] = None minute: Optional[int] = None
weekday: Optional[int] = None weekday: Optional[int] = None
timezone: Optional[str] = None
include_volumes: Optional[bool] = None include_volumes: Optional[bool] = None
stop_first: Optional[bool] = None stop_first: Optional[bool] = None
keep: Optional[int] = None keep: Optional[int] = None
@@ -120,6 +125,7 @@ class ScheduleRead(SQLModel):
hour: int hour: int
minute: int minute: int
weekday: int weekday: int
timezone: str
include_volumes: bool include_volumes: bool
stop_first: bool stop_first: bool
keep: int keep: int
+2
View File
@@ -15,3 +15,5 @@ httpx==0.28.1
PyYAML==6.0.2 PyYAML==6.0.2
paramiko==3.5.0 paramiko==3.5.0
boto3==1.35.99 boto3==1.35.99
# Time zones for backup schedules: the slim base image has no system tz database.
tzdata==2026.4
+13 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -45,6 +46,7 @@ def _to_read(session: Session, s: BackupSchedule) -> ScheduleRead:
hour=s.hour, hour=s.hour,
minute=s.minute, minute=s.minute,
weekday=s.weekday, weekday=s.weekday,
timezone=s.timezone or "UTC",
include_volumes=s.include_volumes, include_volumes=s.include_volumes,
stop_first=s.stop_first, stop_first=s.stop_first,
keep=s.keep, keep=s.keep,
@@ -68,6 +70,14 @@ def _get_or_404(session: Session, schedule_id: int) -> BackupSchedule:
def _validate(session: Session, schedule: BackupSchedule) -> None: def _validate(session: Session, schedule: BackupSchedule) -> None:
if schedule.frequency not in FREQUENCIES: if schedule.frequency not in FREQUENCIES:
raise HTTPException(status_code=400, detail=f"Unknown frequency '{schedule.frequency}'") 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): if not session.get(BackupDestination, schedule.destination_id):
raise HTTPException(status_code=404, detail=f"Destination {schedule.destination_id} not found") raise HTTPException(status_code=404, detail=f"Destination {schedule.destination_id} not found")
if schedule.all_stacks: 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 [])) set_stacks(s, body.stack_ids or ([body.stack_id] if body.stack_id else []))
_validate(session, s) _validate(session, s)
s.next_run = schedule_service.compute_next_run( 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.add(s)
session.commit() session.commit()
@@ -130,9 +140,9 @@ def update_schedule(
set_stacks(s, stack_ids) set_stacks(s, stack_ids)
_validate(session, s) _validate(session, s)
# Recompute next run when timing fields change. # 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.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.add(s)
session.commit() session.commit()
+51 -18
View File
@@ -15,6 +15,7 @@ import json
import logging import logging
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -55,28 +56,57 @@ def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None) 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( 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: ) -> 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": if frequency == "hourly":
nxt = after.replace(minute=minute % 60, second=0, microsecond=0) cand = local.replace(minute=minute % 60, second=0, microsecond=0)
if nxt <= after: # 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) 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": if frequency == "weekly":
days_ahead = (weekday - base.weekday()) % 7 day += timedelta(days=(weekday - day.weekday()) % 7)
cand = base + timedelta(days=days_ahead) step = timedelta(days=7)
if cand <= after: else: # daily (default)
cand += timedelta(days=7) step = timedelta(days=1)
return cand while at(day).astimezone(timezone.utc) <= after_utc:
day += step
# daily (default) return utc(at(day))
if base <= after:
base += timedelta(days=1)
return base
def _prune(dest: BackupDestination, stack_id: str, keep: int) -> int: 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.last_run = now
schedule.next_run = compute_next_run( 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.add(schedule)
session.commit() session.commit()
@@ -241,7 +272,9 @@ async def run_due() -> None:
due = [] due = []
for s in schedules: for s in schedules:
if s.next_run is None: 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) session.add(s)
elif s.next_run <= now: elif s.next_run <= now:
due.append(s) due.append(s)
+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)) run(env.svc.run_schedule(env.session, schedule))
stored = json.loads(schedule.stack_status) stored = json.loads(schedule.stack_status)
assert stored[env.ids["a"]]["status"] == "ok" and "at" in stored[env.ids["a"]] 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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version.""" """Single source of truth for the StackPilot release version."""
APP_VERSION = "0.65.0" APP_VERSION = "0.66.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.65.0", "version": "0.66.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+3
View File
@@ -12,6 +12,8 @@ export interface BackupSchedule {
hour: number; hour: number;
minute: number; minute: number;
weekday: number; weekday: number;
/** IANA zone the time is in, e.g. "Europe/Berlin". */
timezone: string;
include_volumes: boolean; include_volumes: boolean;
stop_first: boolean; stop_first: boolean;
keep: number; keep: number;
@@ -34,6 +36,7 @@ export interface ScheduleInput {
hour: number; hour: number;
minute: number; minute: number;
weekday: number; weekday: number;
timezone: string;
include_volumes: boolean; include_volumes: boolean;
stop_first: boolean; stop_first: boolean;
keep: number; keep: number;
+36 -2
View File
@@ -77,8 +77,23 @@ export function Settings() {
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; 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 { 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 === "hourly") return `Hourly at :${String(s.minute).padStart(2, "0")}`;
if (s.frequency === "weekly") return `Weekly · ${WEEKDAYS[s.weekday] ?? "?"} ${t}`; if (s.frequency === "weekly") return `Weekly · ${WEEKDAYS[s.weekday] ?? "?"} ${t}`;
return `Daily · ${t}`; return `Daily · ${t}`;
@@ -312,6 +327,8 @@ function ScheduleForm({
hour: schedule?.hour ?? 3, hour: schedule?.hour ?? 3,
minute: schedule?.minute ?? 0, minute: schedule?.minute ?? 0,
weekday: schedule?.weekday ?? 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, include_volumes: schedule?.include_volumes ?? true,
stop_first: schedule?.stop_first ?? true, stop_first: schedule?.stop_first ?? true,
keep: schedule?.keep ?? 7, keep: schedule?.keep ?? 7,
@@ -429,7 +446,7 @@ function ScheduleForm({
)} )}
{form.frequency !== "hourly" && ( {form.frequency !== "hourly" && (
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Hour (UTC)</span> <span className="text-xs font-medium text-slate-500">Hour</span>
<Input type="number" min={0} max={23} value={form.hour} onChange={(e) => set("hour", Number(e.target.value))} /> <Input type="number" min={0} max={23} value={form.hour} onChange={(e) => set("hour", Number(e.target.value))} />
</label> </label>
)} )}
@@ -437,6 +454,23 @@ function ScheduleForm({
<span className="text-xs font-medium text-slate-500">Minute</span> <span className="text-xs font-medium text-slate-500">Minute</span>
<Input type="number" min={0} max={59} value={form.minute} onChange={(e) => set("minute", Number(e.target.value))} /> <Input type="number" min={0} max={59} value={form.minute} onChange={(e) => set("minute", Number(e.target.value))} />
</label> </label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Time zone</span>
<select
className={selectClass}
value={form.timezone}
onChange={(e) => set("timezone", e.target.value)}
>
{!TIME_ZONES.includes(form.timezone) && (
<option value={form.timezone}>{form.timezone}</option>
)}
{TIME_ZONES.map((z) => (
<option key={z} value={z}>
{z === BROWSER_TZ ? `${z} (this browser)` : z}
</option>
))}
</select>
</label>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1"> <label className="space-y-1">