Coverage for app/services/backup_scheduler.py: 100%
146 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1"""Built-in daily backup scheduling and retention.
3OPENHANGAR_BACKUP_TIME (HH:MM UTC, empty = disabled) runs the existing
4run_backup() from a daemon thread once a day, guarded by the same advisory
5lock mechanism as the other schedulers so only one gunicorn worker produces
6a ZIP per tick. After every *successful* backup, retention prunes per
7OPENHANGAR_BACKUP_RETENTION: 'simple' (default) keeps the newest
8OPENHANGAR_BACKUP_KEEP backups; 'gfs' keeps everything for
9OPENHANGAR_BACKUP_KEEP_DAYS days, then the newest backup per week for
10OPENHANGAR_BACKUP_KEEP_WEEKS weeks, per month for
11OPENHANGAR_BACKUP_KEEP_MONTHS months, then per year forever. A failed
12backup never triggers pruning, so a broken pipeline cannot silently erase
13the archives that still exist.
14"""
16import logging
17import os
18from datetime import UTC, date, timedelta
19from typing import Any
21log = logging.getLogger(__name__)
23# See the lock id registry in services/advisory_lock.py.
24BACKUP_LOCK_ID = 7283910460
26DEFAULT_KEEP = 30
29def parse_backup_time() -> "tuple[int, int] | None":
30 """Return (hour, minute) from OPENHANGAR_BACKUP_TIME, or None when unset.
32 Raises ValueError with a human-readable message if the value is invalid.
33 """
34 raw = os.environ.get("OPENHANGAR_BACKUP_TIME", "").strip()
35 if not raw:
36 return None
37 err = (
38 f"OPENHANGAR_BACKUP_TIME={raw!r} is invalid — expected HH:MM UTC "
39 f"(e.g. '03:30'), or empty to disable scheduled backups"
40 )
41 parts = raw.split(":")
42 if len(parts) != 2:
43 raise ValueError(err)
44 try:
45 hour, minute = int(parts[0]), int(parts[1])
46 except ValueError:
47 raise ValueError(err)
48 if not (0 <= hour <= 23 and 0 <= minute <= 59):
49 raise ValueError(err)
50 return hour, minute
53def _parse_positive_int(env_name: str, default: int) -> int:
54 """Positive-integer env var with a default; ValueError on bad values."""
55 raw = os.environ.get(env_name, "").strip()
56 if not raw:
57 return default
58 err = f"{env_name}={raw!r} is invalid — expected a positive integer"
59 try:
60 value = int(raw)
61 except ValueError:
62 raise ValueError(err)
63 if value < 1:
64 raise ValueError(err)
65 return value
68def parse_backup_keep() -> int:
69 """Retention count for the 'simple' scheme (OPENHANGAR_BACKUP_KEEP)."""
70 return _parse_positive_int("OPENHANGAR_BACKUP_KEEP", DEFAULT_KEEP)
73RETENTION_SIMPLE = "simple"
74RETENTION_GFS = "gfs"
76DEFAULT_KEEP_DAYS = 7
77DEFAULT_KEEP_WEEKS = 4
78DEFAULT_KEEP_MONTHS = 12
81def parse_backup_retention() -> str:
82 """Retention scheme from OPENHANGAR_BACKUP_RETENTION (default 'simple').
84 'simple' keeps the newest OPENHANGAR_BACKUP_KEEP backups; 'gfs' keeps
85 everything for OPENHANGAR_BACKUP_KEEP_DAYS days, then the newest backup
86 per week for OPENHANGAR_BACKUP_KEEP_WEEKS weeks, per month for
87 OPENHANGAR_BACKUP_KEEP_MONTHS months, and per year forever.
88 """
89 raw = os.environ.get("OPENHANGAR_BACKUP_RETENTION", "").strip().lower()
90 if not raw:
91 return RETENTION_SIMPLE
92 if raw not in (RETENTION_SIMPLE, RETENTION_GFS):
93 raise ValueError(
94 f"OPENHANGAR_BACKUP_RETENTION={raw!r} is invalid — expected "
95 f"'{RETENTION_SIMPLE}' or '{RETENTION_GFS}'"
96 )
97 return raw
100def parse_backup_keep_days() -> int:
101 return _parse_positive_int("OPENHANGAR_BACKUP_KEEP_DAYS", DEFAULT_KEEP_DAYS)
104def parse_backup_keep_weeks() -> int:
105 return _parse_positive_int("OPENHANGAR_BACKUP_KEEP_WEEKS", DEFAULT_KEEP_WEEKS)
108def parse_backup_keep_months() -> int:
109 return _parse_positive_int("OPENHANGAR_BACKUP_KEEP_MONTHS", DEFAULT_KEEP_MONTHS)
112def _gfs_keep_ids(
113 ok_records: "list[Any]", today: "date", days: int, weeks: int, months: int
114) -> "set[int]":
115 """Grandfather-father-son keep-set over newest-first successful backups.
117 Everything younger than `days` days is kept. Beyond that, the newest
118 backup of each ISO week is kept for the first `weeks` distinct weeks,
119 then the newest per calendar month for `months` distinct months, then
120 the newest per calendar year forever. Counting distinct periods (like
121 restic's --keep-weekly) means gaps in the schedule never shrink the
122 retained history.
123 """
124 keep: set[int] = set()
125 weeks_seen: set[tuple[int, int]] = set()
126 months_seen: set[tuple[int, int]] = set()
127 years_seen: set[int] = set()
128 daily_cutoff = today - timedelta(days=days)
129 for record in ok_records:
130 created = record.created_at.date()
131 if created >= daily_cutoff:
132 keep.add(record.id)
133 continue
134 iso = created.isocalendar()
135 wkey = (iso[0], iso[1])
136 if wkey in weeks_seen:
137 continue # this week is already represented by a newer backup
138 if len(weeks_seen) < weeks:
139 weeks_seen.add(wkey)
140 keep.add(record.id)
141 continue
142 mkey = (created.year, created.month)
143 if mkey in months_seen:
144 continue
145 if len(months_seen) < months:
146 months_seen.add(mkey)
147 keep.add(record.id)
148 continue
149 if created.year not in years_seen:
150 years_seen.add(created.year)
151 keep.add(record.id)
152 return keep
155def prune_old_backups(keep: "int | None" = None, today: "date | None" = None) -> int:
156 """Delete successful backups that fall outside the retention scheme.
158 With an explicit `keep` (or OPENHANGAR_BACKUP_RETENTION=simple, the
159 default) the newest `keep` backups survive. With the 'gfs' scheme the
160 day/week/month/year tiers decide. Returns the number of records
161 removed. A record whose file cannot be deleted is kept so the operator
162 can still see (and clean up) the stranded archive. Failed records
163 carry no archive and are left alone.
164 """
165 from models import BackupRecord, db # pyright: ignore[reportMissingImports]
167 ok_records = (
168 BackupRecord.query.filter_by(status="ok")
169 .order_by(BackupRecord.created_at.desc(), BackupRecord.id.desc())
170 .all()
171 )
172 if keep is None and parse_backup_retention() == RETENTION_GFS:
173 keep_ids = _gfs_keep_ids(
174 ok_records,
175 today or date.today(),
176 parse_backup_keep_days(),
177 parse_backup_keep_weeks(),
178 parse_backup_keep_months(),
179 )
180 to_delete = [r for r in ok_records if r.id not in keep_ids]
181 else:
182 if keep is None:
183 keep = parse_backup_keep()
184 to_delete = ok_records[keep:]
186 removed = 0
187 for record in to_delete:
188 try:
189 if record.path and os.path.exists(record.path):
190 os.remove(record.path)
191 except OSError:
192 log.warning(
193 "Backup retention: could not delete %s — keeping its record",
194 record.path,
195 )
196 continue
197 db.session.delete(record)
198 removed += 1
199 db.session.commit()
200 return removed
203def run_scheduled_backup(app: "object") -> None:
204 """One scheduled tick: back up, then prune retention on success only."""
205 from models import db # pyright: ignore[reportMissingImports]
207 from services.advisory_lock import (
208 advisory_lock_scope, # pyright: ignore[reportMissingImports]
209 )
211 with app.app_context(): # type: ignore[attr-defined]
212 try:
213 with advisory_lock_scope(db, BACKUP_LOCK_ID) as acquired:
214 if not acquired:
215 log.info(
216 "Scheduled backup: another worker holds the lock — skipping"
217 )
218 return
219 from config.routes import (
220 run_backup, # pyright: ignore[reportMissingImports]
221 )
223 try:
224 record = run_backup()
225 except RuntimeError:
226 log.exception("Scheduled backup failed — retention pruning skipped")
227 return
228 log.info("Scheduled backup OK: %s", record.filename)
229 from services.backup_verification import (
230 verify_and_alert, # pyright: ignore[reportMissingImports]
231 )
233 verify_and_alert(record)
234 removed = prune_old_backups()
235 if removed:
236 log.info("Backup retention: pruned %d old backup(s)", removed)
237 except Exception:
238 log.exception("Error in scheduled backup run")
241def _backup_daily_loop(app: "object", run_hour: int, run_minute: int) -> None:
242 import time
243 from datetime import datetime, timedelta
245 log.info("Backup scheduled daily at %02d:%02d UTC", run_hour, run_minute)
246 while True:
247 now = datetime.now(UTC)
248 next_run = now.replace(
249 hour=run_hour, minute=run_minute, second=0, microsecond=0
250 )
251 if next_run <= now:
252 next_run += timedelta(days=1)
253 time.sleep((next_run - datetime.now(UTC)).total_seconds())
254 run_scheduled_backup(app)
257def start_backup_scheduler(app: "object") -> None:
258 """Start the daily backup thread when OPENHANGAR_BACKUP_TIME is set."""
259 import threading
261 schedule = parse_backup_time()
262 if schedule is None:
263 log.info("OPENHANGAR_BACKUP_TIME not set — built-in backup scheduling disabled")
264 return
265 threading.Thread(
266 target=_backup_daily_loop,
267 args=(app, schedule[0], schedule[1]),
268 daemon=True,
269 name="backup-scheduler",
270 ).start()