diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py index 5e7bb96..2e91fb4 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py @@ -39,6 +39,12 @@ Seit Paket NT (Quorum 4): Endet die Frage nach einer Summe oder die Bestaetigung 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. +Seit Paket NR (kritische Pruefung NT, Befund 3): Laesst sich lauf.log nicht schreiben (Sperre, Rechte, volle +Platte), haelt das weder Ergebnisdatei noch Notizzeile noch Sicherung auf. Der Lauf schreibt am Ende eine +Zeile "Abendkontrolle lauf.log (automatisch): **nicht schreibbar** ()" in die Notiz von heute und endet +mit Exit 1. Seit der Plankritik NR: Laesst sich die Ergebnisdatei eines Tages nicht schreiben, bekommt seine +Zeile in der Notiz eine Unterzeile mit dem Typ der Ausnahme, die folgenden Tage des Teils laufen weiter, und +der Lauf endet mit Exit 1. Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl ] [--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] [--sicherung-ziel ] """ @@ -583,10 +589,27 @@ def schreibe_notiz(vault: Path, tag: str, zeile: str) -> None: os.replace(neu, notiz) +# Seit Paket NR (kritische Pruefung NT, Befund 3): der Typ der letzten Ausnahme, an der lauf.log in diesem +# Lauf scheiterte; None, solange es ging. main setzt ihn zu Beginn zurueck, nur logge setzt ihn. +LOG_FEHLER: str | None = None +LOG_KOPF = "Abendkontrolle lauf.log (automatisch)" + + def logge(ablage: Path, text: str) -> None: - ablage.mkdir(parents=True, exist_ok=True) - with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as datei: - datei.write(f"{datetime.now().isoformat(timespec='seconds')} {text}\n") + """Eine Zeile in lauf.log. Wirft seit Paket NR nicht mehr. + + Vorher warf ein gesperrtes oder nicht schreibbares lauf.log in main vor fahre_teil, in fahre_teil vor + der Zeile in der Notiz und in der Sicherung vor der Zeile zum unerreichbaren Host; uebrig blieb nur + Exit 1. Jetzt merkt sich logge den Typ der Ausnahme in LOG_FEHLER, und main schreibt dazu am Ende + eine Zeile in die Notiz von heute. + """ + global LOG_FEHLER + try: + ablage.mkdir(parents=True, exist_ok=True) + with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as datei: + datei.write(f"{datetime.now().isoformat(timespec='seconds')} {text}\n") + except Exception as ausnahme: + LOG_FEHLER = type(ausnahme).__name__ def main(argv=None) -> int: @@ -616,6 +639,10 @@ def main(argv=None) -> int: 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 NR: Jeder Lauf beginnt ohne Fehler an lauf.log. Die Angriffsfaelle rufen main mehrmals im + # selben Prozess; ohne das Zuruecksetzen truege ein Lauf den Fehler des vorigen weiter. + global LOG_FEHLER + LOG_FEHLER = None # 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 @@ -634,8 +661,9 @@ def main(argv=None) -> int: else: tage = faellige_tage(jetzt, ablage) vorsatz = "" if teil == "alpaca" else f"{teil} " - # Scheitert ein Teil ausserhalb seiner eigenen Fehlerbehandlung (Ergebnisdatei nicht - # schreibbar, lauf.log gesperrt), laeuft der naechste trotzdem (Pruefung IB2). + # Scheitert ein Teil ausserhalb seiner eigenen Fehlerbehandlung, laeuft der naechste trotzdem + # (Pruefung IB2). Seit Paket NR werfen weder ein gesperrtes lauf.log (logge) noch eine nicht + # schreibbare Ergebnisdatei (fahre_teil). try: logge(ablage, f"{vorsatz}start, faellig: {tage}") # Seit Paket DH: die Quelle je Teil; die IBKR-Instanz zieht an einem eigenen Abend um. @@ -662,6 +690,19 @@ def main(argv=None) -> int: logge(ablage, f"sicherung abgebrochen: {type(ausnahme).__name__}: {ausnahme}") except Exception: pass + # Seit Paket NR (kritische Pruefung NT, Befund 3): Scheiterte lauf.log in diesem Lauf, einmal eine Zeile + # in der Notiz von heute mit dem Typ der Ausnahme, ohne Pfad und ohne Inhalt, und Exit 1. Scheitert + # auch die Notiz, bleibt es bei Exit 1. + if LOG_FEHLER is not None: + code = 1 + log_zeile = ( + f"- {jetzt:%H:%M} Uhr — {LOG_KOPF}: **nicht schreibbar** ({LOG_FEHLER}) #needs-review. Mindestens " + "ein Eintrag dieses Laufs fehlt dort; Ergebnisdateien, Notizzeilen und Sicherung hängen nicht davon ab." + ) + try: + schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile) + except Exception: + pass return code @@ -703,15 +744,30 @@ def fahre_teil(teil: str, tage: list[str], jetzt: datetime, vault: Path, ablage: logge(ablage, f"{vorsatz}{tag}: auch die Notiz scheiterte {zweite!r}") continue # Ein Abbruch im WSL-Teil (Exit 2) wird wiederholt; alles andere gilt als gelaufen. + # Seit Paket NR (Plankritik NR): Liess sich die Ergebnisdatei nicht schreiben, flog die Ausnahme vorher + # aus fahre_teil, und es fehlten die Zeile dieses Tages und die aller folgenden Tage des Teils. Jetzt + # bekommt die Zeile eine Unterzeile mit dem Typ der Ausnahme, die folgenden Tage laufen weiter, Exit 1. + # Ob der naechste Lauf den Tag erneut prueft, entscheidet faellige_tage: nur im Fenster und nur, wenn + # unter dem Namen der Datei nichts liegt, auch keine halb geschriebene Datei. + zusatz = "" if ergebnis.get("exit") != 2: - (ablage / f"{tag}{endung}.json").write_text( - json.dumps(ergebnis, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n" - ) + try: + (ablage / f"{tag}{endung}.json").write_text( + json.dumps(ergebnis, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n" + ) + except Exception as ausnahme: + code = 1 + logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei nicht geschrieben {ausnahme!r}") + zusatz = ( + f"\n - Ergebnisdatei nicht geschrieben ({type(ausnahme).__name__}) #needs-review. Der nächste " + "Lauf prüft den Tag erneut, wenn er dann noch im Nachholfenster ist und unter dem Namen der " + "Datei nichts liegt, auch keine halb geschriebene." + ) else: code = 1 logge(ablage, f"{vorsatz}{tag}: {len(ergebnis['befunde'])} Befund(e), exit {ergebnis.get('exit')}") try: - schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit)) + schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit) + zusatz) except Exception as ausnahme: code = 1 logge(ablage, f"{vorsatz}{tag}: Notiz nicht geschrieben {ausnahme!r}") diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py index bdff33d..1f26b6b 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py @@ -18,6 +18,10 @@ 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. +Seit Paket NR (kritische Pruefung NT, Befund 3): lauf_log_sperren legt an der Stelle von lauf.log einen +Ordner an, und vier Faelle zeigen, dass Ergebnis, Notizzeilen und Sicherung nicht daran haengen. Seit der +Plankritik NR drei weitere: ein Fehler in logge, der kein OSError ist, eine Ablage, die sich nicht anlegen +laesst, und eine Ergebnisdatei, die sich nicht schreiben laesst. 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 @@ -1191,6 +1195,288 @@ def kein_kommentar_beginnt_mit_s3(): assert [z for z in zeilen if z.startswith("# (S3)")] == [], "Kommentar beginnt mit (S3)." +# --- Seit Paket NR (kritische Pruefung NT, Befund 3): lauf.log nicht schreibbar --------------------- + +LOG_KOPF = "Abendkontrolle lauf.log (automatisch)" + + +def lauf_log_sperren(modul, wurzel): + """Legt an der Stelle von lauf.log einen Ordner an; liefert die Ablage und den Typ der Ausnahme. + + Oeffnen zum Anhaengen scheitert dann wie bei einer Sperre oder fehlenden Rechten, und die Ablage + daneben bleibt schreibbar. Den Typ misst der Fall hier selbst, statt ihn je Betriebssystem zu raten. + """ + ablage = wurzel / "vault" / modul.ABLAGE_TEIL + gesperrt = ablage / "lauf.log" + gesperrt.mkdir(parents=True) + try: + with gesperrt.open("a", encoding="utf-8"): + pass + except OSError as fehler: + return ablage, type(fehler).__name__ + raise AssertionError("lauf.log als Ordner liess sich zum Anhaengen oeffnen") + + +def log_zeile(uhrzeit, typ): + """Die eine Zeile, die ein Lauf mit nicht schreibbarem lauf.log in die Notiz von heute schreibt.""" + return ( + f"- {uhrzeit} Uhr — {LOG_KOPF}: **nicht schreibbar** ({typ}) #needs-review. Mindestens ein Eintrag " + "dieses Laufs fehlt dort; Ergebnisdateien, Notizzeilen und Sicherung hängen nicht davon ab." + ) + + +def log_zeilen(notiz): + """Die Zeilen der Notizen, die lauf.log nennen.""" + return [zeile for zeile in notiz.splitlines() if LOG_KOPF in zeile] + + +@fall +def ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf(): + # Kritische Pruefung NT, Befund 3: Vorher warf logge schon beim Start jedes Teils, beide Teile fielen + # aus, die Sicherung brach nach dem ersten Archiv ab, und in der Notiz stand nichts; uebrig blieb + # Exit 1. Jetzt laufen Ergebnis, Zeilen und Sicherung beider Teile (erster und letzter Teil), und + # genau eine Zeile von heute nennt lauf.log, mit Exit 1. Ein zweiter Lauf mit demselben Modul und + # schreibbarem lauf.log endet mit 0 und ohne neue Zeile: Der Fehler gilt je Lauf. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + host = host_ablage(wurzel) + modul = lade() + ablage, typ = lauf_log_sperren(modul, wurzel) + code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul) + ergebnisse = sorted(p.name for p in ablage.glob("*.json")) + assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen" + assert "Abendkontrolle Trading Plattform (automatisch): ohne Befund" in notiz, notiz + assert "Abendkontrolle IBKR (automatisch): ohne Befund" in notiz, notiz + ziel = wurzel / "ziel" / TAG + assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) + assert namen(host / "bestaetigt") == [f"{TAG}-alpaca", f"{TAG}-ibkr"], namen(host / "bestaetigt") + assert "**gescheitert**" not in einzige_zeile(notiz), notiz + assert log_zeilen(notiz) == [log_zeile("23:30", typ)], notiz + assert code == 1, code + assert namen(ablage / "lauf.log") == [], namen(ablage / "lauf.log") + # Zweiter Lauf mit demselben Modul; lauf.log ist wieder schreibbar. + (ablage / "lauf.log").rmdir() + code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45", modul=modul) + assert code == 0, (code, notiz) + assert len(log_zeilen(notiz)) == 1, notiz + assert (ablage / "lauf.log").is_file(), "lauf.log nach dem zweiten Lauf keine Datei" + + +@fall +def ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht(): + # Kritische Pruefung NT, Befund 3: Im Zweig "Docker-Host nicht erreichbar" stand logge vor der Zeile in + # der Notiz; warf es, fehlte die Zeile, und uebrig blieb Exit 1. Jetzt stehen in der Notiz von heute + # die Zeile zum Host und die zu lauf.log, je genau einmal. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + modul = lade() + ergebnisse_liegen(modul, wurzel) + _, typ = lauf_log_sperren(modul, wurzel) + code, notiz, befehle, _ = sicherungslauf( + wurzel, "--jetzt", "2026-10-12T23:30", modul=modul, erster_tag=date(2026, 10, 12), host_weg="0" + ) + assert len(sicherungszeilen(notiz)) == 1, "Zeile zum unerreichbaren Host fehlt" + assert einzige_zeile(notiz) == ( + f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " + f"(sicherung-summe 2026-10-12-alpaca: Exit 255; {HOST_WEG}), Sicherung abgebrochen. " + "Der nächste Lauf versucht es erneut." + ), notiz + assert log_zeilen(notiz) == [log_zeile("23:30", typ)], notiz + assert namen(wurzel / "vault" / "Inbox") == ["2026-10-12.md"], namen(wurzel / "vault" / "Inbox") + assert befehle == ["sicherung-summe 2026-10-12-alpaca"], befehle + assert code == 1, code + + +@fall +def ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht(): + # Kritische Pruefung NT, Befund 3: Scheiterte ein Teil, stand logge vor seiner Zeile in der Notiz. Hier + # liefert der Host fuer beide Teile kein Ergebnis (erster und letzter Teil), und --tag ist nicht heute: + # Die Zeilen der Teile stehen in der Notiz des Tags, die Zeile zu lauf.log nur in der von heute. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + modul = lade() + _, typ = lauf_log_sperren(modul, wurzel) + + def kein_ergebnis(tag, teil="alpaca"): + raise RuntimeError(f"auf dem Docker-Host liegt fuer {tag} ({teil}) noch kein Ergebnis") + + modul.fahre_dockerhost = kein_ergebnis + code, _, befehle, _ = sicherungslauf( + wurzel, "--tag", TAG, "--jetzt", "2026-10-06T23:30", modul=modul, erster_tag=None + ) + inbox = wurzel / "vault" / "Inbox" + assert namen(inbox) == [f"{TAG}.md", "2026-10-06.md"], f"Notizen {namen(inbox)}" + tagnotiz = (inbox / f"{TAG}.md").read_text(encoding="utf-8") + heute = (inbox / "2026-10-06.md").read_text(encoding="utf-8") + for kopf in ("Abendkontrolle Trading Plattform (automatisch)", "Abendkontrolle IBKR (automatisch)"): + assert f"- 2026-10-06 23:30 Uhr — {kopf}: **gescheitert** (RuntimeError: " in tagnotiz, tagnotiz + assert log_zeilen(tagnotiz) == [], tagnotiz + assert log_zeilen(heute) == [log_zeile("23:30", typ)], heute + assert befehle == [], befehle + assert code == 1, code + + +@fall +def eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab(): + # Paket NR: Laesst sich neben lauf.log auch die Notiz von heute nicht schreiben (ihre Nachbardatei + # ist ein Ordner), endet main ohne Ausnahme mit Exit 1, und die Ergebnisdateien beider Teile liegen. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + modul = lade() + ablage, _ = lauf_log_sperren(modul, wurzel) + (wurzel / "vault" / "Inbox" / f"{TAG}.md.abendkontrolle.tmp").mkdir(parents=True) + code, _, befehle, _ = sicherungslauf( + wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None + ) + ergebnisse = sorted(p.name for p in ablage.glob("*.json")) + assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen" + assert namen(wurzel / "vault" / "Inbox") == [f"{TAG}.md.abendkontrolle.tmp"], namen(wurzel / "vault" / "Inbox") + assert befehle == [], befehle + assert code == 1, code + + +# --- Seit der Plankritik NR: kein OSError in logge, Ablage nicht anlegbar, Ergebnisdatei nicht schreibbar --- + + +def typ_beim_schreiben(pfad): + """Der Typ der Ausnahme, wenn pfad sich nicht als Datei schreiben laesst; der Fall misst ihn selbst.""" + try: + pfad.write_text("{}", encoding="utf-8") + except OSError as fehler: + return type(fehler).__name__ + raise AssertionError("an dieser Stelle liess sich eine Datei schreiben") + + +def ergebnis_zusatz(typ): + """Die Unterzeile unter der Zeile eines Tages, dessen Ergebnisdatei sich nicht schreiben liess.""" + return ( + f" - Ergebnisdatei nicht geschrieben ({typ}) #needs-review. Der nächste Lauf prüft den Tag erneut, " + "wenn er dann noch im Nachholfenster ist und unter dem Namen der Datei nichts liegt, auch keine halb " + "geschriebene." + ) + + +def zeile_mit_folge(notiz, anfang): + """Die eine Zeile der Notizen, die mit anfang beginnt, und die Zeile danach ("" am Ende).""" + zeilen = notiz.splitlines() + treffer = [i for i, zeile in enumerate(zeilen) if zeile.startswith(anfang)] + assert len(treffer) == 1, (anfang, notiz) + i = treffer[0] + return zeilen[i], (zeilen[i + 1] if i + 1 < len(zeilen) else "") + + +@fall +def ein_fehler_in_logge_der_kein_oserror_ist_haelt_den_lauf_nicht_auf(): + # Plankritik NR: logge faengt jede Ausnahme, nicht nur OSError (Entscheidungsliste Task 743, Nr. 1). + # Hier wirft die Uhr in logge einen RuntimeError; die Attrappe gilt nur im geladenen Modul, und --jetzt + # liest dieselbe Klasse heil. Ergebnis und Zeilen beider Teile entstehen, die Zeile zu lauf.log nennt + # den Typ, Exit 1. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + modul = lade() + + class KaputteUhr(datetime): + @classmethod + def now(cls, tz=None): + raise RuntimeError("Uhr der Attrappe") + + modul.datetime = KaputteUhr + code, notiz, befehle, _ = sicherungslauf( + wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None + ) + ablage = wurzel / "vault" / modul.ABLAGE_TEIL + ergebnisse = sorted(p.name for p in ablage.glob("*.json")) + assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen" + assert "Abendkontrolle Trading Plattform (automatisch): ohne Befund" in notiz, notiz + assert "Abendkontrolle IBKR (automatisch): ohne Befund" in notiz, notiz + assert log_zeilen(notiz) == [log_zeile("23:30", "RuntimeError")], notiz + assert befehle == [], befehle + assert code == 1, code + + +@fall +def eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf(): + # Plankritik NR: logge legt die Ablage im try an (Entscheidungsliste Task 743, Nr. 1). Hier liegt an + # ihrer Stelle eine Datei; weder lauf.log noch die Ergebnisdateien lassen sich schreiben. Mit --tag + # liest der Lauf die Ablage vorher nicht (faellige_tage). Die Zeilen beider Teile stehen mit der + # Unterzeile zur Ergebnisdatei in der Notiz, dazu die Zeile zu lauf.log, Exit 1. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + modul = lade() + ablage = wurzel / "vault" / modul.ABLAGE_TEIL + ablage.parent.mkdir(parents=True) + ablage.write_bytes(b"keine Ablage\n") + try: + ablage.mkdir(parents=True, exist_ok=True) + except OSError as fehler: + typ_log = type(fehler).__name__ + else: + raise AssertionError("die Ablage liess sich anlegen") + typ_ergebnis = typ_beim_schreiben(ablage / f"{TAG}.json") + code, notiz, befehle, _ = sicherungslauf( + wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None + ) + inbox = wurzel / "vault" / "Inbox" + assert namen(inbox) == [f"{TAG}.md"], f"Notizen {namen(inbox)}" + for teil in ("alpaca", "ibkr"): + zeile, folge = zeile_mit_folge(notiz, f"- 23:30 Uhr — {modul.KOEPFE[teil]}: ohne Befund.") + assert folge == ergebnis_zusatz(typ_ergebnis), (zeile, folge) + assert log_zeilen(notiz) == [log_zeile("23:30", typ_log)], notiz + assert ablage.read_bytes() == b"keine Ablage\n" + assert befehle == [], befehle + assert code == 1, code + + +@fall +def eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf(): + # Plankritik NR: Die Ergebnisdatei stand in fahre_teil ohne try vor der Zeile in der Notiz. Liess sie + # sich nicht schreiben, flog die Ausnahme aus fahre_teil: Es fehlten die Zeile dieses Tages und die + # aller folgenden Tage des Teils, uebrig blieben "abgebrochen" in lauf.log und Exit 1. Hier liegt an + # ihrer Stelle ein Ordner, bei alpaca am ersten, bei ibkr am letzten faelligen Tag. Die Attrappe legt + # ihn erst nach der Frage an den Host an; vorher zaehlte faellige_tage den Tag als erledigt. + with tempfile.TemporaryDirectory() as ordner: + wurzel = Path(ordner) + modul = lade() + ablage = wurzel / "vault" / modul.ABLAGE_TEIL + ablage.mkdir(parents=True) + for tag in ("2026-09-25", "2026-09-28", "2026-09-29", "2026-09-30"): + (ablage / f"{tag}.json").write_text("{}", encoding="utf-8") + (ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8") + gesperrt = { + ("2026-10-01", "alpaca"): ablage / "2026-10-01.json", + ("2026-10-02", "ibkr"): ablage / "2026-10-02-ibkr.json", + } + echt = modul.fahre_dockerhost + + def fahre_dockerhost(tag, teil="alpaca"): + ergebnis = echt(tag, teil) + if (tag, teil) in gesperrt: + gesperrt[(tag, teil)].mkdir() + return ergebnis + + modul.fahre_dockerhost = fahre_dockerhost + code, notiz, befehle, _ = sicherungslauf( + wurzel, "--jetzt", "2026-10-02T23:30", modul=modul, erster_tag=None + ) + inbox = wurzel / "vault" / "Inbox" + assert namen(inbox) == ["2026-10-01.md", "2026-10-02.md"], f"Notizen {namen(inbox)}" + zusatz = ergebnis_zusatz(typ_beim_schreiben(gesperrt[("2026-10-01", "alpaca")])) + for uhrzeit, teil, gesperrt_hier in ( + ("2026-10-02 23:30", "alpaca", True), + ("2026-10-02 23:30", "ibkr", False), + ("23:30", "alpaca", False), + ("23:30", "ibkr", True), + ): + _, folge = zeile_mit_folge(notiz, f"- {uhrzeit} Uhr — {modul.KOEPFE[teil]}: ohne Befund.") + assert (folge == zusatz) == gesperrt_hier, (uhrzeit, teil, folge) + assert notiz.count("Ergebnisdatei nicht geschrieben") == 2, notiz + assert (ablage / "2026-10-02.json").is_file() and (ablage / "2026-10-01-ibkr.json").is_file(), namen(ablage) + assert all(pfad.is_dir() for pfad in gesperrt.values()), namen(ablage) + assert log_zeilen(notiz) == [], notiz + assert befehle == [], befehle + assert code == 1, code + + # 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 @@ -1327,6 +1613,64 @@ MUTANTEN = [ ' try:\n lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n' ' except HostNichtErreichbar as abbruch:\n raise RuntimeError(f"nicht bestaetigt ({abbruch})") from None', ["eine_bestaetigung_ohne_host_bricht_die_sicherung_ab"]), + # Seit Paket NR (kritische Pruefung NT, Befund 3): lauf.log nicht schreibbar. + ("E743-01 lauf.log wirft wieder", + " except Exception as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__", + " except ZeroDivisionError as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__", + ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf", + "ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht", + "ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht", + "eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab"]), + ("E743-02 keine Zeile zu lauf.log", " if LOG_FEHLER is not None:\n code = 1", + " if False:\n code = 1", + ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf", + "ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht", + "ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]), + ("E743-03 Exit 0 trotz lauf.log", " if LOG_FEHLER is not None:\n code = 1", + " if LOG_FEHLER is not None:\n pass", + ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf"]), + ("E743-04 Fehler des vorigen Laufs bleibt", " LOG_FEHLER = None", " pass", + ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf"]), + ("E743-05 Zeile in der Notiz des Tags", + " schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)", + " schreibe_notiz(vault, argumente.tag or jetzt.date().isoformat(), log_zeile)", + ["ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]), + ("E743-06 Zeile ohne Typ", "**nicht schreibbar** ({LOG_FEHLER}) #needs-review", "**nicht schreibbar** #needs-review", + ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf", + "ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht", + "ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]), + ("E743-07 gesperrte Notiz bricht main ab", + " schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)\n except Exception:\n pass", + " schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)\n except ZeroDivisionError:\n pass", + ["eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab"]), + # Seit der Plankritik NR: logge faengt jede Ausnahme und legt die Ablage im try an; eine nicht + # schreibbare Ergebnisdatei haelt weder die Zeile ihres Tages noch die folgenden Tage auf. + ("E743-08 lauf.log faengt nur OSError", + " except Exception as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__", + " except OSError as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__", + ["ein_fehler_in_logge_der_kein_oserror_ist_haelt_den_lauf_nicht_auf"]), + ("E743-09 Ablage vor dem try angelegt", + " try:\n ablage.mkdir(parents=True, exist_ok=True)\n", + " ablage.mkdir(parents=True, exist_ok=True)\n try:\n", + ["eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]), + ("E743-10 Ergebnisdatei reisst den Teil ab", + ' except Exception as ausnahme:\n code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei', + ' except ZeroDivisionError as ausnahme:\n code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei', + ["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf", + "eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]), + ("E743-11 Exit 0 trotz fehlender Ergebnisdatei", + ' code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei', + ' pass\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei', + ["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf"]), + ("E743-12 Zeile ohne Unterzeile zur Ergebnisdatei", + " schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit) + zusatz)", + " schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit))", + ["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf", + "eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]), + ("E743-13 Unterzeile ohne Typ", "Ergebnisdatei nicht geschrieben ({type(ausnahme).__name__}) #needs-review", + "Ergebnisdatei nicht geschrieben #needs-review", + ["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf", + "eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]), ("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")',