From c5db92c6de869ff8b7cdb034238557816a371a98 Mon Sep 17 00:00:00 2001 From: Pascal Michel Date: Sun, 11 Oct 2026 16:58:56 +0200 Subject: [PATCH] Trading-Plattform-Pruefung: Abendkontrolle holt die Sicherung des Docker-Hosts (Paket E9, Task 725) abendkontrolle.py holt je Teil mit Quelle dockerhost die Sicherungen der letzten zehn Kalendertage, prueft die Summe gegen die des Hosts, legt unter D:\Trading-Sicherung ab, bestaetigt beim Host und schreibt zuletzt die Summendatei. Zeitbudget 1500 s ab dem Start des Laufs. abendkontrolle_einrichten.ps1 setzt den Abend-Ausloeser in Ortszeit ohne UTC-Versatz (S10). Treiber: 32 Faelle, 42 Mutanten. Gegengeprueft: byte-gleich mit der Plankopie, Kette mit echten Archiven ueber das echte abendkontrolle-lesen.sh gruen. SICHERUNG_ERSTER_TAG bleibt None bis zum Einspielen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ --- .../abendkontrolle.py | 282 +++++- .../abendkontrolle_dockerhost_angriffe.py | 894 +++++++++++++++++- .../abendkontrolle_einrichten.ps1 | 5 + 3 files changed, 1174 insertions(+), 7 deletions(-) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py index f166d33..beebdc0 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py @@ -29,13 +29,21 @@ Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millise Annahme, benannt: Die Uhr dieses Rechners geht nach Berliner Zeit (tzutil: W. Europe Standard Time, gelesen am 2026-09-24); ohne das Paket tzdata kennt Python unter Windows keine Zeitzonen-Datenbank. +Seit Paket E9 holt die Aufgabe fuer jeden Teil mit Quelle dockerhost auch die Sicherung des Hosts ab +(hole_sicherungen): je Kalendertag der letzten SICHERUNG_NACHHOLEN_TAGE die Summe des Hosts fragen; wo +die Summendatei auf D: fehlt oder abweicht, -.tar ueber denselben Schluessel holen, Summe +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). Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl ] - [--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] + [--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] [--sicherung-ziel ] """ import argparse +import hashlib import json import os +import re import subprocess import sys import traceback @@ -44,6 +52,7 @@ from datetime import datetime from datetime import time from datetime import timedelta from pathlib import Path +from time import monotonic VAULT = Path(r"D:\Claude") PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform" @@ -82,6 +91,34 @@ KOEPFE = { } +# Seit Paket E9 (Trading Plattform 0.39.0): Der Host sichert werktags 16:45 New Yorker Zeit je +# Instanz Register, data/paper und artifacts/paper (sicherung-host.sh). Diese Aufgabe holt fuer +# jeden Teil mit Quelle dockerhost die Sicherung ueber denselben Schluessel ab (abendkontrolle-lesen.sh +# mit sicherung-summe, sicherung und sicherung-bestaetigt), prueft die Summe und legt sie unter +# SICHERUNG_ZIEL//.tar samt .tar.sha256 ab. Massstab ist die aktuelle Summe des +# Hosts: Fehlt die Summendatei auf D: oder weicht sie ab, holt der Lauf neu und ersetzt die alte Fassung +# (Plankritik Runde 1, Befunde 5 und 24). Sonst loescht er auf D: nichts. +SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung") +# Der erste Tag, den die Aufgabe abholt; None holt nichts. Gesetzt beim Einspielen von E9 (S8): Diese +# Datei laeuft schon vorher jeden Abend, der Host kennt die Lesebefehle erst danach. +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 +# 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 +# Je Archiv; Summe und Bestaetigung nehmen SSH_TIMEOUT_S. Die Aufgabe selbst endet nach 45 Minuten. +SICHERUNG_TIMEOUT_S = 600 +# Zeitbudget je Lauf fuer die Sicherung, geprueft vor jeder Instanz: Ist es um, faengt der Lauf nichts +# mehr an, und der naechste holt den Rest (Plankritik Runde 1, Befund 12). +SICHERUNG_BUDGET_S = 1500 +SICHERUNG_KOPF = "Sicherung Trading Plattform (automatisch)" +# Eine Zeile im Format von sha256sum: 64 Hexzeichen in Kleinbuchstaben, zwei Leerzeichen, Name, LF. +SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n") +TAGFORM = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}") + + 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): @@ -155,6 +192,227 @@ def fahre_dockerhost(tag: str, teil: str = "alpaca") -> dict: raise RuntimeError(f"keine JSON-Zeile vom Docker-Host (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})") +def ssh_aufruf(befehl: str) -> list[str]: + """Die Befehlszeile fuer einen Befehl der Sicherung, mit denselben Optionen wie fahre_dockerhost.""" + return [ + SSH, + "-p", SSH_PORT, + "-i", str(SSH_SCHLUESSEL), + "-o", "BatchMode=yes", + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", f"UserKnownHostsFile={SSH_BEKANNTE}", + "-o", "ConnectTimeout=20", + SSH_ZIEL, + befehl, + ] + + +def fehlerzeile(lauf: subprocess.CompletedProcess) -> str: + """Exit und die letzten Zeilen von stderr, gekuerzt.""" + fehler = (lauf.stderr or b"").decode("utf-8", errors="replace").strip().splitlines()[-3:] + return f"Exit {lauf.returncode}; {' | '.join(fehler)[:300]}" + + +def summe_von(datei: Path) -> str: + """SHA-256 einer Datei in Kleinbuchstaben, stueckweise gelesen.""" + summe = hashlib.sha256() + with datei.open("rb") as strom: + for stueck in iter(lambda: strom.read(1 << 20), b""): + summe.update(stueck) + return summe.hexdigest() + + +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. + """ + 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, + ) + + +def summe_auf_d(ordner: Path, instanz: str) -> str | None: + """Die Summe aus .tar.sha256 in ordner, wenn sie dort richtig steht und das Archiv daneben liegt. + + Die Summendatei schreibt nur hole_sicherung, nach Pruefung und Bestaetigung: Sie steht fuer ein + geprueftes und bestaetigtes Archiv. Das Archiv selbst hasht dieser Lauf nicht nach (Plankritik + Runde 1, Befund 21); sonst laese jeder Lauf alle Archive des Fensters. + """ + archiv = ordner / f"{instanz}.tar" + try: + treffer = SUMMENZEILE.fullmatch((ordner / f"{instanz}.tar.sha256").read_bytes().decode("ascii")) + except (OSError, UnicodeDecodeError): + return None + if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file(): + return None + return treffer.group(1) + + +def sicherungstage(jetzt: datetime, tag: str | None = None) -> list[str]: + """Die Tage, fuer die der Lauf den Host fragt, der aelteste zuerst. + + Ohne SICHERUNG_ERSTER_TAG keiner. Mit tag (--tag) nur dieser. Sonst jeder Kalendertag der letzten + SICHERUNG_NACHHOLEN_TAGE bis heute ab SICHERUNG_ERSTER_TAG, auch Samstag und Sonntag (ein + nachgeholter Lauf des Hosts traegt das Datum des Nachholens) und auch heute (Plankritik Runde 1, + Befunde 8, 16, 18 und 19). Ob das Ergebnis der Abendkontrolle fuer den Tag schon liegt, spielt + keine Rolle. + """ + erster = SICHERUNG_ERSTER_TAG + 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) + return [tag] + tage = [] + for zurueck in range(SICHERUNG_NACHHOLEN_TAGE, -1, -1): + kandidat = jetzt.date() - timedelta(days=zurueck) + if kandidat >= erster: + tage.append(kandidat.isoformat()) + return tage + + +def summe_vom_host(name: str) -> str | None: + """Die Summe, die der Host fuer fuehrt; None bei Exit 3 (dort liegt keine Sicherung).""" + lauf = ssh_lauf(f"sicherung-summe {name}", SSH_TIMEOUT_S) + if lauf.returncode == 3: + return None + if lauf.returncode != 0: + raise RuntimeError(f"Summe nicht gelesen ({fehlerzeile(lauf)})") + treffer = SUMMENZEILE.fullmatch(lauf.stdout.decode("ascii", errors="replace")) + if treffer is None or treffer.group(2) != f"{name}.tar": + raise RuntimeError("Summendatei des Hosts in unerwarteter Form") + return treffer.group(1) + + +def ohne_sicherung_still(tag: str, jetzt: datetime) -> bool: + """Ist Exit 3 fuer diesen Tag kein Fehlschlag? Ja fuer heute (und spaeter) und fuer Samstag und Sonntag. + + Heute kann die Sicherung des Hosts noch ausstehen; am Wochenende sichert er nur, wenn er einen + verpassten Lauf nachholt. Fehlt sie an einem vergangenen Werktag, ist das ein Fehlschlag. + """ + kandidat = date.fromisoformat(tag) + return kandidat >= jetzt.date() or kandidat.weekday() >= 5 + + +def sichere(tag: str, instanz: str, ziel: Path, jetzt: datetime) -> tuple[str, bool]: + """Eine Instanz eines Tags. Liefert ihren Teil der Zeile und ob geholt wurde; Fehlschlag: Ausnahme. + + Massstab ist die aktuelle Summe des Hosts (Plankritik Runde 1, Befunde 5 und 24). Steht dieselbe in + der Summendatei auf D:, ist der Tag geholt und bestaetigt. Sonst holt der Lauf neu und ersetzt die + alte Fassung, etwa nachdem der Host den Tag ein zweites Mal gesichert hat. + """ + name = f"{tag}-{instanz}" + summe = summe_vom_host(name) + if summe is None: + if ohne_sicherung_still(tag, jetzt): + return f"{instanz} auf dem Docker-Host noch nicht gesichert", False + raise RuntimeError(f"auf dem Docker-Host liegt fuer {name} noch keine Sicherung") + if summe_auf_d(ziel / tag, instanz) == summe: + return f"{instanz} lag schon vollständig", False + return hole_sicherung(tag, instanz, ziel, summe), True + + +def hole_sicherung(tag: str, instanz: str, ziel: Path, summe: str) -> str: + """Holt die Sicherung - mit der Summe des Hosts, prueft, legt ab und bestaetigt. + + Reihenfolge: Archiv in die Nachbardatei .tar.teil holen, Summe pruefen, eine alte + .tar.sha256 entfernen, auf .tar umbenennen (ersetzt eine alte Fassung), beim + Host bestaetigen, zuletzt .tar.sha256 schreiben. Erst die Summendatei macht die Ablage + vollstaendig: Scheitert die Bestaetigung, fehlt sie, und der naechste Lauf holt und bestaetigt + erneut. Bei falscher Summe wird nichts umbenannt und nichts bestaetigt. Jeder Fehlschlag ist + eine Ausnahme mit dem Grund. Liefert den Teil der Zeile. + """ + name = f"{tag}-{instanz}" + ordner = ziel / tag + ordner.mkdir(parents=True, exist_ok=True) + teil = ordner / f"{instanz}.tar.teil" + archiv = ordner / f"{instanz}.tar" + summendatei = ordner / f"{instanz}.tar.sha256" + try: + with teil.open("wb") as strom: + lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom) + if lauf.returncode != 0: + raise RuntimeError(f"Archiv nicht geholt ({fehlerzeile(lauf)})") + if summe_von(teil) != summe: + raise RuntimeError("Summe des geholten Archivs ist nicht die des Hosts; nicht abgelegt, nicht bestaetigt") + summendatei.unlink(missing_ok=True) + os.replace(teil, archiv) + finally: + try: + teil.unlink(missing_ok=True) + except OSError: + pass + lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S) + if lauf.returncode != 0: + raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})") + neu = ordner / f"{instanz}.tar.sha256.neu" + neu.write_bytes(f"{summe} {archiv.name}\n".encode("ascii")) + os.replace(neu, summendatei) + return f"{instanz} abgelegt und bestätigt ({archiv.stat().st_size} Bytes)" + + +def hole_sicherungen(jetzt: datetime, vault: Path, ablage: Path, ziel: Path, instanzen: list[str], + frist: float, tag: str | None = None) -> int: + """Je Tag des Fensters und Instanz: Summe des Hosts fragen und holen, wo D: sie nicht hat. + + Eine Zeile in der Notiz je Tag, an dem etwas geholt wurde, etwas scheiterte oder das Zeitbudget + endete; ein Fehlschlag traegt #needs-review. Ein Tag ohne Neues bekommt keine Zeile. frist ist der + Stand von monotonic(), an dem SICHERUNG_BUDGET_S ab dem Start des Laufs um ist (main); danach + faengt der Lauf keine Instanz mehr an, und der naechste holt den Rest. + """ + if not instanzen: + return 0 + code = 0 + erschoepft = False + for kandidat in sicherungstage(jetzt, tag): + if erschoepft: + break + uhrzeit = jetzt.strftime("%H:%M") if kandidat == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}" + stuecke = [] + gescheitert = False + getan = False + for instanz in instanzen: + if monotonic() >= frist: + erschoepft = getan = True + stuecke.append( + f"{instanz} nicht angefangen: Zeitbudget des Laufs ({SICHERUNG_BUDGET_S} s) erschöpft, " + "der nächste Lauf holt den Rest" + ) + logge(ablage, f"sicherung {kandidat}-{instanz}: Zeitbudget erschoepft") + continue + try: + stueck, geholt = sichere(kandidat, instanz, ziel, jetzt) + stuecke.append(stueck) + if geholt: + getan = True + logge(ablage, f"sicherung {kandidat}-{instanz}: abgelegt und bestaetigt") + except Exception as ausnahme: + gescheitert = True + stuecke.append(f"{instanz} gescheitert ({type(ausnahme).__name__}: {str(ausnahme)[:200]})") + logge(ablage, f"sicherung {kandidat}-{instanz}: gescheitert {ausnahme!r}") + if not (gescheitert or getan): + continue + kopf = f"- {uhrzeit} Uhr — {SICHERUNG_KOPF}" + if gescheitert: + code = 1 + zeile = f"{kopf}: **gescheitert** #needs-review. {'; '.join(stuecke)}. Der nächste Lauf versucht es erneut." + else: + zeile = f"{kopf}: {'; '.join(stuecke)}." + try: + schreibe_notiz(vault, kandidat, zeile) + except Exception as ausnahme: + code = 1 + logge(ablage, f"sicherung {kandidat}: Notiz nicht geschrieben {ausnahme!r}") + return code + + def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str: kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)" befunde = ergebnis.get("befunde") or [] @@ -284,7 +542,14 @@ def main(argv=None) -> int: "--quelle-ibkr", choices=QUELLEN, help="die Quelle fuer den Teil ibkr; ohne Angabe dieselbe wie --quelle", ) + zerleger.add_argument( + "--sicherung-ziel", + help="Ordner fuer die Sicherungen statt SICHERUNG_ZIEL (Angriffsfaelle)", + ) argumente = zerleger.parse_args(argv) + # 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 vault = Path(argumente.vault) ablage = vault / ABLAGE_TEIL jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now() @@ -313,6 +578,21 @@ def main(argv=None) -> int: logge(ablage, f"{vorsatz}abgebrochen: {type(ausnahme).__name__}") except Exception: pass + # Seit Paket E9: die Sicherung jedes Teils mit Quelle dockerhost, nach den Ergebnissen beider + # Teile und unabhaengig davon, ob ein Ergebnis gescheitert ist. + instanzen = [ + teil for teil in teile + if ((argumente.quelle_ibkr or argumente.quelle) if teil == "ibkr" else argumente.quelle) == "dockerhost" + ] + ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL + try: + code = max(code, hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, frist, argumente.tag)) + except Exception as ausnahme: + code = 1 + try: + logge(ablage, f"sicherung abgebrochen: {type(ausnahme).__name__}: {ausnahme}") + except Exception: + pass return code diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py index f421962..74addae 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py @@ -9,21 +9,36 @@ 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. -Aufruf: python abendkontrolle_dockerhost_angriffe.py -Exit 0 ohne Abweichung, 2 mit Abweichung. +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 @@ -32,6 +47,71 @@ 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") @@ -55,7 +135,8 @@ def fall(funktion): def lade(): - spec = importlib.util.spec_from_file_location("abendkontrolle_unter_angriff", HIER / "abendkontrolle.py") + # 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 @@ -80,6 +161,13 @@ def lauf(art, *argumente, modul=None): # 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): @@ -233,8 +321,12 @@ def eine_unbekannte_quelle_ist_ein_bedienfehler(): @fall def die_heutige_frist_und_die_aufgabe_passen_zueinander(): - quelle = (HIER / "abendkontrolle.py").read_text(encoding="utf-8") - aufgabe = (HIER / "abendkontrolle_einrichten.ps1").read_text(encoding="utf-8") + # 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 @@ -273,7 +365,797 @@ def der_echte_wsl_aufruf_findet_kein_wsl(): raise AssertionError("fahre_wsl lief ohne wsl.exe weiter") -def main() -> int: +# --- 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: diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 b/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 index 7053169..d84c968 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 @@ -41,6 +41,11 @@ if (-not $QuelleIbkr) { $QuelleIbkr = $Quelle } $Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '" --quelle ' + $Quelle + ' --quelle-ibkr ' + $QuelleIbkr) -WorkingDirectory "D:\Claude" if ($Quelle -eq "dockerhost" -or $QuelleIbkr -eq "dockerhost") { $Uhrzeit = "23:15" } else { $Uhrzeit = "22:45" } $Abends = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday, Tuesday, Wednesday, Thursday, Friday -At $Uhrzeit +# Seit Paket E9 (S10): -At legt den Startzeitpunkt mit UTC-Versatz an (gemessen 2026-10-11: +# ...T21:15:00Z). Eine Aufgabe mit Versatz startet im Winter eine Stunde frueher nach Ortszeit, vor +# der Abendkontrolle des Hosts. Ohne Versatz gesetzt, speichert die Aufgabenplanung den Wert so und +# startet nach Ortszeit. +$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00' $Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer $Anmeldung.Delay = "PT5M" $Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited