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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a0d673798
commit
9a357d2b92
10 files changed
+192
-27
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.65.0"
|
||||
APP_VERSION = "0.66.0"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.65.0",
|
||||
"version": "0.66.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user