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
- **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
+7 -1
View File
@@ -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
+2
View File
@@ -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
+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()
+51 -18
View File
@@ -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)
+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
+1 -1
View File
@@ -1,3 +1,3 @@
"""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",
"private": true,
"version": "0.65.0",
"version": "0.66.0",
"type": "module",
"scripts": {
"dev": "vite",
+3
View File
@@ -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;
+36 -2
View File
@@ -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" && (
<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))} />
</label>
)}
@@ -437,6 +454,23 @@ function ScheduleForm({
<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))} />
</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 className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1">