"""Angriffsfaelle fuer die Quellen wsl und dockerhost von abendkontrolle.py (Paket DH), unter Windows, ohne Netz. Koordinator-Werkzeug, nicht fuers Repo. Ersetzt ssh.exe durch eine .cmd-Attrappe, die ein kleines Python-Skript startet; der Vault ist ein Wegwerf-Ordner. Die Attrappe schreibt jeden Aufruf mit und antwortet je nach ANGRIFF_ART mit einem Ergebnis des angefragten Teils (JJJJ-MM-TT oder JJJJ-MM-TT-ibkr), mit Exit 3 (noch nichts da), mit Muell oder mit einem Ergebnis des falschen Teils. Der Treiber faengt je Fall auch SystemExit: Gegen ein abendkontrolle.py ohne --quelle bricht argparse damit ab, und das soll ein Fall sein, nicht das Ende des Laufs. Abgedichtet (Plankritik DH, 2026-09-26): WSL zeigt in jedem Lauf ins Leere, und fahre_wsl ist ohne eigene Attrappe des Falls eine Attrappe, die ablehnt; ein Aufruf des echten Wegs ist eine Abweichung, kein Lauf. Zwei Faelle belegen beide Sperren. Seit Paket E9 (Sicherung): Die Attrappe kennt auch sicherung-summe, sicherung und sicherung-bestaetigt, so streng wie abendkontrolle-lesen.sh (Task 724: Formen, Exit 0, 1, 2, 3, 4), gegen eine Host-Ablage im Wegwerf-Ordner; ANGRIFF_SICHERUNG stoert sie gezielt. Abgedichtet: 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. 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 Fall muss rot werden; Exit 0, wenn keiner ueberlebt, sonst 2. Die .ps1 liest der Treiber nur als Text. """ import contextlib import hashlib import importlib.util import io import json import os import sys import tempfile from datetime import date from datetime import datetime from pathlib import Path HIER = Path(__file__).resolve().parent # Seit Paket E9: das geladene Werkzeug; im Modus --mutanten eine kaputte Kopie (mutanten()). QUELLE = HIER / "abendkontrolle.py" # Seit der Plankritik Runde 1 von E9: das Skript der Aufgabe, nur als Text gelesen; im Modus # --mutanten auch hier eine kaputte Kopie. AUFGABE = HIER / "abendkontrolle_einrichten.ps1" ATTRAPPE_PY = r''' import json import os import sys with open(os.environ["ANGRIFF_PROTOKOLL"], "a", encoding="utf-8") as datei: datei.write(json.dumps(sys.argv[1:]) + "\n") befehl = sys.argv[-1] if befehl.startswith("sicherung"): # Seit Paket E9: die Lesebefehle der Sicherung, so streng wie abendkontrolle-lesen.sh (Task 724), # gegen die Ablage unter ANGRIFF_HOST. ANGRIFF_SICHERUNG ist oder :. import hashlib import re host = os.environ["ANGRIFF_HOST"] stoerung, _, nur = os.environ.get("ANGRIFF_SICHERUNG", "").partition(":") lesen = re.fullmatch(r"(sicherung-summe|sicherung) ([0-9]{4}-[0-9]{2}-[0-9]{2}-(?:alpaca|ibkr))", befehl) bestaetigen = re.fullmatch( r"sicherung-bestaetigt ([0-9]{4}-[0-9]{2}-[0-9]{2}-(?:alpaca|ibkr)) ([0-9a-f]{64})", befehl ) if lesen is None and bestaetigen is None: sys.stderr.write( "abendkontrolle-lesen: erwartet sicherung-summe|sicherung - " "oder sicherung-bestaetigt - \n" ) sys.exit(2) name = lesen.group(2) if lesen else bestaetigen.group(1) gestoert = stoerung if not nur or name.endswith("-" + nur) else "" archiv = os.path.join(host, name + ".tar") summendatei = archiv + ".sha256" gemeint = archiv if lesen and lesen.group(1) == "sicherung" else summendatei if not os.path.isfile(gemeint): sys.stderr.write("abendkontrolle-lesen: " + os.path.basename(gemeint) + " liegt auf dem Docker-Host nicht vor\n") sys.exit(3) with open(gemeint, "rb") as quelle: inhalt = quelle.read() if lesen and lesen.group(1) == "sicherung-summe": if gestoert == "summe_gross": inhalt = inhalt[:64].upper() + inhalt[64:] if gestoert == "summe_fremd": inhalt = inhalt.replace(name.encode("ascii"), b"2026-01-02-alpaca") sys.stdout.buffer.write(inhalt) sys.exit(0) if lesen: if gestoert == "kaputt": inhalt = inhalt[:-1] + bytes([inhalt[-1] ^ 1]) if gestoert == "abbruch": sys.stdout.buffer.write(inhalt[: len(inhalt) // 2]) sys.stdout.buffer.flush() sys.stderr.write("Connection reset by peer\n") sys.exit(255) sys.stdout.buffer.write(inhalt) sys.stdout.buffer.flush() if gestoert == "neu_gesichert": # Der Host sichert denselben Tag neu, waehrend Windows noch holt. neu = inhalt + b" neu gesichert" with open(archiv, "wb") as ziel: ziel.write(neu) with open(summendatei, "wb") as ziel: ziel.write((hashlib.sha256(neu).hexdigest() + " " + name + ".tar\n").encode("ascii")) sys.exit(0) summe = bestaetigen.group(2) if inhalt.split(b"\n")[0].split(b" ")[0].decode("ascii") != summe: sys.stderr.write("abendkontrolle-lesen: die Summe fuer " + name + " ist nicht die des Hosts, nichts bestaetigt\n") sys.exit(4) bestaetigt = os.path.join(host, "bestaetigt") if gestoert == "bestaetigung_scheitert" or not os.path.isdir(bestaetigt): sys.stderr.write("abendkontrolle-lesen: Bestaetigung fuer " + name + " nicht geschrieben\n") sys.exit(1) with open(os.path.join(bestaetigt, name), "wb") as ziel: ziel.write((summe + "\n").encode("ascii")) sys.exit(0) art = os.environ.get("ANGRIFF_ART", "gut") if art == "fehlt": sys.stderr.write("abendkontrolle-lesen: fuer diesen Tag liegt noch kein Ergebnis\n") sys.exit(3) if art == "muell": print("kein json") sys.exit(0) ziel = sys.argv[-1] ibkr = ziel.endswith("-ibkr") tag = ziel[:-5] if ibkr else ziel teil = "ibkr" if ibkr and art != "falscher_teil" else "alpaca" print(json.dumps({"tag": tag, "teil": teil, "befunde": [], "hinweise": [], "fakten": {"handelstag": True}, "exit": 0})) ''' FAELLE = [] def fall(funktion): FAELLE.append(funktion) return funktion def lade(): # QUELLE zeigt im Modus --mutanten auf eine kaputte Kopie (seit Paket E9). spec = importlib.util.spec_from_file_location("abendkontrolle_unter_angriff", QUELLE) modul = importlib.util.module_from_spec(spec) spec.loader.exec_module(modul) return modul def lauf(art, *argumente, modul=None): """Ein Lauf von main() in einem Wegwerf-Vault; liefert Exit, Notiz, Ergebnisdateien und ssh-Aufrufe.""" with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) vault = wurzel / "vault" (vault / "Inbox").mkdir(parents=True) protokoll = wurzel / "ssh.jsonl" attrappe = wurzel / "ssh_attrappe.py" attrappe.write_text(ATTRAPPE_PY, encoding="utf-8") cmd = wurzel / "ssh_attrappe.cmd" cmd.write_text(f'@"{sys.executable}" "{attrappe}" %*\r\n', encoding="utf-8") modul = modul or lade() modul.SSH = str(cmd) # Abdichtung (Plankritik DH, 2026-09-26): Der echte WSL-Weg lief sonst gegen den echten # Projektordner, fragte das Gateway und schickte echte ntfy-Meldungen. wsl.exe und das # Skript zeigen ins Leere, und fahre_wsl ist eine Attrappe, die jeden Aufruf samt beiden # Pfaden mitschreibt und ablehnt -- ausser ein Fall hat seine eigene gesetzt (wsl_attrappe). modul.WSL = str(wurzel / "kein-wsl" / "wsl.exe") modul.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" # Abdichtung (Paket E9): Ohne ersten Sicherungstag holt kein Lauf eine Sicherung, und das # Ziel liegt im Wegwerf-Ordner statt unter D:\Trading-Sicherung. Die Faelle der Sicherung # laufen ueber sicherungslauf(). modul.SICHERUNG_ERSTER_TAG = None modul.SICHERUNG_ZIEL = wurzel / "sicherung-ziel" os.environ.pop("ANGRIFF_HOST", None) os.environ.pop("ANGRIFF_SICHERUNG", None) unerwartet = [] if not getattr(modul.fahre_wsl, "attrappe", False): def wsl_verboten(tag, teil="alpaca"): unerwartet.append((tag, teil, modul.WSL, modul.WSL_SKRIPT)) raise RuntimeError("echter WSL-Weg ohne Attrappe") modul.fahre_wsl = wsl_verboten os.environ["ANGRIFF_PROTOKOLL"] = str(protokoll) os.environ["ANGRIFF_ART"] = art code = modul.main(["--vault", str(vault), *argumente]) if unerwartet: raise AssertionError(f"WSL-Weg ohne Attrappe gerufen: {unerwartet}") notizen = sorted((vault / "Inbox").glob("*.md")) notiz = "\n".join(n.read_text(encoding="utf-8") for n in notizen) ablage = vault / modul.ABLAGE_TEIL ergebnisse = sorted(p.name for p in ablage.glob("*.json")) if ablage.exists() else [] aufrufe = [] if protokoll.exists(): aufrufe = [json.loads(z) for z in protokoll.read_text(encoding="utf-8").splitlines() if z.strip()] return code, notiz, ergebnisse, aufrufe def wsl_attrappe(modul, gerufen): """Ersetzt fahre_wsl im Modul; schreibt (tag, teil) mit und liefert ein Ergebnis des Teils.""" def fahre_wsl(tag, teil="alpaca"): gerufen.append((tag, teil)) return {"tag": tag, "teil": teil, "befunde": [], "hinweise": [], "fakten": {}, "exit": 0} fahre_wsl.attrappe = True modul.fahre_wsl = fahre_wsl @fall def ergebnis_vom_host_landet_in_ablage_und_notiz(): code, notiz, ergebnisse, aufrufe = lauf( "gut", "--quelle", "dockerhost", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30" ) assert code == 0, code assert ergebnisse == ["2026-10-05-ibkr.json", "2026-10-05.json"], ergebnisse assert "Abendkontrolle Trading Plattform (automatisch): ohne Befund" in notiz, notiz assert "Abendkontrolle IBKR (automatisch): ohne Befund" in notiz, notiz assert len(aufrufe) == 2, aufrufe @fall def ssh_mit_festem_schluessel_ohne_rueckfrage_nur_zum_bekannten_host(): _, _, _, aufrufe = lauf("gut", "--quelle", "dockerhost", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30") for argumente, ziel in zip(aufrufe, ("2026-10-05", "2026-10-05-ibkr"), strict=True): assert argumente[:3] == ["-p", "2498", "-i"], argumente assert argumente[3].endswith("trading-dh-kontrolle"), argumente assert argumente[4:14] == [ "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=~/.ssh/known_hosts_trading_dh", "-o", "ConnectTimeout=20", ], argumente assert argumente[14:] == ["kontrolle@192.168.250.10", ziel], argumente @fall def noch_kein_ergebnis_ist_ein_befund_in_der_notiz_und_wird_wiederholt(): code, notiz, ergebnisse, _ = lauf( "fehlt", "--quelle", "dockerhost", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30" ) assert code == 1, code assert ergebnisse == [], ergebnisse assert "**gescheitert**" in notiz and "noch kein Ergebnis" in notiz, notiz assert "#needs-review" in notiz, notiz assert "fuer 2026-10-05-ibkr noch kein Ergebnis" in notiz, notiz @fall def muell_vom_host_ist_ein_befund(): code, notiz, ergebnisse, _ = lauf( "muell", "--quelle", "dockerhost", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30" ) assert code == 1, code assert ergebnisse == [], ergebnisse assert "keine JSON-Zeile vom Docker-Host" in notiz, notiz @fall def faellige_tage_kommen_einzeln_ueber_ssh(): modul = lade() _, _, ergebnisse, aufrufe = lauf("gut", "--quelle", "dockerhost", "--jetzt", "2026-10-05T22:10", modul=modul) tage = ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02"] ibkr = [f"{t}-ibkr" for t in tage if t >= modul.IBKR_ERSTER_TAG.isoformat()] ziele = [a[-1] for a in aufrufe] assert ziele == tage + ibkr, ziele assert ergebnisse == sorted(f"{z}.json" for z in ziele), ergebnisse @fall def ohne_schalter_bleibt_es_bei_wsl(): modul = lade() gerufen = [] wsl_attrappe(modul, gerufen) def verboten(*_argumente): raise AssertionError("ohne --quelle darf nichts ueber SSH laufen") modul.fahre_dockerhost = verboten code, _, ergebnisse, aufrufe = lauf("gut", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30", modul=modul) assert code == 0, code assert gerufen == [("2026-10-05", "alpaca"), ("2026-10-05", "ibkr")], gerufen assert aufrufe == [], aufrufe assert ergebnisse == ["2026-10-05-ibkr.json", "2026-10-05.json"], ergebnisse @fall def die_ibkr_instanz_bleibt_mit_quelle_ibkr_wsl_in_wsl(): modul = lade() gerufen = [] wsl_attrappe(modul, gerufen) code, _, ergebnisse, aufrufe = lauf( "gut", "--quelle", "dockerhost", "--quelle-ibkr", "wsl", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30", modul=modul, ) assert code == 0, code assert [a[-1] for a in aufrufe] == ["2026-10-05"], aufrufe assert gerufen == [("2026-10-05", "ibkr")], gerufen assert ergebnisse == ["2026-10-05-ibkr.json", "2026-10-05.json"], ergebnisse @fall def ein_ergebnis_des_falschen_teils_vom_host_gilt_als_gescheitert(): code, notiz, ergebnisse, aufrufe = lauf( "falscher_teil", "--quelle", "dockerhost", "--teil", "ibkr", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30" ) assert code == 1, code assert [a[-1] for a in aufrufe] == ["2026-10-05-ibkr"], aufrufe assert ergebnisse == [], ergebnisse assert "gehoert zu 'alpaca'" in notiz and "**gescheitert**" in notiz, notiz @fall def eine_unbekannte_quelle_ist_ein_bedienfehler(): fehler = io.StringIO() try: with contextlib.redirect_stderr(fehler): lauf("gut", "--quelle", "irgendwo", "--tag", "2026-10-05") except SystemExit as ende: assert ende.code == 2, ende.code assert "--quelle: invalid choice" in fehler.getvalue(), fehler.getvalue() return raise AssertionError("argparse hat die Quelle irgendwo angenommen") @fall def die_heutige_frist_und_die_aufgabe_passen_zueinander(): # Seit Paket E9 ueber QUELLE und AUFGABE, damit --mutanten auch diesen Fall trifft. Die Uhrzeit der # Aufgabe gilt nur in Ortszeit, wenn das Skript StartBoundary ohne Versatz setzt (S10; die Form # prueft der_abendausloeser_steht_in_ortszeit_ohne_versatz). quelle = QUELLE.read_text(encoding="utf-8") aufgabe = AUFGABE.read_text(encoding="utf-8") assert "$Abends.StartBoundary = " in aufgabe, "Abend-Ausloeser ohne StartBoundary in Ortszeit" assert "StrictHostKeyChecking=no" not in quelle assert "if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:" in quelle assert aufgabe.count('[ValidateSet("wsl", "dockerhost")]') == 2 assert "'\" --quelle ' + $Quelle + ' --quelle-ibkr ' + $QuelleIbkr" in aufgabe assert 'if ($Quelle -eq "dockerhost" -or $QuelleIbkr -eq "dockerhost") { $Uhrzeit = "23:15" }' in aufgabe assert "if (-not $QuelleIbkr) { $QuelleIbkr = $Quelle }" in aufgabe @fall def der_echte_wsl_weg_ist_versperrt(): # Ohne --quelle und ohne eigene Attrappe nimmt main den WSL-Weg. Er muss als Abweichung # enden, nicht als Lauf. try: lauf("gut", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30") except AssertionError as ausnahme: text = str(ausnahme) assert "WSL-Weg ohne Attrappe" in text, text # Beleg fuer die Zeilen in lauf(): Beim Aufruf zeigten wsl.exe und das Skript ins Leere. assert "kein-wsl" in text and "/nicht-vorhanden/" in text, text return raise AssertionError("ein Lauf ohne WSL-Attrappe kam durch") @fall def der_echte_wsl_aufruf_findet_kein_wsl(): # Die zweite Sperre: Selbst die echte fahre_wsl findet in der Abdichtung kein wsl.exe, und das # Skript, das sie dort starten wuerde, gibt es nicht. modul = lade() with tempfile.TemporaryDirectory() as ordner: modul.WSL = str(Path(ordner) / "kein-wsl" / "wsl.exe") modul.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" try: modul.fahre_wsl("2026-10-05", "alpaca") except FileNotFoundError: return raise AssertionError("fahre_wsl lief ohne wsl.exe weiter") # --- Seit Paket E9: das Abholen der Sicherung ------------------------------------------------- TAG = "2026-10-05" KOPF = "Sicherung Trading Plattform (automatisch)" # Bytes, die kein Text sind, auch CR, LF und Strg-Z: Die Uebertragung muss sie unveraendert lassen. ARCHIVE = { "alpaca": b"tar-attrappe alpaca\r\n" + bytes(range(256)), "ibkr": b"tar-attrappe ibkr\n" + bytes(reversed(range(256))), } def host_ablage(wurzel, tage=(TAG,), instanzen=("alpaca", "ibkr")): """Die Ablage des Hosts wie nach sicherung-host.sh (Task 723): Archiv, Summendatei, bestaetigt/.""" host = wurzel / "host" (host / "bestaetigt").mkdir(parents=True) for tag in tage: for instanz in instanzen: inhalt = ARCHIVE[instanz] + tag.encode("ascii") (host / f"{tag}-{instanz}.tar").write_bytes(inhalt) zeile = f"{hashlib.sha256(inhalt).hexdigest()} {tag}-{instanz}.tar\n" (host / f"{tag}-{instanz}.tar.sha256").write_bytes(zeile.encode("ascii")) return host def host_summe(host, instanz, tag=TAG): """Die Summe, die der Host fuer - fuehrt.""" return (host / f"{tag}-{instanz}.tar.sha256").read_bytes()[:64].decode("ascii") def sicherungszeilen(notiz): """Die Zeilen der Notizen, die zur Sicherung gehoeren.""" return [zeile for zeile in notiz.splitlines() if KOPF in zeile] def einzige_zeile(notiz): """Genau eine Zeile zur Sicherung, sonst eine Abweichung.""" zeilen = sicherungszeilen(notiz) assert len(zeilen) == 1, notiz return zeilen[0] 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): """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 weiterarbeiten kann. Abgedichtet wie lauf(): ssh ist die Attrappe, WSL zeigt ins Leere, und fahre_wsl lehnt ohne eigene Attrappe des Falls ab. Mit schalter geht die Sicherung ueber --sicherung-ziel nach wurzel/ziel, und das Ziel der Konstante (wurzel/konstante-ziel) muss leer bleiben; ohne schalter gilt die Konstante. Liefert Exit, Notizen, die Befehle der Sicherung an den Host und ihre ganzen Befehlszeilen. """ vault = wurzel / "vault" (vault / "Inbox").mkdir(parents=True, exist_ok=True) protokoll = wurzel / "ssh.jsonl" protokoll.unlink(missing_ok=True) attrappe = wurzel / "ssh_attrappe.py" attrappe.write_text(ATTRAPPE_PY, encoding="utf-8") cmd = wurzel / "ssh_attrappe.cmd" cmd.write_text(f'@"{sys.executable}" "{attrappe}" %*\r\n', encoding="utf-8") modul = modul or lade() modul.SSH = str(cmd) modul.WSL = str(wurzel / "kein-wsl" / "wsl.exe") modul.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" unerwartet = [] if not getattr(modul.fahre_wsl, "attrappe", False): def wsl_verboten(tag, teil="alpaca"): unerwartet.append((tag, teil)) raise RuntimeError("echter WSL-Weg ohne Attrappe") modul.fahre_wsl = wsl_verboten modul.SICHERUNG_ERSTER_TAG = erster_tag modul.SICHERUNG_ZIEL = wurzel / "konstante-ziel" os.environ["ANGRIFF_PROTOKOLL"] = str(protokoll) os.environ["ANGRIFF_ART"] = "gut" os.environ["ANGRIFF_HOST"] = str(wurzel / "host") os.environ["ANGRIFF_SICHERUNG"] = stoerung vorne = ["--vault", str(vault), "--quelle", "dockerhost"] if schalter: vorne += ["--sicherung-ziel", str(wurzel / "ziel")] code = modul.main([*vorne, *argumente]) if unerwartet: raise AssertionError(f"WSL-Weg ohne Attrappe gerufen: {unerwartet}") if schalter and (wurzel / "konstante-ziel").exists(): raise AssertionError("Sicherung unter SICHERUNG_ZIEL statt unter --sicherung-ziel") notiz = "\n".join(n.read_text(encoding="utf-8") for n in sorted((vault / "Inbox").glob("*.md"))) zeilen = protokoll.read_text(encoding="utf-8").splitlines() if protokoll.exists() else [] ganz = [json.loads(z) for z in zeilen if z.strip()] ganz = [argumente_ssh for argumente_ssh in ganz if argumente_ssh[-1].startswith("sicherung")] return code, notiz, [argumente_ssh[-1] for argumente_ssh in ganz], ganz @fall def sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt(): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) code, notiz, befehle, ganz = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") assert code == 0, (code, notiz) erwartet = [] for instanz in ("alpaca", "ibkr"): erwartet += [ f"sicherung-summe {TAG}-{instanz}", f"sicherung {TAG}-{instanz}", f"sicherung-bestaetigt {TAG}-{instanz} {host_summe(host, instanz)}", ] assert befehle == erwartet, befehle for argumente_ssh in ganz: assert argumente_ssh[:3] == ["-p", "2498", "-i"], argumente_ssh assert argumente_ssh[3].endswith("trading-dh-kontrolle"), argumente_ssh assert argumente_ssh[4:15] == [ "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=~/.ssh/known_hosts_trading_dh", "-o", "ConnectTimeout=20", "kontrolle@192.168.250.10", ], argumente_ssh assert len(argumente_ssh) == 16, argumente_ssh ziel = wurzel / "ziel" / TAG assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) groessen = {} for instanz in ("alpaca", "ibkr"): inhalt = (host / f"{TAG}-{instanz}.tar").read_bytes() summe = hashlib.sha256(inhalt).hexdigest() assert (ziel / f"{instanz}.tar").read_bytes() == inhalt, instanz assert (ziel / f"{instanz}.tar.sha256").read_bytes() == f"{summe} {instanz}.tar\n".encode("ascii"), instanz assert (host / "bestaetigt" / f"{TAG}-{instanz}").read_bytes() == f"{summe}\n".encode("ascii"), instanz groessen[instanz] = len(inhalt) assert einzige_zeile(notiz) == ( f"- 23:30 Uhr — {KOPF}: alpaca abgelegt und bestätigt ({groessen['alpaca']} Bytes); " f"ibkr abgelegt und bestätigt ({groessen['ibkr']} Bytes)." ), notiz assert notiz.index("## Agentenprotokoll") < notiz.index(KOPF), notiz @fall def eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt(): # Reihenfolge: derselbe Schaden an der ersten und an der letzten Instanz. for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) code, notiz, befehle, _ = sicherungslauf( wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=f"kaputt:{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 [b for b in befehle if b.startswith("sicherung-bestaetigt ")] == [ f"sicherung-bestaetigt {TAG}-{heil} {host_summe(host, heil)}" ], befehle assert "**gescheitert** #needs-review" in zeile, zeile assert f"{kaputt} gescheitert (RuntimeError: Summe des geholten Archivs ist nicht die des Hosts" in zeile, zeile assert f"{heil} abgelegt und bestätigt" in zeile, zeile @fall def 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, befehle, _ = sicherungslauf( wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=f"abbruch:{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 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 @fall def exit_3_heisst_noch_keine_sicherung_auf_dem_host(): for vorhanden in ((), ("ibkr",), ("alpaca",)): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel, instanzen=vorhanden) # Seit der Plankritik Runde 1 ist Exit 3 nur fuer einen vergangenen Werktag ein Fehlschlag: # TAG ist ein Montag, der Lauf kommt am Dienstag. code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-06T23:30") zeile = einzige_zeile(notiz) assert code == 1, (vorhanden, code) assert "**gescheitert** #needs-review" in zeile, zeile for instanz in ("alpaca", "ibkr"): if instanz in vorhanden: assert f"{instanz} abgelegt und bestätigt" in zeile, zeile else: assert f"auf dem Docker-Host liegt fuer {TAG}-{instanz} noch keine Sicherung" in zeile, zeile assert f"sicherung {TAG}-{instanz}" not in befehle, befehle assert not (wurzel / "ziel" / TAG / f"{instanz}.tar").exists(), instanz assert namen(host / "bestaetigt") == [f"{TAG}-{i}" for i in vorhanden], namen(host / "bestaetigt") @fall def exit_3_fuer_heute_und_das_wochenende_ist_still(): # Plankritik Runde 1, Befunde 8, 16 und 18: Heute kann die Sicherung des Hosts noch ausstehen, und # am Wochenende sichert er nur, wenn er einen Lauf nachholt. Exit 3 ist dann kein Fehlschlag und # gibt fuer sich allein keine Zeile. with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel, tage=()) code, notiz, befehle, _ = sicherungslauf( wurzel, "--jetzt", "2026-10-05T18:00", erster_tag=date(2026, 10, 3) ) assert code == 0, (code, notiz) assert befehle == [ f"sicherung-summe {t}-{i}" for t in ("2026-10-03", "2026-10-04", "2026-10-05") for i in ("alpaca", "ibkr") ], befehle assert sicherungszeilen(notiz) == [], notiz assert not (wurzel / "ziel").exists() code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") assert code == 0, (code, notiz) assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle assert sicherungszeilen(notiz) == [], notiz # Fehlt heute nur eine Instanz, nennt die Zeile sie ohne #needs-review. with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel, instanzen=("alpaca",)) code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") zeile = einzige_zeile(notiz) assert code == 0, (code, notiz) assert zeile.endswith("ibkr auf dem Docker-Host noch nicht gesichert."), zeile assert "#needs-review" not in zeile, zeile @fall def eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt(): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel) erster = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") assert erster[0] == 0, erster[:2] ziel = wurzel / "ziel" / TAG vorher = {p.name: p.read_bytes() for p in ziel.iterdir()} code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45") assert code == 0, code # Seit der Plankritik Runde 1 fragt jeder Lauf die Summe des Hosts; geholt wird nur bei Abweichung. assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle assert {p.name: p.read_bytes() for p in ziel.iterdir()} == vorher assert len(sicherungszeilen(notiz)) == 1, notiz @fall def ein_neu_gesicherter_tag_wird_neu_geholt_und_ersetzt(): # Plankritik Runde 1, Befunde 5 und 24: Sichert der Host einen Tag nach dem Abholen ein zweites Mal # (Abnahme 3 von Hand, um 16:45 der Zeitgeber), entfernt er dessen Bestaetigung (Task 723). Der # naechste Lauf sieht die neue Summe, holt neu, ersetzt die alte Fassung auf D: und bestaetigt. with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) erster = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T10:30") assert erster[0] == 0, erster[:2] neu = ARCHIVE["alpaca"] + b" zweiter Lauf" summe = hashlib.sha256(neu).hexdigest() (host / f"{TAG}-alpaca.tar").write_bytes(neu) (host / f"{TAG}-alpaca.tar.sha256").write_bytes(f"{summe} {TAG}-alpaca.tar\n".encode("ascii")) (host / "bestaetigt" / f"{TAG}-alpaca").unlink() code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:15") ziel = wurzel / "ziel" / TAG assert code == 0, (code, notiz) assert befehle == [ f"sicherung-summe {TAG}-alpaca", f"sicherung {TAG}-alpaca", f"sicherung-bestaetigt {TAG}-alpaca {summe}", f"sicherung-summe {TAG}-ibkr", ], befehle assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) assert (ziel / "alpaca.tar").read_bytes() == neu assert (ziel / "alpaca.tar.sha256").read_bytes() == f"{summe} alpaca.tar\n".encode("ascii") assert (host / "bestaetigt" / f"{TAG}-alpaca").read_bytes() == f"{summe}\n".encode("ascii") zeilen = sicherungszeilen(notiz) assert len(zeilen) == 2, notiz assert "alpaca abgelegt und bestätigt" in zeilen[1] and "ibkr lag schon vollständig" in zeilen[1], zeilen[1] @fall def nur_die_fehlende_instanz_wird_geholt(): for da, fehlt in (("alpaca", "ibkr"), ("ibkr", "alpaca")): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) ziel = wurzel / "ziel" / TAG ziel.mkdir(parents=True) (ziel / f"{da}.tar").write_bytes((host / f"{TAG}-{da}.tar").read_bytes()) (ziel / f"{da}.tar.sha256").write_bytes(f"{host_summe(host, da)} {da}.tar\n".encode("ascii")) code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") zeile = einzige_zeile(notiz) assert code == 0, (code, notiz) summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] assert summen == [f"{TAG}-alpaca", f"{TAG}-ibkr"], befehle assert [b.split(" ")[1] for b in befehle if not b.startswith("sicherung-summe ")] == [f"{TAG}-{fehlt}"] * 2, befehle assert f"{da} lag schon vollständig" in zeile and f"{fehlt} abgelegt und bestätigt" in zeile, zeile assert namen(host / "bestaetigt") == [f"{TAG}-{fehlt}"], namen(host / "bestaetigt") @fall def fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung(): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) ziel = wurzel / "ziel" / TAG ziel.mkdir(parents=True) (ziel / "notiz.txt").write_bytes(b"fremd\n") (ziel / "alpaca.tar.teil").write_bytes(b"Rest eines abgebrochenen Laufs") # alpaca: die passende Summendatei, aber kein Archiv daneben. ibkr: richtiges Archiv und richtige # Summe, aber in der Summendatei der Name einer anderen Datei. Das Archiv selbst hasht der Lauf # nicht nach (Plankritik Runde 1, Befund 21); beides zaehlt trotzdem nicht als Sicherung. (ziel / "alpaca.tar.sha256").write_bytes(f"{host_summe(host, 'alpaca')} alpaca.tar\n".encode("ascii")) (ziel / "ibkr.tar").write_bytes((host / f"{TAG}-ibkr.tar").read_bytes()) (ziel / "ibkr.tar.sha256").write_bytes(f"{host_summe(host, 'ibkr')} alpaca.tar\n".encode("ascii")) code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") assert code == 0, (code, notiz) assert len([b for b in befehle if b.startswith("sicherung-bestaetigt ")]) == 2, befehle assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256", "notiz.txt"], namen(ziel) assert (ziel / "notiz.txt").read_bytes() == b"fremd\n" for instanz in ("alpaca", "ibkr"): summe = host_summe(host, instanz) assert (ziel / f"{instanz}.tar").read_bytes() == (host / f"{TAG}-{instanz}.tar").read_bytes(), instanz assert (ziel / f"{instanz}.tar.sha256").read_bytes() == f"{summe} {instanz}.tar\n".encode("ascii"), instanz @fall def ein_tagesordner_der_eine_datei_ist_ist_ein_befund(): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) (wurzel / "ziel").mkdir() (wurzel / "ziel" / TAG).write_bytes(b"keine Sicherung\n") code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") zeile = einzige_zeile(notiz) assert code == 1, code assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle assert (wurzel / "ziel" / TAG).read_bytes() == b"keine Sicherung\n" assert namen(host / "bestaetigt") == [], namen(host / "bestaetigt") assert "**gescheitert** #needs-review" in zeile and zeile.count("FileExistsError") == 2, zeile @fall def eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt(): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) ziel = wurzel / "ziel" / TAG ziel.mkdir(parents=True) # Eine aeltere, in sich stimmige Fassung: Der Host hat den Tag seitdem neu gesichert. Ihre # Summendatei muss vor dem Umbenennen weg, sonst laege neben dem neuen Archiv eine Summe, die # nicht passt, und sha256sum -c schluege bei der Ruecksicherung fehl. alt = b"aeltere Fassung" (ziel / "alpaca.tar").write_bytes(alt) (ziel / "alpaca.tar.sha256").write_bytes(f"{hashlib.sha256(alt).hexdigest()} alpaca.tar\n".encode("ascii")) code, notiz, _, _ = sicherungslauf( wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung="bestaetigung_scheitert" ) zeile = einzige_zeile(notiz) assert code == 1, code assert namen(ziel) == ["alpaca.tar", "ibkr.tar"], namen(ziel) assert namen(host / "bestaetigt") == [], namen(host / "bestaetigt") assert "#needs-review" in zeile and zeile.count("abgelegt, aber nicht bestaetigt (Exit 1;") == 2, zeile code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45") assert code == 0, (code, notiz) assert len([b for b in befehle if b.startswith("sicherung-bestaetigt ")]) == 2, befehle 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") @fall def eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt(): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) alt = (host / f"{TAG}-ibkr.tar").read_bytes() code, notiz, _, _ = sicherungslauf( wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung="neu_gesichert:ibkr" ) ziel = wurzel / "ziel" / TAG zeile = einzige_zeile(notiz) assert code == 1, code assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar"], namen(ziel) assert (ziel / "ibkr.tar").read_bytes() == alt assert namen(host / "bestaetigt") == [f"{TAG}-alpaca"], namen(host / "bestaetigt") assert "ibkr gescheitert (RuntimeError: abgelegt, aber nicht bestaetigt (Exit 4;" in zeile, zeile assert "#needs-review" in zeile, zeile code, _, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45") neu = (host / f"{TAG}-ibkr.tar").read_bytes() assert code == 0, code assert neu != alt and (ziel / "ibkr.tar").read_bytes() == neu assert (host / "bestaetigt" / f"{TAG}-ibkr").read_bytes() == f"{host_summe(host, 'ibkr')}\n".encode("ascii") @fall def eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv(): for stoerung in ("summe_gross", "summe_fremd"): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host = host_ablage(wurzel) code, notiz, befehle, _ = sicherungslauf( wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=stoerung ) zeile = einzige_zeile(notiz) assert code == 1, (stoerung, code) assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], (stoerung, befehle) assert not (wurzel / "ziel" / TAG).exists(), stoerung assert namen(host / "bestaetigt") == [], (stoerung, namen(host / "bestaetigt")) assert "#needs-review" in zeile and zeile.count("Summendatei des Hosts in unerwarteter Form") == 2, zeile @fall def ohne_ersten_sicherungstag_holt_kein_lauf_eine_sicherung(): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel) code, notiz, befehle, _ = sicherungslauf( wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", erster_tag=None ) assert code == 0, code assert befehle == [], befehle assert not (wurzel / "ziel").exists() assert sicherungszeilen(notiz) == [], notiz @fall def faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters(): # Plankritik Runde 1, Befunde 16, 18 und 19: Gefragt wird jeder Kalendertag der letzten # SICHERUNG_NACHHOLEN_TAGE bis heute, der aelteste zuerst; auch Samstag und Sonntag (ein # nachgeholter Lauf des Hosts traegt das Datum des Nachholens) und heute schon vor 23 Uhr. auf_dem_host = [ "2026-10-01", "2026-10-02", "2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09", "2026-10-10", "2026-10-12", ] fenster = [f"2026-10-{t:02d}" for t in range(2, 13)] with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel, tage=auf_dem_host) modul = lade() # Die Ergebnisse der Abendkontrolle liegen schon alle; die Sicherung ist trotzdem faellig. ablage = wurzel / "vault" / modul.ABLAGE_TEIL ablage.mkdir(parents=True) for tag in fenster: (ablage / f"{tag}.json").write_text("{}", encoding="utf-8") (ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8") code, notiz, befehle, _ = sicherungslauf( wurzel, "--jetzt", "2026-10-12T10:00", modul=modul, erster_tag=date(2026, 9, 1) ) summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] geholt = [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")] assert code == 0, (code, notiz) assert summen == [f"{t}-{i}" for t in fenster for i in ("alpaca", "ibkr")], summen # 2026-10-01 liegt vor dem Fenster; 10-03, 10-04 und 10-11 fehlen auf dem Host (Wochenende, still). assert geholt == [f"{t}-{i}" for t in auf_dem_host[1:] for i in ("alpaca", "ibkr")], geholt # Der erste Sicherungstag begrenzt das Fenster von unten; schon Geholtes wird nur nachgefragt. code, _, befehle, _ = sicherungslauf(wurzel, "--jetzt", "2026-10-12T10:30", erster_tag=date(2026, 10, 6)) summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] assert code == 0, code assert summen == [f"2026-10-{t:02d}-{i}" for t in range(6, 13) for i in ("alpaca", "ibkr")], summen assert [b for b in befehle if not b.startswith("sicherung-summe ")] == [], befehle @fall def das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit(): # Plankritik Runde 1, Befund 12: Jedes Archiv hat SICHERUNG_TIMEOUT_S (600 s), der ganze Lauf # SICHERUNG_BUDGET_S (1500 s). Runde 2, Befunde 1 und 4: Das Budget zaehlt ab dem Start des Laufs, # die Ergebnisse eingerechnet, und ist bei genau 1500 s um. Die Uhr ist eine Attrappe: Jeder # Ergebnisteil stellt sie 400 s vor, jede Uebertragung 700 s; vor ibkr steht sie auf 1500. tage = ["2026-10-01", "2026-10-02", "2026-10-05"] with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel, tage=tage) modul = lade() echt = modul.ssh_lauf echt_teil = modul.fahre_teil uhr = [0.0] zeitlimits = [] def ssh_lauf(befehl, timeout, stdout=None): if befehl.startswith("sicherung "): zeitlimits.append(timeout) uhr[0] += 700 return echt(befehl, timeout, stdout) def fahre_teil(*argumente, **benannt): uhr[0] += 400 return echt_teil(*argumente, **benannt) modul.ssh_lauf = ssh_lauf modul.fahre_teil = fahre_teil modul.monotonic = lambda: uhr[0] code, notiz, befehle, _ = sicherungslauf( wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=date(2026, 10, 1) ) assert code == 0, (code, notiz) assert uhr[0] == 1500, uhr assert zeitlimits == [600], zeitlimits assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")] == ["2026-10-01-alpaca"], befehle # Nach dem Budget fragt der Lauf auch keine Summe mehr. assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] == [ "2026-10-01-alpaca" ], befehle zeilen = sicherungszeilen(notiz) assert len(zeilen) == 1, notiz assert zeilen[0].endswith( "ibkr nicht angefangen: Zeitbudget des Laufs (1500 s) erschöpft, der nächste Lauf holt den Rest." ), zeilen[0] assert "#needs-review" not in "\n".join(zeilen), zeilen # Der naechste Lauf holt den Rest; die schon geholten Tage fragt er nur nach der Summe. Seine # Ergebnisse liegen schon, sein Ergebnisteil kostet hier keine Zeit. modul.fahre_teil = echt_teil uhr[0] = 0.0 zeitlimits.clear() code, _, befehle, _ = sicherungslauf( wurzel, "--jetzt", "2026-10-05T23:45", modul=modul, erster_tag=date(2026, 10, 1) ) assert code == 0, code assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")] == [ "2026-10-01-ibkr", "2026-10-02-alpaca", "2026-10-02-ibkr" ], befehle @fall def nur_teile_mit_quelle_dockerhost_holen_eine_sicherung(): with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel) modul = lade() gerufen = [] wsl_attrappe(modul, gerufen) code, notiz, befehle, _ = sicherungslauf( wurzel, "--quelle-ibkr", "wsl", "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul ) assert code == 0, (code, notiz) assert gerufen == [(TAG, "ibkr")], gerufen assert [b.split(" ")[1] for b in befehle] == [f"{TAG}-alpaca"] * 3, befehle assert "ibkr" not in einzige_zeile(notiz), notiz with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel) code, notiz, befehle, _ = sicherungslauf(wurzel, "--teil", "ibkr", "--tag", TAG, "--jetzt", "2026-10-05T23:30") assert code == 0, (code, notiz) assert [b.split(" ")[1] for b in befehle] == [f"{TAG}-ibkr"] * 3, befehle assert "alpaca" not in einzige_zeile(notiz), notiz @fall def ohne_schalter_gilt_das_ziel_der_konstante(): assert lade().SICHERUNG_ZIEL == Path(r"D:\Trading-Sicherung"), "Ziel der Konstante" with tempfile.TemporaryDirectory() as ordner: wurzel = Path(ordner) host_ablage(wurzel) code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", schalter=False) assert code == 0, (code, notiz) ziel = wurzel / "konstante-ziel" / TAG assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) assert not (wurzel / "ziel").exists() @fall def ein_tag_ausser_der_form_holt_keine_sicherung(): for tag in ("2026-10-5", "2026-13-05", "20261005", "../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) assert not (wurzel / "ziel").exists(), tag assert sicherungszeilen(notiz) == [], (tag, notiz) @fall def der_abendausloeser_steht_in_ortszeit_ohne_versatz(): # S10 (Plankritik Runde 1, Befund 8): New-ScheduledTaskTrigger -At legt StartBoundary mit UTC-Versatz # an, und die Aufgabe liefe im Winter um 22:15 Ortszeit. Ohne Versatz gesetzt, folgt sie der Ortszeit # (gemessen vom Koordinator am 2026-10-11). Geprueft wird nur der Text des Skripts; nichts wird # eingetragen. zeilen = AUFGABE.read_text(encoding="utf-8").splitlines() zeile = "$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00'" assert zeilen.count(zeile) == 1, f"StartBoundary in Ortszeit {zeilen.count(zeile)}-mal" gesetzt = [z for z in zeilen if ".StartBoundary =" in z] assert gesetzt == [zeile], gesetzt ausloeser = [i for i, z in enumerate(zeilen) if z.startswith("$Abends = New-ScheduledTaskTrigger -Weekly ")] eintragen = [i for i, z in enumerate(zeilen) if z.startswith("Register-ScheduledTask ")] assert len(ausloeser) == 1 and len(eintragen) == 1, (ausloeser, eintragen) assert ausloeser[0] < zeilen.index(zeile) < eintragen[0], (ausloeser, zeilen.index(zeile), eintragen) # 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 # (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 8). MUTANTEN = [ ("Summe ungeprueft", " if summe_von(teil) != summe:", " if False:", ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt"]), ("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"]), ("Nachbardatei bleibt", " teil.unlink(missing_ok=True)", " pass", ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt", "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]), ("Bestaetigung ungeprueft", ' if lauf.returncode != 0:\n raise RuntimeError(f"abgelegt, aber nicht bestaetigt', ' if False:\n raise RuntimeError(f"abgelegt, aber nicht bestaetigt', ["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt", "eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt"]), ("alte Summendatei bleibt", " summendatei.unlink(missing_ok=True)", " pass", ["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt"]), ("erster Tag egal", " if erster is None:\n return []", " if False:\n return []", ["ohne_ersten_sicherungstag_holt_kein_lauf_eine_sicherung", "ergebnis_vom_host_landet_in_ablage_und_notiz"]), ("Fenster 9 Tage", "SICHERUNG_NACHHOLEN_TAGE = 10", "SICHERUNG_NACHHOLEN_TAGE = 9", ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), ("Fenster 11 Tage", "SICHERUNG_NACHHOLEN_TAGE = 10", "SICHERUNG_NACHHOLEN_TAGE = 11", ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), ("heute nicht im Fenster", "range(SICHERUNG_NACHHOLEN_TAGE, -1, -1)", "range(SICHERUNG_NACHHOLEN_TAGE, 0, -1)", ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), ("erster Tag im Fenster egal", " if kandidat >= erster:", " if True:", ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), ("Wochenende nicht im Fenster", " if kandidat >= erster:", " if kandidat >= erster and kandidat.weekday() < 5:", ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), ("Archiv auf D: egal", " if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():", " if treffer is None or treffer.group(2) != archiv.name:", ["fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung"]), ("Name auf D: ungeprueft", " if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():", " if treffer is None or not archiv.is_file():", ["fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung"]), ("nie vollstaendig", " if summe_auf_d(ziel / tag, instanz) == summe:", " if False:", ["eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt", "nur_die_fehlende_instanz_wird_geholt"]), ("Summe des Hosts egal", " if summe_auf_d(ziel / tag, instanz) == summe:", " if summe_auf_d(ziel / tag, instanz) is not None:", ["ein_neu_gesicherter_tag_wird_neu_geholt_und_ersetzt"]), ("Zeile fuer jeden Tag", " if not (gescheitert or getan):\n continue", " if False:\n continue", ["eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt", "exit_3_fuer_heute_und_das_wochenende_ist_still"]), ("Quelle egal", ' if ((argumente.quelle_ibkr or argumente.quelle) if teil == "ibkr" else argumente.quelle) == "dockerhost"', " if True", ["nur_teile_mit_quelle_dockerhost_holen_eine_sicherung"]), ("Exit 3 ohne Grund", " if lauf.returncode == 3:\n return None", " if False:\n return None", ["exit_3_heisst_noch_keine_sicherung_auf_dem_host", "exit_3_fuer_heute_und_das_wochenende_ist_still"]), ("Exit 3 immer still", " if ohne_sicherung_still(tag, jetzt):", " if True:", ["exit_3_heisst_noch_keine_sicherung_auf_dem_host"]), ("heute nicht still", " return kandidat >= jetzt.date() or kandidat.weekday() >= 5", " return kandidat > jetzt.date() or kandidat.weekday() >= 5", ["exit_3_fuer_heute_und_das_wochenende_ist_still"]), ("Wochenende nicht still", " return kandidat >= jetzt.date() or kandidat.weekday() >= 5", " return kandidat >= jetzt.date()", ["exit_3_fuer_heute_und_das_wochenende_ist_still"]), ("Fehlschlag ohne Markierung", "**gescheitert** #needs-review. {'; '.join(stuecke)}", "**gescheitert**. {'; '.join(stuecke)}", ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt"]), ("Fehlschlag reisst den Tag ab", " except Exception as ausnahme:\n gescheitert = True", " except ZeroDivisionError as ausnahme:\n gescheitert = True", ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt", "ein_tagesordner_der_eine_datei_ist_ist_ein_befund"]), ("Zielschalter egal", " ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL", " ziel = SICHERUNG_ZIEL", ["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]), ("Rueckfall nicht auf die Konstante", " ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL", ' ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL / "x"', ["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"]), ("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")', r'SUMMENZEILE = re.compile(r"([0-9a-fA-F]{64}) (\S+)\n")', ["eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv"]), ("Archiv direkt unter dem endgueltigen Namen", ' teil = ordner / f"{instanz}.tar.teil"', ' teil = ordner / f"{instanz}.tar"', ["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]), ("Summendatei auf D: mit dem Namen des Hosts", r' neu.write_bytes(f"{summe} {archiv.name}\n".encode("ascii"))', r' neu.write_bytes(f"{summe} {tag}-{archiv.name}\n".encode("ascii"))', ["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]), ("Zeitlimit des Archivs weg", ' lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom)', ' lauf = ssh_lauf(f"sicherung {name}", None, strom)', ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), ("Zeitlimit des Archivs anders", "SICHERUNG_TIMEOUT_S = 600", "SICHERUNG_TIMEOUT_S = 6000", ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), ("Budget egal", " if monotonic() >= frist:", " if False:", ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), ("Budget anders", "SICHERUNG_BUDGET_S = 1500", "SICHERUNG_BUDGET_S = 3600", ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), ("nach dem Budget weiter", " if erschoepft:\n break", " if False:\n break", ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), ("Budget erst nach der Grenze", " if monotonic() >= frist:", " if monotonic() > frist:", ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), ("Budget erst ab dem Holen", "hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, frist, argumente.tag)", "hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, monotonic() + SICHERUNG_BUDGET_S, argumente.tag)", ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), ("Summendatei vor der Bestaetigung", ' lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n' " if lauf.returncode != 0:\n" ' raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")\n' ' neu = ordner / f"{instanz}.tar.sha256.neu"\n' ' neu.write_bytes(f"{summe} {archiv.name}\\n".encode("ascii"))\n' " os.replace(neu, summendatei)\n", ' neu = ordner / f"{instanz}.tar.sha256.neu"\n' ' neu.write_bytes(f"{summe} {archiv.name}\\n".encode("ascii"))\n' " os.replace(neu, summendatei)\n" ' lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n' " if lauf.returncode != 0:\n" ' raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")\n', ["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt", "eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt"]), ] # Seit der Plankritik Runde 1: Mutanten von abendkontrolle_einrichten.ps1 (AUFGABE), gleiche Form. MUTANTEN_AUFGABE = [ ("Ausloeser mit Versatz", "$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00'\n", "", ["der_abendausloeser_steht_in_ortszeit_ohne_versatz", "die_heutige_frist_und_die_aufgabe_passen_zueinander"]), ("Ausloeser in UTC", "+ $Uhrzeit + ':00'", "+ $Uhrzeit + ':00Z'", ["der_abendausloeser_steht_in_ortszeit_ohne_versatz"]), ] def mutanten() -> int: """Jeder Mutant auf einer Kopie seiner Datei; jeder seiner Faelle muss rot werden. MUTANTEN aendern abendkontrolle.py (QUELLE), MUTANTEN_AUFGABE abendkontrolle_einrichten.ps1 (AUFGABE). """ global QUELLE, AUFGABE faelle = {funktion.__name__: funktion for funktion in FAELLE} alle = [("abendkontrolle.py", *mutant) for mutant in MUTANTEN] alle += [("abendkontrolle_einrichten.ps1", *mutant) for mutant in MUTANTEN_AUFGABE] def gruen_bleiben(namen_der_faelle): gruen = [] for fallname in namen_der_faelle: try: faelle[fallname]() except (Exception, SystemExit): continue gruen.append(fallname) return gruen genannt = sorted({fallname for *_, rot in alle for fallname in rot}) fehlend = [fallname for fallname in genannt if fallname not in faelle] if fehlend: print(f"FALL FEHLT: {fehlend}") return 2 # Grundlinie: Ohne Mutant muss jeder genannte Fall gruen sein, sonst misst der Rest nichts. rot_ohne_mutant = [fallname for fallname in genannt if not gruen_bleiben([fallname])] if rot_ohne_mutant: print(f"GRUNDLINIE rot: {rot_ohne_mutant}") return 2 ueberlebt = [] for datei, name, alt, neu, rot in alle: original = (HIER / datei).read_text(encoding="utf-8") anzahl = original.count(alt) if anzahl != 1: print(f"ANKER {anzahl}-mal: {name}") ueberlebt.append(name) continue with tempfile.TemporaryDirectory() as ordner: kopie = Path(ordner) / datei kopie.write_text(original.replace(alt, neu), encoding="utf-8", newline="\n") if datei == "abendkontrolle.py": QUELLE = kopie else: AUFGABE = kopie try: gruen = gruen_bleiben(rot) finally: QUELLE = HIER / "abendkontrolle.py" AUFGABE = HIER / "abendkontrolle_einrichten.ps1" if gruen: ueberlebt.append(name) print(f"UEBERLEBT {name}: gruen blieben {gruen}") else: print(f"OK {name}: rot wurden {rot}") print(f"{len(alle)} Mutanten, {len(ueberlebt)} ueberlebt ({datetime.now():%Y-%m-%d %H:%M})") return 2 if ueberlebt else 0 def main(argv=None) -> int: argumente = sys.argv[1:] if argv is None else argv if argumente == ["--mutanten"]: return mutanten() if argumente: print(f"unbekannte Argumente {argumente}; erlaubt ist nur --mutanten") return 2 abweichungen = 0 for funktion in FAELLE: try: funktion() except (Exception, SystemExit) as ausnahme: abweichungen += 1 print(f"ABWEICHUNG {funktion.__name__}: {type(ausnahme).__name__}: {ausnahme}") else: print(f"OK {funktion.__name__}") print(f"{len(FAELLE)} Faelle, {abweichungen} Abweichung(en) ({datetime.now():%Y-%m-%d %H:%M})") return 2 if abweichungen else 0 if __name__ == "__main__": sys.exit(main())