Files
mission-control-v2/backend/tests/test_wartungsfenster.py
T
HitonabiandClaude Opus 5.5 3d8df1c6fd einstellungen: Zeitfenster, Radar-Schalter, Telegram-Test, Software-Stand und Altreste
Einstellungen, die etwas einstellen (services/einstellungen.py, /api/einstellungen):
- Update-Zeitfenster: Wochentag und Uhrzeit des Update-Laufs als Drop-in
  mc2-autoupdate.timer.d/zeitfenster.conf. Gegen die Nachhol-Falle von
  Persistent=true (24.09.): Timer vor dem daemon-reload stoppen, Stempel auf
  jetzt, dann starten. Bei Fehler zurück auf das alte Drop-in. Während eines
  Update-Laufs abgelehnt.
- Neustart nur im eingestellten Fenster (deploy/wartungsfenster.sh, drei
  Stunden ab der vollen Stunde des Beginns; Standard So 04:00-06:59).
- Radar an/aus: disable --now bzw. enable + Stempel + start (kein Nachholen).
  deploy.sh respektiert den Schalter und startet den Radar-Timer nur mit
  Stempel; das Zeitfenster-Drop-in fasst er nie an.
- Wächter: schläft der Timer eines Timer-Dienstes, ist ein alter Fehlschlag
  kein Befund mehr (der Dienst selbst ist static und schläft nie).
- Telegram-Test je Instanz (/api/einstellungen/telegram-test und
  /api/homelab/einstellungen/telegram-test) über notify.sh -d; Ergebnis und
  Grund aus dem Melde-Log. notify.sh schreibt jetzt auch den Grund des
  gescheiterten Zweitwegs in den FALLBACK-Eintrag.
- Einstellungsdatei mc2-einstellungen.json im Datenordner.

Software-Stand: deploy.sh und homelab/ausrollen.sh schreiben nach dem
Umschalten mc2-stand.json (deploy/stand-schreiben.py); /api/instanz liefert
den Stand, die Einstellungen zeigen beide Instanzen und warnen bei Abweichung.
Ohne Datei aus git, sonst unbekannt.

Aufräumen: Altreste neben dem Betrieb aus einer festen, auf der Box geprüften
Liste (Größe, Hinweis, nur was da und nicht in Gebrauch ist). Löschen nur per
Kennung und Klick mit Rückfrage; Worktrees mit git worktree prune, Alt-Units
mit daemon-reload, /opt/llamacpp notfalls mit sudo -n.

Tests: pytest mit einem systemd-Abbild samt Nachhol-Regel, echtem notify.sh
(Ersatz-Hermes, Tmp-Zugang, lokale Bot-API), Shell-Funktionen des
Wartungsfensters und von deploy.sh; vitest für die Rückfragen der Schublade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:37:30 +02:00

151 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Wartungsfenster für den Neustart nach Updates (deploy/wartungsfenster.sh, seit 24.09.2026 einstellbar) und wie
deploy.sh mit den Einstellungen umgeht: Radar-Schalter respektieren, das Zeitfenster-Drop-in nie anfassen.
Die Tests fahren die echten Shell-Funktionen mit vorgetäuschter Zeit (MC_FENSTER_JETZT)."""
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from services import einstellungen
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
BASH = shutil.which("bash")
pytestmark = pytest.mark.skipif(
BASH is None or (sys.platform == "win32" and "system32" in BASH.lower()),
reason="braucht eine bash (Git-Bash oder Linux)",
)
def _fenster(befehl: str, **env: str) -> subprocess.CompletedProcess:
skript = f'. "{(DEPLOY / "wartungsfenster.sh").as_posix()}"; {befehl}'
umgebung = {k: v for k, v in os.environ.items() if k not in ("MC_WARTUNGSFENSTER", "MC_FENSTER_JETZT")}
umgebung.update({"MC_EINSTELLUNGEN": "/gibt/es/nicht.json", **env})
return subprocess.run([BASH, "-c", skript], env=umgebung, capture_output=True, text=True, encoding="utf-8",
timeout=60)
def _im_fenster(jetzt: str, fenster: str | None = None) -> bool:
env = {"MC_FENSTER_JETZT": jetzt}
if fenster:
env["MC_WARTUNGSFENSTER"] = fenster
return _fenster("im_wartungsfenster", **env).returncode == 0
@pytest.mark.parametrize("jetzt, erwartet", [
("7 04:00", True), ("7 04:40", True), ("7 06:59", True),
("7 03:59", False), ("7 07:00", False),
("4 15:00", False), # Donnerstagnachmittag: der Neustart vom 24.09.
("6 05:00", False),
])
def test_standard_ist_sonntag_frueh(jetzt, erwartet):
assert _im_fenster(jetzt) is erwartet
@pytest.mark.parametrize("fenster, jetzt, erwartet", [
("6 05:00", "6 05:00", True), ("6 05:00", "6 07:59", True), ("6 05:00", "6 08:00", False),
("6 05:00", "7 05:00", False),
("6 23:30", "6 23:00", True), ("6 23:30", "7 01:59", True), ("6 23:30", "7 02:00", False),
("6 23:30", "6 22:59", False),
("7 23:00", "1 01:30", True), ("7 23:00", "1 02:00", False), # über das Wochenende hinaus
("9 25:00", "7 04:30", True), ("9 25:00", "6 05:00", False), # Unsinn → Standard So 04:30
])
def test_eingestelltes_fenster(fenster, jetzt, erwartet):
assert _im_fenster(jetzt, fenster) is erwartet
@pytest.mark.parametrize("tag, uhrzeit", [(7, "04:30"), (6, "05:00"), (6, "23:30"), (7, "22:00"), (1, "00:00"),
(3, "21:30")])
def test_text_wie_in_der_oberflaeche(tag, uhrzeit):
lauf = _fenster("fenster_text", MC_WARTUNGSFENSTER=f"{tag} {uhrzeit}")
assert lauf.returncode == 0, lauf.stderr
assert lauf.stdout == einstellungen.neustart_fenster(tag, uhrzeit)
@pytest.mark.skipif(shutil.which("jq") is None, reason="jq fehlt hier (auf der Box ist es da)")
def test_fenster_aus_der_einstellungsdatei(tmp_path):
datei = tmp_path / "mc2-einstellungen.json"
datei.write_text(json.dumps({"update_fenster": {"tag": 6, "uhrzeit": "05:00"}}), encoding="utf-8")
lauf = _fenster("fenster_text", MC_EINSTELLUNGEN=datei.as_posix())
assert lauf.stdout == "Samstag 05:00–07:59"
def test_ohne_datei_gilt_sonntag():
assert _fenster("fenster_text").stdout == "Sonntag 04:00–06:59"
def test_autoupdate_nutzt_das_fenster():
skript = (DEPLOY / "autoupdate.sh").read_text(encoding="utf-8")
assert '. "$SRC_DIR/wartungsfenster.sh"' in skript
assert "! im_wartungsfenster" in skript and "$(fenster_text)" in skript
assert '[ "$tag" != "7" ]' not in skript # die alte, feste Sonntagsregel ist raus
# --- deploy.sh und die Einstellungen ------------------------------------------------------------------
def _befehlszeilen(skript: str) -> list[str]:
return [z for z in skript.splitlines() if z.strip() and not z.strip().startswith("#")]
def test_deploy_fasst_das_zeitfenster_nicht_an():
"""Das Drop-in gehört der Oberfläche: kein Befehl in deploy.sh nennt es, keiner räumt Drop-in-Ordner weg."""
for zeile in _befehlszeilen((DEPLOY / "deploy.sh").read_text(encoding="utf-8")):
assert "mc2-autoupdate.timer.d" not in zeile and "zeitfenster" not in zeile, zeile
assert not re.search(r"\brm\b.*(\.d\b|systemd/user)", zeile), zeile
def test_deploy_respektiert_den_radar_schalter():
skript = (DEPLOY / "deploy.sh").read_text(encoding="utf-8")
stufe2 = skript[skript.index("stufe2() {"):skript.index("homelab_mitziehen() {")]
assert "if radar_aus; then" in stufe2 and 'aktiv="${aktiv//mc2-radar.timer/}"' in stufe2
assert "systemctl --user disable --now mc2-radar.timer" in stufe2
assert "systemctl --user enable $aktiv" in stufe2 and "enable $AKTIV" not in stufe2
# Kein Start des Radars ohne den Schalter und ohne den Stempel gegen das Nachholen.
for zeile in _befehlszeilen(stufe2):
if "systemctl --user start" in zeile and "mc2-radar.timer" in zeile:
assert zeile.strip() == "systemctl --user start mc2-radar.timer"
start = stufe2.index("systemctl --user start mc2-radar.timer")
assert stufe2.rindex('touch "$HOME/.local/share/systemd/timers/stamp-mc2-radar.timer"', 0, start) > \
stufe2.rindex('[ "$radar_an" = 1 ]', 0, start)
@pytest.fixture
def radar_aus(tmp_path):
"""Die echte Funktion radar_aus aus deploy.sh, mit einem python3, das es auch am PC gibt."""
skript = (DEPLOY / "deploy.sh").read_text(encoding="utf-8")
funktion = re.search(r"^radar_aus\(\) \{\n.*?^\}\n", skript, re.DOTALL | re.MULTILINE).group(0)
shim = tmp_path / "bin"
shim.mkdir()
(shim / "python3").write_text(f'#!/bin/sh\nexec "{Path(sys.executable).as_posix()}" "$@"\n',
encoding="utf-8", newline="\n")
(shim / "python3").chmod(0o755)
def pruefen(inhalt: str | None) -> bool:
datei = tmp_path / "mc2-einstellungen.json"
datei.unlink(missing_ok=True)
if inhalt is not None:
datei.write_text(inhalt, encoding="utf-8")
lauf = subprocess.run([BASH, "-c", f'{funktion}\nradar_aus'], capture_output=True, text=True, timeout=60,
env={**os.environ, "EINSTELLUNGEN": datei.as_posix(),
"PATH": f"{shim}{os.pathsep}{os.environ.get('PATH', '')}"})
return lauf.returncode == 0
return pruefen
@pytest.mark.parametrize("inhalt, aus", [
(None, False), # keine Datei: an, wie vor dem Schalter
('{"radar": {"an": false}}', True),
('{"radar": {"an": true}}', False),
('{"update_fenster": {"tag": 6, "uhrzeit": "05:00"}}', False),
("kaputt", False), ("[]", False), ('{"radar": "aus"}', False),
])
def test_radar_aus_liest_den_schalter(radar_aus, inhalt, aus):
assert radar_aus(inhalt) is aus