From 88ec4fd40ff25eb013ca33314f7fbb6d2643e82e Mon Sep 17 00:00:00 2001 From: Pascal Michel Date: Sun, 11 Oct 2026 23:25:38 +0200 Subject: [PATCH] Trading-Plattform-Pruefung: Host nicht erreichbar gibt eine Zeile, --tag frueh geprueft (Paket NT, Task 729) - abendkontrolle.py: Exit 255 oder Zeitlimit bei Summe oder Bestaetigung beendet die Sicherung mit genau einer Zeile in der Notiz von heute und Exit 1; ein Abbruch im Archiv laesst nur die Instanz scheitern. --tag ausser der Form endet gleich mit Exit 2, ohne Notiz und ssh. - abendkontrolle_dockerhost_angriffe.py: sieben neue Faelle, zwei angepasste, Mutanten E729-01 bis E729-15 und E729-25 (38 Faelle, 56 Mutanten, keiner ueberlebt). - abendkontrolle_wsl.py: Kopf nennt die neuen fehler-Gruende. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ --- .../abendkontrolle.py | 92 +++++- .../abendkontrolle_dockerhost_angriffe.py | 288 +++++++++++++++++- .../abendkontrolle_wsl.py | 3 +- 3 files changed, 355 insertions(+), 28 deletions(-) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py index beebdc0..c8e062f 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py @@ -35,6 +35,10 @@ die Summendatei auf D: fehlt oder abweicht, -.tar ueber denselben pruefen, nach SICHERUNG_ZIEL//.tar umbenennen, beim Host bestaetigen, zuletzt .tar.sha256 schreiben; eine Zeile in der Notiz je Tag mit Neuem. Solange SICHERUNG_ERSTER_TAG None ist, holt sie nichts. Den Abend-Ausloeser legt abendkontrolle_einrichten.ps1 in Ortszeit an (S10). +Seit Paket NT (Quorum 4): Endet die Frage nach einer Summe oder die Bestaetigung mit ssh-Exit 255 oder +im Zeitlimit, ist der Docker-Host nicht erreichbar, und die Sicherung hoert sofort auf: eine Zeile in der +Notiz von heute, Exit 1. Bricht nur das Archiv so ab, scheitert allein diese Instanz. --tag ausser der +Form JJJJ-MM-TT oder ohne gueltiges Datum endet gleich nach dem Zerlegen mit Exit 2, ohne Notiz und ssh. Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl ] [--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] [--sicherung-ziel ] """ @@ -104,7 +108,7 @@ SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung") SICHERUNG_ERSTER_TAG: date | None = None # So viele Kalendertage vor heute fragt jeder Lauf, dazu heute. Das Fenster liegt in der Aufbewahrung # des Hosts: Er haelt die neuesten zehn Sicherungstage, nur werktags gesichert, also gut zwei Kalenderwochen. -# (S3). Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026 +# Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026 # und vom 14.3. bis 27.3.2027 schon 21:45. Die Aufgabe startet 23:15 Ortszeit (S10); fehlt die # Sicherung von heute trotzdem noch, ist das kein Fehlschlag. SICHERUNG_NACHHOLEN_TAGE = 10 @@ -119,6 +123,20 @@ SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n") TAGFORM = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}") +def tag_gueltig(tag: str) -> bool: + """Ist tag ein Datum JJJJ-MM-TT, das es gibt, nur mit ASCII-Ziffern? Seit Paket NT fuer --tag in main. + + TAGFORM allein liesse 2026-13-05 durch, date.fromisoformat allein seit Python 3.11 auch 20261005. + """ + if not TAGFORM.fullmatch(tag): + return False + try: + date.fromisoformat(tag) + except ValueError: + return False + return True + + def faellige_tage(jetzt: datetime, ablage: Path, erster_tag: date = ERSTER_TAG, endung: str = "") -> list[str]: tage = [] for zurueck in range(NACHHOLEN_TAGE, -1, -1): @@ -223,16 +241,36 @@ def summe_von(datei: Path) -> str: return summe.hexdigest() +class HostNichtErreichbar(Exception): + """ssh selbst scheiterte (Exit 255) oder ein Befehl der Sicherung lief in sein Zeitlimit (seit Paket NT). + + Beim Archiv macht hole_sicherung daraus den Fehlschlag dieser einen Instanz. + """ + + def ssh_lauf(befehl: str, timeout, stdout=None) -> subprocess.CompletedProcess: """Ein Befehl der Sicherung beim Host. Mit stdout (offene Datei) geht die Ausgabe dorthin. - Eigene Funktion, damit die Angriffsfaelle Zeitlimit und Dauer jedes Befehls sehen. + Eigene Funktion, damit die Angriffsfaelle Zeitlimit und Dauer jedes Befehls sehen. Seit Paket NT + (Quorum 4, G1): abendkontrolle-lesen.sh endet mit 0 bis 4. Exit 255 meldet ssh selbst: keine + Verbindung oder Anmeldung, aber auch ein Befehl, den auf dem Host ein Signal beendet. Er und ein + abgelaufenes Zeitlimit heissen: Der Docker-Host ist nicht erreichbar (HostNichtErreichbar). """ - if stdout is None: - return subprocess.run(ssh_aufruf(befehl), capture_output=True, timeout=timeout, creationflags=KEIN_FENSTER) - return subprocess.run( - ssh_aufruf(befehl), stdout=stdout, stderr=subprocess.PIPE, timeout=timeout, creationflags=KEIN_FENSTER, - ) + try: + if stdout is None: + lauf = subprocess.run( + ssh_aufruf(befehl), capture_output=True, timeout=timeout, creationflags=KEIN_FENSTER, + ) + else: + lauf = subprocess.run( + ssh_aufruf(befehl), stdout=stdout, stderr=subprocess.PIPE, timeout=timeout, + creationflags=KEIN_FENSTER, + ) + except subprocess.TimeoutExpired: + raise HostNichtErreichbar(f"{befehl}: Zeitlimit {timeout} s abgelaufen") from None + if lauf.returncode == 255: + raise HostNichtErreichbar(f"{befehl}: {fehlerzeile(lauf)}") + return lauf def summe_auf_d(ordner: Path, instanz: str) -> str | None: @@ -265,10 +303,8 @@ def sicherungstage(jetzt: datetime, tag: str | None = None) -> list[str]: if erster is None: return [] if tag is not None: - # Der Tag wird Teil eines Pfads und eines Befehls auf dem Host: nur JJJJ-MM-TT. - if not TAGFORM.fullmatch(tag): - raise ValueError(f"--tag {tag!r} ist kein Datum JJJJ-MM-TT") - date.fromisoformat(tag) + # Der Tag wird Teil eines Pfads und eines Befehls auf dem Host. Seine Form (JJJJ-MM-TT, ein Datum, + # das es gibt) prueft seit Paket NT main gleich nach dem Zerlegen der Argumente (tag_gueltig). return [tag] tage = [] for zurueck in range(SICHERUNG_NACHHOLEN_TAGE, -1, -1): @@ -337,7 +373,14 @@ def hole_sicherung(tag: str, instanz: str, ziel: Path, summe: str) -> str: summendatei = ordner / f"{instanz}.tar.sha256" try: with teil.open("wb") as strom: - lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom) + try: + lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom) + except HostNichtErreichbar as abbruch: + # Seit Paket NT (Plankritik, Befund 1): Mitten im Archiv heissen Zeitlimit und Exit 255 nicht, + # dass der Host fehlt; er hat die Summe gerade beantwortet. Nur diese Instanz scheitert, und + # ein einzelnes zu langsames Archiv haelt nicht jeden Lauf an derselben Stelle an. Ist der + # Host wirklich weg, merkt es die naechste Frage nach einer Summe. + raise RuntimeError(f"Archiv nicht geholt ({abbruch})") from None if lauf.returncode != 0: raise RuntimeError(f"Archiv nicht geholt ({fehlerzeile(lauf)})") if summe_von(teil) != summe: @@ -393,6 +436,24 @@ def hole_sicherungen(jetzt: datetime, vault: Path, ablage: Path, ziel: Path, ins if geholt: getan = True logge(ablage, f"sicherung {kandidat}-{instanz}: abgelegt und bestaetigt") + except HostNichtErreichbar as ausnahme: + # Seit Paket NT (Quorum 4, G1): Ohne Host scheiterte sonst jeder Tag des Fensters mit eigener + # Zeile, auch in neu angelegten Notizen von Wochenenden. Die Sicherung hoert hier auf: eine + # Zeile in der Notiz von heute, Exit 1. Fertige Tage behalten ihre Zeilen; was der + # angefangene Tag schon hat, nennt diese Zeile. Auf D: entsteht danach nichts mehr. + logge(ablage, f"sicherung {kandidat}-{instanz}: Docker-Host nicht erreichbar ({ausnahme}), Sicherung abgebrochen") + zeile = ( + f"- {jetzt:%H:%M} Uhr — {SICHERUNG_KOPF}: **gescheitert** #needs-review. Docker-Host nicht " + f"erreichbar ({str(ausnahme)[:200]}), Sicherung abgebrochen." + ) + if stuecke: + zeile += f" Bis dahin am {kandidat}: {'; '.join(stuecke)}." + zeile += " Der nächste Lauf versucht es erneut." + try: + schreibe_notiz(vault, jetzt.date().isoformat(), zeile) + except Exception as zweite: + logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}") + return 1 except Exception as ausnahme: gescheitert = True stuecke.append(f"{instanz} gescheitert ({type(ausnahme).__name__}: {str(ausnahme)[:200]})") @@ -547,6 +608,13 @@ def main(argv=None) -> int: help="Ordner fuer die Sicherungen statt SICHERUNG_ZIEL (Angriffsfaelle)", ) argumente = zerleger.parse_args(argv) + # Seit Paket NT (Quorum 4): --tag gleich hier pruefen. Der Tag wird Name der Notiz, Pfad unter + # SICHERUNG_ZIEL und Teil eines Befehls auf dem Host; vorher legte ein Tag ausser der Form + # Inbox/.md an, mit ../ auch ausserhalb von Inbox/. Leer zaehlt als ausser der Form. Die + # Ausgabe ist ASCII (!a), damit kein Zeichen an der Kodierung von stderr scheitert. + if argumente.tag is not None and not tag_gueltig(argumente.tag): + print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr) + return 2 # Seit Paket E9: Das Zeitbudget der Sicherung zaehlt ab hier, also mit der Zeit fuer die # Ergebnisse (bis zu zwoelf Abfragen mit je SSH_TIMEOUT_S; Plankritik Runde 2, Befund 1). frist = monotonic() + SICHERUNG_BUDGET_S diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py index 74addae..bd99325 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py @@ -15,6 +15,9 @@ gegen eine Host-Ablage im Wegwerf-Ordner; ANGRIFF_SICHERUNG stoert sie gezielt. lauf() setzt SICHERUNG_ERSTER_TAG auf None und SICHERUNG_ZIEL in den Wegwerf-Ordner; sicherungslauf() lenkt mit --sicherung-ziel um, und das Ziel der Konstante muss dort leer bleiben. Nichts landet unter D:\\Trading-Sicherung. +Seit Paket NT (Quorum 4): ANGRIFF_HOST_WEG= laesst die Attrappe jeden Aufruf nach den ersten n mit +Exit 255 enden wie ssh ohne Host; ANGRIFF_SICHERUNG=langsam haelt jeden Sicherungsbefehl 5 s auf, +ANGRIFF_SICHERUNG=archiv_unlesbar endet beim Archiv wie abendkontrolle-lesen.sh ohne Leserecht. Aufruf: python abendkontrolle_dockerhost_angriffe.py [--mutanten] Exit 0 ohne Abweichung, 2 mit Abweichung. --mutanten faehrt MUTANTEN auf Kopien von abendkontrolle.py und MUTANTEN_AUFGABE auf Kopien von abendkontrolle_einrichten.ps1: Jeder genannte @@ -47,6 +50,13 @@ import sys with open(os.environ["ANGRIFF_PROTOKOLL"], "a", encoding="utf-8") as datei: datei.write(json.dumps(sys.argv[1:]) + "\n") +# Seit Paket NT: Nach ANGRIFF_HOST_WEG Aufrufen ist der Host weg; ssh selbst endet dann mit 255. +weg = os.environ.get("ANGRIFF_HOST_WEG", "") +if weg: + with open(os.environ["ANGRIFF_PROTOKOLL"], encoding="utf-8") as datei: + if len(datei.read().splitlines()) > int(weg): + sys.stderr.write("ssh: connect to host docker-host port 2498: Connection timed out\n") + sys.exit(255) befehl = sys.argv[-1] if befehl.startswith("sicherung"): # Seit Paket E9: die Lesebefehle der Sicherung, so streng wie abendkontrolle-lesen.sh (Task 724), @@ -68,6 +78,11 @@ if befehl.startswith("sicherung"): sys.exit(2) name = lesen.group(2) if lesen else bestaetigen.group(1) gestoert = stoerung if not nur or name.endswith("-" + nur) else "" + if gestoert == "langsam": + # Seit Paket NT: laenger als das Zeitlimit, das der Fall in seiner Kopie setzt. + import time + + time.sleep(5) archiv = os.path.join(host, name + ".tar") summendatei = archiv + ".sha256" gemeint = archiv if lesen and lesen.group(1) == "sicherung" else summendatei @@ -84,6 +99,10 @@ if befehl.startswith("sicherung"): sys.stdout.buffer.write(inhalt) sys.exit(0) if lesen: + if gestoert == "archiv_unlesbar": + # Seit Paket NT: wie abendkontrolle-lesen.sh bei einem Archiv ohne Leserecht (Exit 1). + sys.stderr.write("abendkontrolle-lesen: " + name + ".tar liegt auf dem Docker-Host, laesst sich aber nicht lesen\n") + sys.exit(1) if gestoert == "kaputt": inhalt = inhalt[:-1] + bytes([inhalt[-1] ^ 1]) if gestoert == "abbruch": @@ -168,6 +187,7 @@ def lauf(art, *argumente, modul=None): modul.SICHERUNG_ZIEL = wurzel / "sicherung-ziel" os.environ.pop("ANGRIFF_HOST", None) os.environ.pop("ANGRIFF_SICHERUNG", None) + os.environ.pop("ANGRIFF_HOST_WEG", None) unerwartet = [] if not getattr(modul.fahre_wsl, "attrappe", False): @@ -410,7 +430,7 @@ def namen(ordner): return sorted(p.name for p in ordner.iterdir()) -def sicherungslauf(wurzel, *argumente, modul=None, stoerung="", erster_tag=date(2026, 10, 5), schalter=True): +def sicherungslauf(wurzel, *argumente, modul=None, stoerung="", erster_tag=date(2026, 10, 5), schalter=True, host_weg=""): """Ein Lauf von main() mit Quelle dockerhost und Sicherung, alles unter wurzel. Vault, Host-Ablage (host_ablage) und Ziel bleiben stehen, damit ein zweiter Lauf daran @@ -446,6 +466,8 @@ def sicherungslauf(wurzel, *argumente, modul=None, stoerung="", erster_tag=date( os.environ["ANGRIFF_ART"] = "gut" os.environ["ANGRIFF_HOST"] = str(wurzel / "host") os.environ["ANGRIFF_SICHERUNG"] = stoerung + # Seit Paket NT: "" heisst, der Host bleibt erreichbar. + os.environ["ANGRIFF_HOST_WEG"] = host_weg vorne = ["--vault", str(vault), "--quelle", "dockerhost"] if schalter: vorne += ["--sicherung-ziel", str(wurzel / "ziel")] @@ -529,6 +551,9 @@ def eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt(): @fall def eine_abgebrochene_uebertragung_hinterlaesst_nichts(): + # Seit Paket NT (Plankritik, Befund 1): Exit 255 mitten im Archiv heisst nicht, dass der Host fehlt; + # er hat die Summe gerade beantwortet. Nur die kaputte Instanz scheitert, mit dem Befehl im Grund, + # die andere wird geholt. Keine Nachbardatei bleibt, die kaputte Instanz ist nicht bestaetigt. for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) @@ -541,8 +566,13 @@ def eine_abgebrochene_uebertragung_hinterlaesst_nichts(): assert namen(wurzel / "ziel" / TAG) == [f"{heil}.tar", f"{heil}.tar.sha256"], namen(wurzel / "ziel" / TAG) assert namen(host / "bestaetigt") == [f"{TAG}-{heil}"], namen(host / "bestaetigt") assert not any(b.startswith(f"sicherung-bestaetigt {TAG}-{kaputt} ") for b in befehle), befehle - assert f"{kaputt} gescheitert (RuntimeError: Archiv nicht geholt (Exit 255;" in zeile, zeile - assert "#needs-review" in zeile, zeile + assert ( + f"{kaputt} gescheitert (RuntimeError: Archiv nicht geholt (sicherung {TAG}-{kaputt}: Exit 255; " + "Connection reset by peer))" + ) in zeile, zeile + assert f"{heil} abgelegt und bestätigt" in zeile, zeile + assert "**gescheitert** #needs-review" in zeile, zeile + assert "Docker-Host nicht erreichbar" not in zeile, zeile @fall @@ -929,16 +959,23 @@ def ohne_schalter_gilt_das_ziel_der_konstante(): @fall -def ein_tag_ausser_der_form_holt_keine_sicherung(): - for tag in ("2026-10-5", "2026-13-05", "20261005", "../2026-10-05"): +def ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh(): + # Seit Paket NT (Quorum 4): main prueft --tag gleich nach dem Zerlegen. Vorher lief die + # Abendkontrolle mit dem falschen Tag los und legte Inbox/.md an, mit ../ auch ausserhalb von + # Inbox/; seit E9 hielt nur die Sicherung an (Exit 1). Jetzt endet der ganze Lauf vorher mit Exit 2. + for tag in ("2026-10-5", "2026-13-05", "2026-02-30", "20261005", "x026-10-05", "2026-10-0x", "../2026-10-05", ""): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel) - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", tag, "--jetzt", "2026-10-05T23:30") - assert code == 1, (tag, code) - assert befehle == [], (tag, befehle) + fehler = io.StringIO() + with contextlib.redirect_stderr(fehler): + code, notiz, _, _ = sicherungslauf(wurzel, "--tag", tag, "--jetzt", "2026-10-05T23:30") + assert code == 2, (tag, code) + assert fehler.getvalue() == f"abendkontrolle: --tag {tag!a} ist kein Datum JJJJ-MM-TT\n", (tag, fehler.getvalue()) + assert namen(wurzel / "vault") == ["Inbox"], (tag, namen(wurzel / "vault")) + assert namen(wurzel / "vault" / "Inbox") == [], (tag, notiz) + assert not (wurzel / "ssh.jsonl").exists(), tag assert not (wurzel / "ziel").exists(), tag - assert sicherungszeilen(notiz) == [], (tag, notiz) @fall @@ -958,6 +995,176 @@ def der_abendausloeser_steht_in_ortszeit_ohne_versatz(): assert ausloeser[0] < zeilen.index(zeile) < eintragen[0], (ausloeser, zeilen.index(zeile), eintragen) +# --- Seit Paket NT (Quorum 4): Host nicht erreichbar, --tag frueh, unlesbare Dateien -------------- + +HOST_WEG = "ssh: connect to host docker-host port 2498: Connection timed out" + + +def ergebnisse_liegen(modul, wurzel): + """Die Ergebnisse beider Teile liegen fuer 2026-09-20 bis 2026-10-12: Der Lauf faehrt nur die Sicherung.""" + ablage = wurzel / "vault" / modul.ABLAGE_TEIL + ablage.mkdir(parents=True, exist_ok=True) + for tag in [f"2026-09-{t}" for t in range(20, 31)] + [f"2026-10-{t:02d}" for t in range(1, 13)]: + (ablage / f"{tag}.json").write_text("{}", encoding="utf-8") + (ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8") + return ablage + + +@fall +def ein_unlesbares_archiv_auf_dem_host_scheitert_nur_fuer_seine_instanz(): + # Paket NT (Quorum 4, G2): abendkontrolle-lesen.sh endet bei einem Archiv ohne Leserecht mit Exit 1 + # und eigener Zeile. Das ist kein Ausfall des Hosts: nur diese Instanz scheitert, die andere wird + # geholt. Seit ssh_lauf Exit 255 als HostNichtErreichbar meldet, erreicht ein Abbruch mitten im + # Archiv die Pruefung des Exit-Codes nicht mehr; den Mutanten "Exit des Archivs egal" traegt + # deshalb dieser Fall (vorher eine_abgebrochene_uebertragung_hinterlaesst_nichts). + for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")): + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + host = host_ablage(wurzel) + code, notiz, _, _ = sicherungslauf( + wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=f"archiv_unlesbar:{kaputt}" + ) + zeile = einzige_zeile(notiz) + assert code == 1, code + assert namen(wurzel / "ziel" / TAG) == [f"{heil}.tar", f"{heil}.tar.sha256"], namen(wurzel / "ziel" / TAG) + assert namen(host / "bestaetigt") == [f"{TAG}-{heil}"], namen(host / "bestaetigt") + assert ( + f"{kaputt} gescheitert (RuntimeError: Archiv nicht geholt (Exit 1; abendkontrolle-lesen: " + f"{TAG}-{kaputt}.tar liegt auf dem Docker-Host, laesst sich aber nicht lesen)" + ) in zeile, zeile + assert f"{heil} abgelegt und bestätigt" in zeile, zeile + assert "Docker-Host nicht erreichbar" not in zeile, zeile + + +@fall +def ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz(): + # Paket NT (Quorum 4, G1): Vorher scheiterte bei einem Host ohne Verbindung jeder Tag des Fensters + # mit eigener Zeile, und schreibe_notiz legte die fehlenden Notizen an, auch fuer Samstag und + # Sonntag. Jetzt endet die Sicherung beim ersten Exit 255 mit genau einer Zeile von heute. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + modul = lade() + ablage = ergebnisse_liegen(modul, wurzel) + code, notiz, befehle, _ = sicherungslauf( + wurzel, "--jetzt", "2026-10-12T23:30", modul=modul, erster_tag=date(2026, 9, 1), host_weg="0" + ) + assert code == 1, (code, notiz) + assert namen(wurzel / "vault" / "Inbox") == ["2026-10-12.md"], namen(wurzel / "vault" / "Inbox") + assert einzige_zeile(notiz) == ( + f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " + f"(sicherung-summe 2026-10-02-alpaca: Exit 255; {HOST_WEG}), Sicherung abgebrochen. " + "Der nächste Lauf versucht es erneut." + ), notiz + assert befehle == ["sicherung-summe 2026-10-02-alpaca"], befehle + assert not (wurzel / "ziel").exists() + log = (ablage / "lauf.log").read_text(encoding="utf-8") + assert "sicherung 2026-10-02-alpaca: Docker-Host nicht erreichbar (" in log, log + + +@fall +def ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen(): + # Paket NT (Quorum 4, G1): Fertige Tage behalten ihre Zeilen in ihren Notizen; was ein angefangener + # Tag schon hat, nennt die eine Zeile von heute. Je Tag mit Sicherung sechs Befehle (Summe, Archiv, + # Bestaetigung je Instanz); nach weg Befehlen ist der Host weg. Am Samstag 2026-10-03 liegt keine + # Sicherung, gefragt wird trotzdem. + for weg, notizen, zuletzt, bis_dahin in ( + ("12", ["2026-10-01.md", "2026-10-02.md", "2026-10-05.md"], "sicherung-summe 2026-10-03-alpaca", ""), + ("9", ["2026-10-01.md", "2026-10-05.md"], "sicherung-summe 2026-10-02-ibkr", + " Bis dahin am 2026-10-02: alpaca abgelegt und bestätigt ("), + ): + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + host_ablage(wurzel, tage=("2026-10-01", "2026-10-02", "2026-10-05")) + modul = lade() + ergebnisse_liegen(modul, wurzel) + code, notiz, befehle, _ = sicherungslauf( + wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=date(2026, 10, 1), host_weg=weg + ) + zeilen = sicherungszeilen(notiz) + assert code == 1, (weg, code) + assert namen(wurzel / "vault" / "Inbox") == notizen, (weg, namen(wurzel / "vault" / "Inbox")) + assert befehle[-1] == zuletzt and len(befehle) == int(weg) + 1, (weg, befehle) + assert namen(wurzel / "ziel") == ["2026-10-01", "2026-10-02"], (weg, namen(wurzel / "ziel")) + assert len(zeilen) == len(notizen), (weg, zeilen) + for zeile in zeilen[:-1]: + assert "alpaca abgelegt und bestätigt" in zeile and "ibkr abgelegt und bestätigt" in zeile, zeile + assert zeilen[-1].startswith( + f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " + f"({zuletzt}: Exit 255; {HOST_WEG}), Sicherung abgebrochen." + ), zeilen[-1] + if bis_dahin: + assert bis_dahin in zeilen[-1], zeilen[-1] + else: + assert "Bis dahin" not in zeilen[-1], zeilen[-1] + assert zeilen[-1].endswith(" Der nächste Lauf versucht es erneut."), zeilen[-1] + + +@fall +def ein_zeitlimit_beim_host_zaehlt_wie_ein_unerreichbarer_host(): + # Paket NT (Quorum 4, G1): Laeuft die Frage nach der Summe in ihr Zeitlimit (SSH_TIMEOUT_S), gilt der + # Host als nicht erreichbar. In der Kopie ist das Zeitlimit 1 s, die Attrappe braucht je + # Sicherungsbefehl 5 s. Das Zeitlimit beim Archiv selbst: naechster Fall. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + host_ablage(wurzel) + modul = lade() + ergebnisse_liegen(modul, wurzel) + modul.SSH_TIMEOUT_S = 1 + code, notiz, befehle, _ = sicherungslauf(wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, stoerung="langsam") + assert code == 1, (code, notiz) + assert namen(wurzel / "vault" / "Inbox") == ["2026-10-05.md"], namen(wurzel / "vault" / "Inbox") + assert befehle == ["sicherung-summe 2026-10-05-alpaca"], befehle + assert einzige_zeile(notiz) == ( + f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " + "(sicherung-summe 2026-10-05-alpaca: Zeitlimit 1 s abgelaufen), Sicherung abgebrochen. " + "Der nächste Lauf versucht es erneut." + ), notiz + assert not (wurzel / "ziel").exists() + + +@fall +def ein_zeitlimit_beim_archiv_laesst_nur_diese_instanz_scheitern(): + # Plankritik NT, Befund 1: Laeuft nur das Archiv in sein Zeitlimit (SICHERUNG_TIMEOUT_S), ist der Host + # erreichbar; er hat die Summe gerade beantwortet. Die Instanz scheitert mit eigenem Grund, die + # andere wird geholt, der naechste Tag auch. In der Kopie ist das Zeitlimit des Archivs 1 s, die + # Attrappe braucht fuer jeden Befehl von alpaca 5 s; die Summe hat weiter SSH_TIMEOUT_S. + tage = ("2026-10-01", "2026-10-02") + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + host = host_ablage(wurzel, tage=tage) + modul = lade() + ergebnisse_liegen(modul, wurzel) + modul.SICHERUNG_TIMEOUT_S = 1 + code, notiz, befehle, _ = sicherungslauf( + wurzel, "--jetzt", "2026-10-02T23:30", modul=modul, erster_tag=date(2026, 10, 1), stoerung="langsam:alpaca" + ) + zeilen = sicherungszeilen(notiz) + assert code == 1, (code, notiz) + assert namen(wurzel / "vault" / "Inbox") == ["2026-10-01.md", "2026-10-02.md"], namen(wurzel / "vault" / "Inbox") + assert namen(host / "bestaetigt") == [f"{t}-ibkr" for t in tage], namen(host / "bestaetigt") + assert [b for b in befehle if b.startswith("sicherung ")] == [ + f"sicherung {t}-{i}" for t in tage for i in ("alpaca", "ibkr") + ], befehle + assert len(zeilen) == 2, zeilen + for tag, zeile in zip(tage, zeilen): + assert namen(wurzel / "ziel" / tag) == ["ibkr.tar", "ibkr.tar.sha256"], (tag, namen(wurzel / "ziel" / tag)) + assert ( + "**gescheitert** #needs-review. alpaca gescheitert (RuntimeError: Archiv nicht geholt " + f"(sicherung {tag}-alpaca: Zeitlimit 1 s abgelaufen)); ibkr abgelegt und bestätigt (" + ) in zeile, zeile + assert "Docker-Host nicht erreichbar" not in zeile, zeile + + +@fall +def kein_kommentar_beginnt_mit_s3(): + # Paket NT (Quorum 4): Die Kommentarzeile ueber SICHERUNG_NACHHOLEN_TAGE begann mit "(S3).", einem + # Verweis ohne Ziel. Erst die neue Zeile zusichern, dann die alte ausschliessen. + zeilen = QUELLE.read_text(encoding="utf-8").splitlines() + neu = "# Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026" + assert zeilen.count(neu) == 1, f"neue Kommentarzeile {zeilen.count(neu)}-mal" + assert [z for z in zeilen if z.startswith("# (S3)")] == [], "Kommentar beginnt mit (S3)." + + # Mutanten fuer --mutanten: Name, Text in abendkontrolle.py (genau einmal), Ersatz und die Faelle, # die davon rot werden muessen. MUTANTEN_AUFGABE (unten) tut dasselbe fuer abendkontrolle_einrichten.ps1. # Ein Mutant, der das Verhalten nicht aendert, gehoert nicht hierher @@ -968,7 +1175,7 @@ MUTANTEN = [ ("Exit des Archivs egal", ' if lauf.returncode != 0:\n raise RuntimeError(f"Archiv nicht geholt', ' if False:\n raise RuntimeError(f"Archiv nicht geholt', - ["eine_abgebrochene_uebertragung_hinterlaesst_nichts"]), + ["ein_unlesbares_archiv_auf_dem_host_scheitert_nur_fuer_seine_instanz"]), ("Nachbardatei bleibt", " teil.unlink(missing_ok=True)", " pass", ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt", "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]), ("Bestaetigung ungeprueft", @@ -1032,11 +1239,62 @@ MUTANTEN = [ ["ohne_schalter_gilt_das_ziel_der_konstante"]), ("Ziel der Konstante", r'SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung")', r'SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung-alt")', ["ohne_schalter_gilt_das_ziel_der_konstante"]), - ("Tag ungeprueft", - ' if not TAGFORM.fullmatch(tag):\n raise ValueError(f"--tag {tag!r} ist kein Datum JJJJ-MM-TT")\n' - " date.fromisoformat(tag)", - " pass", ["ein_tag_ausser_der_form_holt_keine_sicherung"]), - ("Datum ungeprueft", " date.fromisoformat(tag)", " pass", ["ein_tag_ausser_der_form_holt_keine_sicherung"]), + # Seit Paket NT (Quorum 4): Host nicht erreichbar, --tag frueh geprueft, Kommentar ohne (S3). + ("E729-01 Host weg: Exit 255 wie jeder andere", " if lauf.returncode == 255:", " if False:", + ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz", + "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]), + ("E729-02 Host weg: Zeitlimit wie jeder Fehler", " except subprocess.TimeoutExpired:", + " except ZeroDivisionError:", ["ein_zeitlimit_beim_host_zaehlt_wie_ein_unerreichbarer_host"]), + ("E729-03 Host weg reisst nur die Instanz ab", " except HostNichtErreichbar as ausnahme:", + " except ZeroDivisionError as ausnahme:", + ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz", + "ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen"]), + ("E729-04 Host weg: Zeile in der Notiz des Tags", + " schreibe_notiz(vault, jetzt.date().isoformat(), zeile)", + " schreibe_notiz(vault, kandidat, zeile)", + ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]), + ("E729-05 Host weg mit Exit 0", + ' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 1', + ' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 0', + ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]), + ("E729-06 Host weg: weiter mit der naechsten Instanz", + ' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 1', + ' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n continue', + ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]), + ("E729-07 Host weg: nichts vom angefangenen Tag", " if stuecke:\n zeile +=", + " if False:\n zeile +=", + ["ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen"]), + ("E729-08 Host weg ohne lauf.log", + ' logge(ablage, f"sicherung {kandidat}-{instanz}: Docker-Host nicht erreichbar ({ausnahme}), ' + 'Sicherung abgebrochen")', + " pass", ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]), + ("E729-09 Tag spaet geprueft", " if argumente.tag is not None and not tag_gueltig(argumente.tag):", + " if False:", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), + ("E729-10 leerer Tag gilt als keiner", " if argumente.tag is not None and not tag_gueltig(argumente.tag):", + " if argumente.tag and not tag_gueltig(argumente.tag):", + ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), + ("E729-11 falscher Tag mit Exit 1", + ' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)\n' + " return 2", + ' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)\n' + " return 1", + ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), + ("E729-12 falscher Tag ohne Meldung", + ' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)', + " pass", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), + ("E729-13 Form des Tags ungeprueft", " if not TAGFORM.fullmatch(tag):\n return False", + " if False:\n return False", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), + ("E729-14 Datum des Tags ungeprueft", " date.fromisoformat(tag)\n except ValueError:", + " pass\n except ValueError:", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), + ("E729-15 Verweis S3 zurueck", + "# Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026", + "# (S3). Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026", + ["kein_kommentar_beginnt_mit_s3"]), + # Seit der Plankritik NT, Befund 1: Ein Abbruch im Archiv laesst nur die Instanz scheitern. + ("E729-25 Abbruch im Archiv beendet die Sicherung", " except HostNichtErreichbar as abbruch:", + " except ZeroDivisionError as abbruch:", + ["ein_zeitlimit_beim_archiv_laesst_nur_diese_instanz_scheitern", + "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]), ("Name der Summendatei beim Host egal", ' if treffer is None or treffer.group(2) != f"{name}.tar":', " if treffer is None:", ["eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv"]), ("Grossbuchstaben in der Summe", r'SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n")', diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py index f18daca..8066979 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py @@ -87,7 +87,8 @@ Container /abend/sicherung.json); die Ablage selbst sieht das Werkzeug nicht. Di nach dem Teil in einem eigenen Schritt, auch an einem Tag ohne Handel und nach einem Abbruch. E1. Ist der Stand lesbar? Projektvorgabe (Plan E9, Task 726): Datei fehlt, kein JSON, kein Objekt, ohne erster_tag oder eintraege -> Befund. Ein Schluessel - fehler (Ablage fehlt, nicht lesbar, erster-tag kaputt) -> Befund. + fehler (Ablage fehlt, nicht lesbar, erster-tag kaputt, seit Paket NT + auch eine unlesbare Summendatei oder Bestaetigung) -> Befund. E2. Ab wann wird geprueft? Projektvorgabe (S6, S8): erster_tag aus erster-tag der Ablage; null, kein Datum oder ein Tag nach --tag -> Befund, sonst schwiege die Pruefung auf Dauer; nur ein voriger Handelstag vor