"""Download-Archiv: sichert die EPIRent-Pakete je Versionsnummer. Die Dateien sind groß (Server ~923 MB, Client ~218 MB, zusammen ~1,1 GB pro Version). Deshalb: * Download läuft im Hintergrund-Thread (Ticketabruf blockiert nicht), * gestreamt auf Platte (kein Speicherverbrauch), * Platzprüfung vorab, * Aufbewahrung begrenzt (EPIRENT_DOWNLOAD_KEEP, Standard 3 Stände). """ import hashlib import logging import shutil import threading from datetime import datetime, timezone from pathlib import Path from urllib.parse import urlparse import requests from . import config, db, notify log = logging.getLogger("episupport.downloads") DOWNLOAD_DIR = config.DATA_DIR / "downloads" _lock = threading.Lock() _running = False def _now() -> str: return datetime.now(timezone.utc).isoformat() def filename_for(url: str) -> str: name = Path(urlparse(url).path).name return name or "download.bin" def version_dir(version: str) -> Path: # Versionsnummern sind rein numerisch – zur Sicherheit trotzdem säubern safe = "".join(ch for ch in str(version) if ch.isalnum() or ch in "._-") return DOWNLOAD_DIR / (safe or "unbekannt") def archived(version: str) -> list[dict]: with db.get_conn() as conn: rows = conn.execute( "SELECT * FROM downloads WHERE version=? ORDER BY filename", (str(version),)).fetchall() return [dict(r) for r in rows] def is_complete(version: str) -> bool: """Sind alle konfigurierten Dateien für diese Version vorhanden?""" want = {filename_for(u) for u in config.EPIRENT_DOWNLOAD_URLS} have = {r["filename"] for r in archived(version) if (version_dir(version) / r["filename"]).exists()} return bool(want) and want.issubset(have) def history() -> list[dict]: """Alle archivierten Versionen mit Dateien und Gesamtgröße.""" with db.get_conn() as conn: rows = conn.execute( "SELECT * FROM downloads ORDER BY downloaded_at DESC, filename" ).fetchall() by_version: dict[str, dict] = {} for r in rows: v = by_version.setdefault(r["version"], { "version": r["version"], "files": [], "total": 0, "downloaded_at": r["downloaded_at"]}) v["files"].append(dict(r)) v["total"] += r["size"] or 0 return list(by_version.values()) def human_size(n) -> str: if not n: return "–" n = float(n) for unit in ("B", "KB", "MB", "GB"): if n < 1024 or unit == "GB": return f"{n:.0f} {unit}" if unit in ("B", "KB") else f"{n:.1f} {unit}".replace(".", ",") n /= 1024 return f"{n:.1f} GB" # ------------------------------------------------------------------ Download def _remote_info(url: str) -> tuple[int, str | None]: """(Größe, Last-Modified) per HEAD ermitteln.""" r = requests.head(url, timeout=30, allow_redirects=True, headers={"User-Agent": "episupport-monitor/1.0"}) r.raise_for_status() return int(r.headers.get("Content-Length") or 0), r.headers.get("Last-Modified") def _download(url: str, dest: Path) -> tuple[int, str]: """Streamend herunterladen; gibt (Größe, sha256) zurück.""" tmp = dest.with_suffix(dest.suffix + ".part") h = hashlib.sha256() size = 0 with requests.get(url, stream=True, timeout=(30, 300), headers={"User-Agent": "episupport-monitor/1.0"}) as r: r.raise_for_status() with open(tmp, "wb") as f: for chunk in r.iter_content(chunk_size=1024 * 1024): if not chunk: continue f.write(chunk) h.update(chunk) size += len(chunk) tmp.replace(dest) return size, h.hexdigest() def _enough_space(needed: int) -> bool: DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) free = shutil.disk_usage(DOWNLOAD_DIR).free if free < needed * 1.2: log.error("Zu wenig Speicherplatz: benötigt ~%s, frei %s.", human_size(needed), human_size(free)) return False return True def fetch_version(version: str) -> list[dict]: """Fehlende Dateien dieser Version holen. Gibt die neu geholten zurück.""" urls = config.EPIRENT_DOWNLOAD_URLS if not urls: return [] target = version_dir(version) target.mkdir(parents=True, exist_ok=True) todo = [] for url in urls: name = filename_for(url) if (target / name).exists() and any( r["filename"] == name for r in archived(version)): continue todo.append((url, name)) if not todo: return [] # Platzbedarf vorab prüfen needed = 0 infos = {} for url, name in todo: try: size, modified = _remote_info(url) except requests.RequestException as e: log.warning("HEAD fehlgeschlagen für %s: %s", url, e) size, modified = 0, None infos[name] = (size, modified) needed += size if needed and not _enough_space(needed): return [] got = [] for url, name in todo: dest = target / name try: log.info("Lade %s (%s) für Version %s …", name, human_size(infos[name][0]), version) size, digest = _download(url, dest) with db.get_conn() as conn: conn.execute( "INSERT OR REPLACE INTO downloads (version, filename, url, " "size, sha256, remote_modified, downloaded_at) " "VALUES (?,?,?,?,?,?,?)", (str(version), name, url, size, digest, infos[name][1], _now())) got.append({"filename": name, "size": size, "sha256": digest}) log.info("Fertig: %s (%s)", name, human_size(size)) except (requests.RequestException, OSError) as e: log.error("Download %s fehlgeschlagen: %s", url, e) try: (target / (name + ".part")).unlink(missing_ok=True) except OSError: pass return got def cleanup(keep: int | None = None) -> list[str]: """Alte Versionsstände löschen, nur die neuesten `keep` behalten. `keep = 0` (oder negativ) bedeutet: unbegrenzt aufbewahren – es wird nichts gelöscht. """ keep = config.EPIRENT_DOWNLOAD_KEEP if keep is None else keep if keep <= 0: return [] versions = [v["version"] for v in history()] removed = [] for v in versions[keep:]: d = version_dir(v) shutil.rmtree(d, ignore_errors=True) with db.get_conn() as conn: conn.execute("DELETE FROM downloads WHERE version=?", (v,)) removed.append(v) log.info("Alten Download-Stand %s entfernt.", v) return removed # --------------------------------------------------------------- Hintergrund def archive_async(version: str) -> None: """Archivierung im Hintergrund starten (max. eine gleichzeitig).""" global _running if not config.EPIRENT_DOWNLOAD_ENABLED or not version: return with _lock: if _running: log.info("Archivierung läuft bereits – überspringe.") return _running = True def _work(): global _running try: got = fetch_version(version) removed = cleanup() if got: _report(version, got, removed) except Exception: # noqa: BLE001 log.exception("Archivierung fehlgeschlagen") finally: with _lock: _running = False threading.Thread(target=_work, name="epirent-archive", daemon=True).start() def _report(version: str, got: list[dict], removed: list[str]) -> None: """Erfolgreiche Archivierung als Änderung erfassen und mailen.""" parts = [f"{g['filename']} ({human_size(g['size'])})" for g in got] detail = ", ".join(parts) if removed: detail += f" · alte Stände entfernt: {', '.join(removed)}" change = { "ticket_number": None, "ticket_title": "EPIRent Download-Archiv", "field": "epirent_download", "label": "Downloads gesichert", "old_value": None, "new_value": f"Version {version}", "detail": detail, } with db.get_conn() as conn: conn.execute( "INSERT INTO changes (ticket_id, ticket_number, ticket_title, field, " "label, old_value, new_value, detail, detected_at) " "VALUES (?,?,?,?,?,?,?,?,?)", (0, None, change["ticket_title"], change["field"], change["label"], None, change["new_value"], detail, _now())) try: notify.send_change_mail([change]) except Exception: # noqa: BLE001 log.exception("Mail zur Archivierung fehlgeschlagen")