server-up/server-up/core/scheduler.py
Ramon 87fb9d19d1
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s
v0.7.50-beta - meldingen, schijfruimte, backups controleren en extern wegzetten
Het onbewaakte deel was de zwakke plek: geplande taken draaiden 's nachts en
een storing kwam alleen in het auditlog terecht.

Meldingen (core/notify.py):
- ntfy, webhook (Discord/Slack/Gotify) en e-mail, alle drie met de
  standaardbibliotheek.
- Gebeurtenissen: mislukte backup, onleesbaar archief, vastgelopen taak,
  weinig schijfruimte, beschikbare update, gestopte container.
- Eén bericht per ronde; bij schijfruimte en gestopte containers alleen bij
  de overgang, zodat je niet elk uur hetzelfde krijgt.
- Testknop die eerst opslaat, zodat je test wat je net hebt ingevuld.
- send() gooit nooit: het kanaal mag de taak die de melding veroorzaakte niet
  alsnog laten omvallen.

Schijfruimte (core/diskspace.py):
- Controle vóór elke backup; past het niet, dan weigeren in plaats van
  halverwege afbreken.
- DISK_MIN_FREE_GB blijft gereserveerd, DISK_WARN_PCT kleurt de balk rood.
- Per filesystem één regel in de backuplijst.

Backups controleren:
- Elk nieuw archief wordt helemaal uitgelezen (tar-structuur plus
  gzip-checksum, die aan het eind staat).
- Diepe variant pakt echt uit naar een tijdelijke map, met dezelfde
  beperkingen als een echt herstel.
- Resultaat staat in de metadata en als schildje in de lijst.

Backups de deur uit:
- Downloadknop.
- BACKUP_OFFSITE_DIR kopieert elke nieuwe backup naar een gemounte schijf,
  NFS- of SMB-share, via .part zodat een afgebroken kopie herkenbaar onaf is.
- Ruimtecontrole op de bestemming, want een weggevallen mount laat vaak een
  lege map op de systeemschijf achter.

NOTIFY_TOKEN en NOTIFY_EMAIL_PASSWORD zijn write-only.

Nieuw: docs/meldingen.md; docs/backups.md uitgebreid. Getest tegen een
draaiende server met een echte ontvanger. 1064 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-28 19:41:35 +02:00

128 lines
4.5 KiB
Python

"""Periodieke taken in één achtergrondthread.
Geen cron en geen extra dependency: één daemon-thread die elke paar minuten
kijkt of er iets te doen is. De laatste uitvoering staat in `config.json`, dus
een herstart laat de planning niet opnieuw beginnen — en een taak die door een
herstart gemist is, loopt bij de eerstvolgende tick alsnog.
Taken registreren zich met `register()`; `start()` zet de lus in gang.
"""
from __future__ import annotations
import threading
import time
import traceback
from datetime import datetime
import core as cfg
TICK_SECONDS = 300 # elke 5 minuten kijken of er iets moet
_TAKEN: dict[str, dict] = {}
_LOCK = threading.Lock()
_gestart = False
def register(naam: str, fn, interval_uren: float = 24.0, enabled_key: str = ""):
"""Registreer een periodieke taak.
`fn` retourneert **True** als het werk daadwerkelijk gedaan is, en False of
None als het moment nog niet daar was. Alleen bij True wordt de taak
afgestempeld. Dat onderscheid is essentieel voor taken die zelf nog een
tijdvenster bewaken: zou een overgeslagen taak ook afgestempeld worden, dan
verschuift het volgende moment naar precies dat verkeerde tijdstip en komt
de taak nooit meer aan de beurt.
`enabled_key` verwijst naar een config-sleutel; staat die op iets falsy, dan
wordt de taak overgeslagen (maar blijft hij geregistreerd).
"""
with _LOCK:
_TAKEN[naam] = {"fn": fn, "interval": max(0.05, float(interval_uren)),
"enabled_key": enabled_key}
def _laatste() -> dict:
d = cfg.load().get("SCHEDULER_LAST") or {}
return d if isinstance(d, dict) else {}
def _markeer(naam: str):
d = dict(_laatste())
d[naam] = time.time()
cfg.patch({"SCHEDULER_LAST": d})
def due(naam: str, taak: dict, nu: float | None = None) -> bool:
"""Moet deze taak nu draaien?"""
nu = nu if nu is not None else time.time()
if taak.get("enabled_key") and not cfg.load().get(taak["enabled_key"]):
return False
laatste = _laatste().get(naam)
if not laatste:
return True
return (nu - float(laatste)) >= taak["interval"] * 3600
def run_due(log_fn=None) -> list[str]:
"""Draai alle taken die aan de beurt zijn. Retourneert de namen."""
gedraaid = []
with _LOCK:
items = list(_TAKEN.items())
for naam, taak in items:
try:
if not due(naam, taak):
continue
if log_fn:
log_fn(f"[scheduler] {naam}")
if taak["fn"]() is not True:
continue # overgeslagen: niet afstempelen
_markeer(naam)
gedraaid.append(naam)
except Exception as e:
# Eén kapotte taak mag de lus niet stoppen. Wél afstempelen, anders
# blijft een structureel falende taak elke tick opnieuw proberen.
spoor = traceback.format_exc()
print(f" [scheduler] {naam} faalde:\n{spoor}")
_markeer(naam)
# Een taak die stilletjes faalt en netjes wordt afgestempeld, is
# precies het geval waarin je nooit merkt dat er niets meer gebeurt.
try:
from core import notify
notify.send("job_failed", f"Geplande taak '{naam}' mislukt",
f"{type(e).__name__}: {e}\n\n{spoor[-1500:]}")
except Exception:
pass
return gedraaid
def _lus():
# Even wachten tot de app volledig op is.
time.sleep(30)
while True:
try:
run_due()
except Exception:
print(f" [scheduler] tick faalde:\n{traceback.format_exc()}")
time.sleep(TICK_SECONDS)
def start():
"""Start de achtergrondlus (eenmalig)."""
global _gestart
if _gestart:
return
_gestart = True
threading.Thread(target=_lus, daemon=True, name="su-scheduler").start()
# ── Hulp voor dagelijkse taken op een vast tijdstip ──────────────────────────
def binnen_venster(uur: int, marge_minuten: int = 30, nu: datetime | None = None) -> bool:
"""True als de huidige tijd binnen `marge` minuten ná `uur`:00 ligt.
Voor 'draai dit rond 03:00'. De tick is 5 minuten, dus een venster van een
half uur vangt ruim af — combineer met de interval-check zodat een taak niet
meerdere keren in hetzelfde venster draait.
"""
nu = nu or datetime.now()
minuten_na = (nu.hour - uur) * 60 + nu.minute
return 0 <= minuten_na < marge_minuten