Files
claude-regelwerk/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py
T
Pascal MichelandClaude Opus 5.5 3a38cb7e83 Trading-Plattform-Pruefung: Nachtrag nach der kritischen Pruefung (Paket NR, Task 746) und LIESMICH (Task 745)
abendkontrolle.py: Die Unterzeile zu einer nicht geschriebenen Ergebnisdatei sagt nur
noch, wann ein spaeterer Lauf ohne --tag den Tag erneut prueft (Werktag ab dem ersten
Prueftag des Teils, hoechstens NACHHOLEN_TAGE zurueck, unter dem Namen der Datei nichts);
der Docstring nennt den Fall ohne lauf.log. Treiber zieht den Wortlaut nach (46 Faelle,
70 Mutanten, keiner ueberlebt; Windows-Teil ohne Abweichung). LIESMICH: Abschnitt
Paket NR, Grenze im Abschnitt NT ersetzt. Vault-Teil von 746 aus der Plankopie
uebernommen, byte-gleich (Beginn 23:44:58, die Windows-Aufgabe war deaktiviert).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-12 23:48:53 +02:00

1812 lines
97 KiB
Python

"""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.
Seit Paket NT (Quorum 4): ANGRIFF_HOST_WEG=<n> laesst die Attrappe jeden Aufruf nach den ersten n mit
Exit 255 enden wie ssh ohne Host; ANGRIFF_SICHERUNG=langsam haelt jeden Sicherungsbefehl 5 s auf,
ANGRIFF_SICHERUNG=archiv_unlesbar endet beim Archiv wie abendkontrolle-lesen.sh ohne Leserecht.
Seit Paket NR (kritische Pruefung NT, Befund 3): lauf_log_sperren legt an der Stelle von lauf.log einen
Ordner an, und vier Faelle zeigen, dass Ergebnis, Notizzeilen und Sicherung nicht daran haengen. Seit der
Plankritik NR drei weitere: ein Fehler in logge, der kein OSError ist, eine Ablage, die sich nicht anlegen
laesst, und eine Ergebnisdatei, die sich nicht schreiben laesst.
Aufruf: python abendkontrolle_dockerhost_angriffe.py [--mutanten]
Exit 0 ohne Abweichung, 2 mit Abweichung. --mutanten faehrt MUTANTEN auf Kopien von
abendkontrolle.py und MUTANTEN_AUFGABE auf Kopien von abendkontrolle_einrichten.ps1: Jeder genannte
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")
# Seit Paket NT: Nach ANGRIFF_HOST_WEG Aufrufen ist der Host weg; ssh selbst endet dann mit 255.
weg = os.environ.get("ANGRIFF_HOST_WEG", "")
if weg:
with open(os.environ["ANGRIFF_PROTOKOLL"], encoding="utf-8") as datei:
if len(datei.read().splitlines()) > int(weg):
sys.stderr.write("ssh: connect to host docker-host port 2498: Connection timed out\n")
sys.exit(255)
befehl = sys.argv[-1]
if befehl.startswith("sicherung"):
# Seit Paket E9: die Lesebefehle der Sicherung, so streng wie abendkontrolle-lesen.sh (Task 724),
# gegen die Ablage unter ANGRIFF_HOST. ANGRIFF_SICHERUNG ist <stoerung> oder <stoerung>:<instanz>.
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 <JJJJ-MM-TT>-<alpaca|ibkr> "
"oder sicherung-bestaetigt <JJJJ-MM-TT>-<alpaca|ibkr> <sha256>\n"
)
sys.exit(2)
name = lesen.group(2) if lesen else bestaetigen.group(1)
gestoert = stoerung if not nur or name.endswith("-" + nur) else ""
if gestoert == "langsam":
# Seit Paket NT: laenger als das Zeitlimit, das der Fall in seiner Kopie setzt.
import time
time.sleep(5)
archiv = os.path.join(host, name + ".tar")
summendatei = archiv + ".sha256"
gemeint = archiv if lesen and lesen.group(1) == "sicherung" else summendatei
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 == "archiv_unlesbar":
# Seit Paket NT: wie abendkontrolle-lesen.sh bei einem Archiv ohne Leserecht (Exit 1).
sys.stderr.write("abendkontrolle-lesen: " + name + ".tar liegt auf dem Docker-Host, laesst sich aber nicht lesen\n")
sys.exit(1)
if gestoert == "kaputt":
inhalt = inhalt[:-1] + bytes([inhalt[-1] ^ 1])
if gestoert == "abbruch":
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)
os.environ.pop("ANGRIFF_HOST_WEG", 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:] == ["[email protected]", 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 <tag>-<instanz> 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, host_weg=""):
"""Ein Lauf von main() mit Quelle dockerhost und Sicherung, alles unter wurzel.
Vault, Host-Ablage (host_ablage) und Ziel bleiben stehen, damit ein zweiter Lauf daran
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
# Seit Paket NT: "" heisst, der Host bleibt erreichbar.
os.environ["ANGRIFF_HOST_WEG"] = host_weg
vorne = ["--vault", str(vault), "--quelle", "dockerhost"]
if schalter:
vorne += ["--sicherung-ziel", str(wurzel / "ziel")]
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",
"[email protected]",
], 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():
# Seit Paket NT (Plankritik, Befund 1): Exit 255 mitten im Archiv heisst nicht, dass der Host fehlt;
# er hat die Summe gerade beantwortet. Nur die kaputte Instanz scheitert, mit dem Befehl im Grund,
# die andere wird geholt. Keine Nachbardatei bleibt, die kaputte Instanz ist nicht bestaetigt.
for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
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 (sicherung {TAG}-{kaputt}: Exit 255; "
"Connection reset by peer))"
) in zeile, zeile
assert f"{heil} abgelegt und bestätigt" in zeile, zeile
assert "**gescheitert** #needs-review" in zeile, zeile
assert "Docker-Host nicht erreichbar" not in zeile, zeile
@fall
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_endet_mit_zwei_ohne_notiz_und_ohne_ssh():
# Seit Paket NT (Quorum 4): main prueft --tag gleich nach dem Zerlegen. Vorher lief die
# Abendkontrolle mit dem falschen Tag los und legte Inbox/<tag>.md an, mit ../ auch ausserhalb von
# Inbox/; seit E9 hielt nur die Sicherung an (Exit 1). Jetzt endet der ganze Lauf vorher mit Exit 2.
for tag in ("2026-10-5", "2026-13-05", "2026-02-30", "20261005", "x026-10-05", "2026-10-0x", "../2026-10-05", ""):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel)
fehler = io.StringIO()
with contextlib.redirect_stderr(fehler):
code, notiz, _, _ = sicherungslauf(wurzel, "--tag", tag, "--jetzt", "2026-10-05T23:30")
assert code == 2, (tag, code)
assert fehler.getvalue() == f"abendkontrolle: --tag {tag!a} ist kein Datum JJJJ-MM-TT\n", (tag, fehler.getvalue())
assert namen(wurzel / "vault") == ["Inbox"], (tag, namen(wurzel / "vault"))
assert namen(wurzel / "vault" / "Inbox") == [], (tag, notiz)
assert not (wurzel / "ssh.jsonl").exists(), tag
assert not (wurzel / "ziel").exists(), tag
@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)
# --- Seit Paket NT (Quorum 4): Host nicht erreichbar, --tag frueh, unlesbare Dateien --------------
HOST_WEG = "ssh: connect to host docker-host port 2498: Connection timed out"
def ergebnisse_liegen(modul, wurzel):
"""Die Ergebnisse beider Teile liegen fuer 2026-09-20 bis 2026-10-12: Der Lauf faehrt nur die Sicherung."""
ablage = wurzel / "vault" / modul.ABLAGE_TEIL
ablage.mkdir(parents=True, exist_ok=True)
for tag in [f"2026-09-{t}" for t in range(20, 31)] + [f"2026-10-{t:02d}" for t in range(1, 13)]:
(ablage / f"{tag}.json").write_text("{}", encoding="utf-8")
(ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8")
return ablage
@fall
def ein_unlesbares_archiv_auf_dem_host_scheitert_nur_fuer_seine_instanz():
# Paket NT (Quorum 4, G2): abendkontrolle-lesen.sh endet bei einem Archiv ohne Leserecht mit Exit 1
# und eigener Zeile. Das ist kein Ausfall des Hosts: nur diese Instanz scheitert, die andere wird
# geholt. Seit ssh_lauf Exit 255 als HostNichtErreichbar meldet, erreicht ein Abbruch mitten im
# Archiv die Pruefung des Exit-Codes nicht mehr; den Mutanten "Exit des Archivs egal" traegt
# deshalb dieser Fall (vorher eine_abgebrochene_uebertragung_hinterlaesst_nichts).
for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
code, notiz, _, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=f"archiv_unlesbar:{kaputt}"
)
zeile = einzige_zeile(notiz)
assert code == 1, code
assert namen(wurzel / "ziel" / TAG) == [f"{heil}.tar", f"{heil}.tar.sha256"], namen(wurzel / "ziel" / TAG)
assert namen(host / "bestaetigt") == [f"{TAG}-{heil}"], namen(host / "bestaetigt")
assert (
f"{kaputt} gescheitert (RuntimeError: Archiv nicht geholt (Exit 1; abendkontrolle-lesen: "
f"{TAG}-{kaputt}.tar liegt auf dem Docker-Host, laesst sich aber nicht lesen)"
) in zeile, zeile
assert f"{heil} abgelegt und bestätigt" in zeile, zeile
assert "Docker-Host nicht erreichbar" not in zeile, zeile
@fall
def ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz():
# Paket NT (Quorum 4, G1): Vorher scheiterte bei einem Host ohne Verbindung jeder Tag des Fensters
# mit eigener Zeile, und schreibe_notiz legte die fehlenden Notizen an, auch fuer Samstag und
# Sonntag. Jetzt endet die Sicherung beim ersten Exit 255 mit genau einer Zeile von heute.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
modul = lade()
ablage = ergebnisse_liegen(modul, wurzel)
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-12T23:30", modul=modul, erster_tag=date(2026, 9, 1), host_weg="0"
)
assert code == 1, (code, notiz)
assert namen(wurzel / "vault" / "Inbox") == ["2026-10-12.md"], namen(wurzel / "vault" / "Inbox")
assert einzige_zeile(notiz) == (
f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar "
f"(sicherung-summe 2026-10-02-alpaca: Exit 255; {HOST_WEG}), Sicherung abgebrochen. "
"Der nächste Lauf versucht es erneut."
), notiz
assert befehle == ["sicherung-summe 2026-10-02-alpaca"], befehle
assert not (wurzel / "ziel").exists()
log = (ablage / "lauf.log").read_text(encoding="utf-8")
assert "sicherung 2026-10-02-alpaca: Docker-Host nicht erreichbar (" in log, log
@fall
def ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen():
# Paket NT (Quorum 4, G1): Fertige Tage behalten ihre Zeilen in ihren Notizen; was ein angefangener
# Tag schon hat, nennt die eine Zeile von heute. Je Tag mit Sicherung sechs Befehle (Summe, Archiv,
# Bestaetigung je Instanz); nach weg Befehlen ist der Host weg. Am Samstag 2026-10-03 liegt keine
# Sicherung, gefragt wird trotzdem.
for weg, notizen, zuletzt, bis_dahin in (
("12", ["2026-10-01.md", "2026-10-02.md", "2026-10-05.md"], "sicherung-summe 2026-10-03-alpaca", ""),
("9", ["2026-10-01.md", "2026-10-05.md"], "sicherung-summe 2026-10-02-ibkr",
" Bis dahin am 2026-10-02: alpaca abgelegt und bestätigt ("),
):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel, tage=("2026-10-01", "2026-10-02", "2026-10-05"))
modul = lade()
ergebnisse_liegen(modul, wurzel)
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=date(2026, 10, 1), host_weg=weg
)
zeilen = sicherungszeilen(notiz)
assert code == 1, (weg, code)
assert namen(wurzel / "vault" / "Inbox") == notizen, (weg, namen(wurzel / "vault" / "Inbox"))
assert befehle[-1] == zuletzt and len(befehle) == int(weg) + 1, (weg, befehle)
assert namen(wurzel / "ziel") == ["2026-10-01", "2026-10-02"], (weg, namen(wurzel / "ziel"))
assert len(zeilen) == len(notizen), (weg, zeilen)
for zeile in zeilen[:-1]:
assert "alpaca abgelegt und bestätigt" in zeile and "ibkr abgelegt und bestätigt" in zeile, zeile
assert zeilen[-1].startswith(
f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar "
f"({zuletzt}: Exit 255; {HOST_WEG}), Sicherung abgebrochen."
), zeilen[-1]
if bis_dahin:
assert bis_dahin in zeilen[-1], zeilen[-1]
else:
assert "Bis dahin" not in zeilen[-1], zeilen[-1]
assert zeilen[-1].endswith(" Der nächste Lauf versucht es erneut."), zeilen[-1]
@fall
def ein_zeitlimit_beim_host_zaehlt_wie_ein_unerreichbarer_host():
# Paket NT (Quorum 4, G1): Laeuft die Frage nach der Summe in ihr Zeitlimit (SSH_TIMEOUT_S), gilt der
# Host als nicht erreichbar. In der Kopie ist das Zeitlimit 1 s, die Attrappe braucht je
# Sicherungsbefehl 5 s. Das Zeitlimit beim Archiv selbst: naechster Fall.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel)
modul = lade()
ergebnisse_liegen(modul, wurzel)
modul.SSH_TIMEOUT_S = 1
code, notiz, befehle, _ = sicherungslauf(wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, stoerung="langsam")
assert code == 1, (code, notiz)
assert namen(wurzel / "vault" / "Inbox") == ["2026-10-05.md"], namen(wurzel / "vault" / "Inbox")
assert befehle == ["sicherung-summe 2026-10-05-alpaca"], befehle
assert einzige_zeile(notiz) == (
f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar "
"(sicherung-summe 2026-10-05-alpaca: Zeitlimit 1 s abgelaufen), Sicherung abgebrochen. "
"Der nächste Lauf versucht es erneut."
), notiz
assert not (wurzel / "ziel").exists()
@fall
def ein_zeitlimit_beim_archiv_laesst_nur_diese_instanz_scheitern():
# Plankritik NT, Befund 1: Laeuft nur das Archiv in sein Zeitlimit (SICHERUNG_TIMEOUT_S), ist der Host
# erreichbar; er hat die Summe gerade beantwortet. Die Instanz scheitert mit eigenem Grund, die
# andere wird geholt, der naechste Tag auch. In der Kopie ist das Zeitlimit des Archivs 1 s, die
# Attrappe braucht fuer jeden Befehl von alpaca 5 s; die Summe hat weiter SSH_TIMEOUT_S.
tage = ("2026-10-01", "2026-10-02")
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel, tage=tage)
modul = lade()
ergebnisse_liegen(modul, wurzel)
modul.SICHERUNG_TIMEOUT_S = 1
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-02T23:30", modul=modul, erster_tag=date(2026, 10, 1), stoerung="langsam:alpaca"
)
zeilen = sicherungszeilen(notiz)
assert code == 1, (code, notiz)
assert namen(wurzel / "vault" / "Inbox") == ["2026-10-01.md", "2026-10-02.md"], namen(wurzel / "vault" / "Inbox")
assert namen(host / "bestaetigt") == [f"{t}-ibkr" for t in tage], namen(host / "bestaetigt")
assert [b for b in befehle if b.startswith("sicherung ")] == [
f"sicherung {t}-{i}" for t in tage for i in ("alpaca", "ibkr")
], befehle
assert len(zeilen) == 2, zeilen
for tag, zeile in zip(tage, zeilen):
assert namen(wurzel / "ziel" / tag) == ["ibkr.tar", "ibkr.tar.sha256"], (tag, namen(wurzel / "ziel" / tag))
assert (
"**gescheitert** #needs-review. alpaca gescheitert (RuntimeError: Archiv nicht geholt "
f"(sicherung {tag}-alpaca: Zeitlimit 1 s abgelaufen)); ibkr abgelegt und bestätigt ("
) in zeile, zeile
assert "Docker-Host nicht erreichbar" not in zeile, zeile
@fall
def eine_bestaetigung_ohne_host_bricht_die_sicherung_ab():
# Kritische Pruefung NT, Befund 1: Auch die Bestaetigung gehoert zur Grenze "Docker-Host nicht
# erreichbar" (Entscheidung 1 in Task 729), nicht nur die Summe. Nach Summe und Archiv von alpaca
# ist der Host weg, die Bestaetigung endet mit 255. Das Archiv liegt dann ohne Summendatei auf D:,
# nichts ist bestaetigt, und die Sicherung endet mit genau einer Zeile von heute.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
modul = lade()
ergebnisse_liegen(modul, wurzel)
code, notiz, befehle, _ = sicherungslauf(wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, host_weg="2")
summe = host_summe(host, "alpaca")
assert code == 1, (code, notiz)
assert befehle == [
f"sicherung-summe {TAG}-alpaca", f"sicherung {TAG}-alpaca", f"sicherung-bestaetigt {TAG}-alpaca {summe}"
], befehle
assert einzige_zeile(notiz) == (
f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar "
f"(sicherung-bestaetigt {TAG}-alpaca {summe}: Exit 255; {HOST_WEG}), Sicherung abgebrochen. "
"Der nächste Lauf versucht es erneut."
), notiz
assert namen(wurzel / "ziel" / TAG) == ["alpaca.tar"], namen(wurzel / "ziel" / TAG)
assert namen(host / "bestaetigt") == [], namen(host / "bestaetigt")
@fall
def kein_kommentar_beginnt_mit_s3():
# Paket NT (Quorum 4): Die Kommentarzeile ueber SICHERUNG_NACHHOLEN_TAGE begann mit "(S3).", einem
# Verweis ohne Ziel. Erst die neue Zeile zusichern, dann die alte ausschliessen.
zeilen = QUELLE.read_text(encoding="utf-8").splitlines()
neu = "# Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026"
assert zeilen.count(neu) == 1, f"neue Kommentarzeile {zeilen.count(neu)}-mal"
assert [z for z in zeilen if z.startswith("# (S3)")] == [], "Kommentar beginnt mit (S3)."
# --- Seit Paket NR (kritische Pruefung NT, Befund 3): lauf.log nicht schreibbar ---------------------
LOG_KOPF = "Abendkontrolle lauf.log (automatisch)"
def lauf_log_sperren(modul, wurzel):
"""Legt an der Stelle von lauf.log einen Ordner an; liefert die Ablage und den Typ der Ausnahme.
Oeffnen zum Anhaengen scheitert dann wie bei einer Sperre oder fehlenden Rechten, und die Ablage
daneben bleibt schreibbar. Den Typ misst der Fall hier selbst, statt ihn je Betriebssystem zu raten.
"""
ablage = wurzel / "vault" / modul.ABLAGE_TEIL
gesperrt = ablage / "lauf.log"
gesperrt.mkdir(parents=True)
try:
with gesperrt.open("a", encoding="utf-8"):
pass
except OSError as fehler:
return ablage, type(fehler).__name__
raise AssertionError("lauf.log als Ordner liess sich zum Anhaengen oeffnen")
def log_zeile(uhrzeit, typ):
"""Die eine Zeile, die ein Lauf mit nicht schreibbarem lauf.log in die Notiz von heute schreibt."""
return (
f"- {uhrzeit} Uhr — {LOG_KOPF}: **nicht schreibbar** ({typ}) #needs-review. Mindestens ein Eintrag "
"dieses Laufs fehlt dort; Ergebnisdateien, Notizzeilen und Sicherung hängen nicht davon ab."
)
def log_zeilen(notiz):
"""Die Zeilen der Notizen, die lauf.log nennen."""
return [zeile for zeile in notiz.splitlines() if LOG_KOPF in zeile]
@fall
def ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf():
# Kritische Pruefung NT, Befund 3: Vorher warf logge schon beim Start jedes Teils, beide Teile fielen
# aus, die Sicherung brach nach dem ersten Archiv ab, und in der Notiz stand nichts; uebrig blieb
# Exit 1. Jetzt laufen Ergebnis, Zeilen und Sicherung beider Teile (erster und letzter Teil), und
# genau eine Zeile von heute nennt lauf.log, mit Exit 1. Ein zweiter Lauf mit demselben Modul und
# schreibbarem lauf.log endet mit 0 und ohne neue Zeile: Der Fehler gilt je Lauf.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
modul = lade()
ablage, typ = lauf_log_sperren(modul, wurzel)
code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul)
ergebnisse = sorted(p.name for p in ablage.glob("*.json"))
assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen"
assert "Abendkontrolle Trading Plattform (automatisch): ohne Befund" in notiz, notiz
assert "Abendkontrolle IBKR (automatisch): ohne Befund" in notiz, notiz
ziel = wurzel / "ziel" / TAG
assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel)
assert namen(host / "bestaetigt") == [f"{TAG}-alpaca", f"{TAG}-ibkr"], namen(host / "bestaetigt")
assert "**gescheitert**" not in einzige_zeile(notiz), notiz
assert log_zeilen(notiz) == [log_zeile("23:30", typ)], notiz
assert code == 1, code
assert namen(ablage / "lauf.log") == [], namen(ablage / "lauf.log")
# Zweiter Lauf mit demselben Modul; lauf.log ist wieder schreibbar.
(ablage / "lauf.log").rmdir()
code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45", modul=modul)
assert code == 0, (code, notiz)
assert len(log_zeilen(notiz)) == 1, notiz
assert (ablage / "lauf.log").is_file(), "lauf.log nach dem zweiten Lauf keine Datei"
@fall
def ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht():
# Kritische Pruefung NT, Befund 3: Im Zweig "Docker-Host nicht erreichbar" stand logge vor der Zeile in
# der Notiz; warf es, fehlte die Zeile, und uebrig blieb Exit 1. Jetzt stehen in der Notiz von heute
# die Zeile zum Host und die zu lauf.log, je genau einmal.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
modul = lade()
ergebnisse_liegen(modul, wurzel)
_, typ = lauf_log_sperren(modul, wurzel)
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-12T23:30", modul=modul, erster_tag=date(2026, 10, 12), host_weg="0"
)
assert len(sicherungszeilen(notiz)) == 1, "Zeile zum unerreichbaren Host fehlt"
assert einzige_zeile(notiz) == (
f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar "
f"(sicherung-summe 2026-10-12-alpaca: Exit 255; {HOST_WEG}), Sicherung abgebrochen. "
"Der nächste Lauf versucht es erneut."
), notiz
assert log_zeilen(notiz) == [log_zeile("23:30", typ)], notiz
assert namen(wurzel / "vault" / "Inbox") == ["2026-10-12.md"], namen(wurzel / "vault" / "Inbox")
assert befehle == ["sicherung-summe 2026-10-12-alpaca"], befehle
assert code == 1, code
@fall
def ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht():
# Kritische Pruefung NT, Befund 3: Scheiterte ein Teil, stand logge vor seiner Zeile in der Notiz. Hier
# liefert der Host fuer beide Teile kein Ergebnis (erster und letzter Teil), und --tag ist nicht heute:
# Die Zeilen der Teile stehen in der Notiz des Tags, die Zeile zu lauf.log nur in der von heute.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
modul = lade()
_, typ = lauf_log_sperren(modul, wurzel)
def kein_ergebnis(tag, teil="alpaca"):
raise RuntimeError(f"auf dem Docker-Host liegt fuer {tag} ({teil}) noch kein Ergebnis")
modul.fahre_dockerhost = kein_ergebnis
code, _, befehle, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-06T23:30", modul=modul, erster_tag=None
)
inbox = wurzel / "vault" / "Inbox"
assert namen(inbox) == [f"{TAG}.md", "2026-10-06.md"], f"Notizen {namen(inbox)}"
tagnotiz = (inbox / f"{TAG}.md").read_text(encoding="utf-8")
heute = (inbox / "2026-10-06.md").read_text(encoding="utf-8")
for kopf in ("Abendkontrolle Trading Plattform (automatisch)", "Abendkontrolle IBKR (automatisch)"):
assert f"- 2026-10-06 23:30 Uhr — {kopf}: **gescheitert** (RuntimeError: " in tagnotiz, tagnotiz
assert log_zeilen(tagnotiz) == [], tagnotiz
assert log_zeilen(heute) == [log_zeile("23:30", typ)], heute
assert befehle == [], befehle
assert code == 1, code
@fall
def eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab():
# Paket NR: Laesst sich neben lauf.log auch die Notiz von heute nicht schreiben (ihre Nachbardatei
# ist ein Ordner), endet main ohne Ausnahme mit Exit 1, und die Ergebnisdateien beider Teile liegen.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
modul = lade()
ablage, _ = lauf_log_sperren(modul, wurzel)
(wurzel / "vault" / "Inbox" / f"{TAG}.md.abendkontrolle.tmp").mkdir(parents=True)
code, _, befehle, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None
)
ergebnisse = sorted(p.name for p in ablage.glob("*.json"))
assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen"
assert namen(wurzel / "vault" / "Inbox") == [f"{TAG}.md.abendkontrolle.tmp"], namen(wurzel / "vault" / "Inbox")
assert befehle == [], befehle
assert code == 1, code
# --- Seit der Plankritik NR: kein OSError in logge, Ablage nicht anlegbar, Ergebnisdatei nicht schreibbar ---
def typ_beim_schreiben(pfad):
"""Der Typ der Ausnahme, wenn pfad sich nicht als Datei schreiben laesst; der Fall misst ihn selbst."""
try:
pfad.write_text("{}", encoding="utf-8")
except OSError as fehler:
return type(fehler).__name__
raise AssertionError("an dieser Stelle liess sich eine Datei schreiben")
def ergebnis_zusatz(typ):
"""Die Unterzeile unter der Zeile eines Tages, dessen Ergebnisdatei sich nicht schreiben liess.
Seit Task 746 (kritische Pruefung NR) sagt sie nur, was faellige_tage hergibt; 7 ist NACHHOLEN_TAGE.
"""
return (
f" - Ergebnisdatei nicht geschrieben ({typ}) #needs-review. Ein späterer Lauf ohne --tag prüft den Tag "
"nur erneut, wenn er ein Werktag ab dem ersten Prüftag des Teils ist, höchstens 7 Kalendertage "
"zurückliegt und unter dem Namen der Datei nichts liegt, auch keine halb geschriebene. Sonst prüft ihn "
"kein Lauf von selbst."
)
def zeile_mit_folge(notiz, anfang):
"""Die eine Zeile der Notizen, die mit anfang beginnt, und die Zeile danach ("" am Ende)."""
zeilen = notiz.splitlines()
treffer = [i for i, zeile in enumerate(zeilen) if zeile.startswith(anfang)]
assert len(treffer) == 1, (anfang, notiz)
i = treffer[0]
return zeilen[i], (zeilen[i + 1] if i + 1 < len(zeilen) else "")
@fall
def ein_fehler_in_logge_der_kein_oserror_ist_haelt_den_lauf_nicht_auf():
# Plankritik NR: logge faengt jede Ausnahme, nicht nur OSError (Entscheidungsliste Task 743, Nr. 1).
# Hier wirft die Uhr in logge einen RuntimeError; die Attrappe gilt nur im geladenen Modul, und --jetzt
# liest dieselbe Klasse heil. Ergebnis und Zeilen beider Teile entstehen, die Zeile zu lauf.log nennt
# den Typ, Exit 1.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
modul = lade()
class KaputteUhr(datetime):
@classmethod
def now(cls, tz=None):
raise RuntimeError("Uhr der Attrappe")
modul.datetime = KaputteUhr
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None
)
ablage = wurzel / "vault" / modul.ABLAGE_TEIL
ergebnisse = sorted(p.name for p in ablage.glob("*.json"))
assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen"
assert "Abendkontrolle Trading Plattform (automatisch): ohne Befund" in notiz, notiz
assert "Abendkontrolle IBKR (automatisch): ohne Befund" in notiz, notiz
assert log_zeilen(notiz) == [log_zeile("23:30", "RuntimeError")], notiz
assert befehle == [], befehle
assert code == 1, code
@fall
def eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf():
# Plankritik NR: logge legt die Ablage im try an (Entscheidungsliste Task 743, Nr. 1). Hier liegt an
# ihrer Stelle eine Datei; weder lauf.log noch die Ergebnisdateien lassen sich schreiben. Mit --tag
# liest der Lauf die Ablage vorher nicht (faellige_tage). Die Zeilen beider Teile stehen mit der
# Unterzeile zur Ergebnisdatei in der Notiz, dazu die Zeile zu lauf.log, Exit 1.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
modul = lade()
ablage = wurzel / "vault" / modul.ABLAGE_TEIL
ablage.parent.mkdir(parents=True)
ablage.write_bytes(b"keine Ablage\n")
try:
ablage.mkdir(parents=True, exist_ok=True)
except OSError as fehler:
typ_log = type(fehler).__name__
else:
raise AssertionError("die Ablage liess sich anlegen")
typ_ergebnis = typ_beim_schreiben(ablage / f"{TAG}.json")
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None
)
inbox = wurzel / "vault" / "Inbox"
assert namen(inbox) == [f"{TAG}.md"], f"Notizen {namen(inbox)}"
for teil in ("alpaca", "ibkr"):
zeile, folge = zeile_mit_folge(notiz, f"- 23:30 Uhr — {modul.KOEPFE[teil]}: ohne Befund.")
assert folge == ergebnis_zusatz(typ_ergebnis), (zeile, folge)
assert log_zeilen(notiz) == [log_zeile("23:30", typ_log)], notiz
assert ablage.read_bytes() == b"keine Ablage\n"
assert befehle == [], befehle
assert code == 1, code
@fall
def eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf():
# Plankritik NR: Die Ergebnisdatei stand in fahre_teil ohne try vor der Zeile in der Notiz. Liess sie
# sich nicht schreiben, flog die Ausnahme aus fahre_teil: Es fehlten die Zeile dieses Tages und die
# aller folgenden Tage des Teils, uebrig blieben "abgebrochen" in lauf.log und Exit 1. Hier liegt an
# ihrer Stelle ein Ordner, bei alpaca am ersten, bei ibkr am letzten faelligen Tag. Die Attrappe legt
# ihn erst nach der Frage an den Host an; vorher zaehlte faellige_tage den Tag als erledigt.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
modul = lade()
ablage = wurzel / "vault" / modul.ABLAGE_TEIL
ablage.mkdir(parents=True)
for tag in ("2026-09-25", "2026-09-28", "2026-09-29", "2026-09-30"):
(ablage / f"{tag}.json").write_text("{}", encoding="utf-8")
(ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8")
gesperrt = {
("2026-10-01", "alpaca"): ablage / "2026-10-01.json",
("2026-10-02", "ibkr"): ablage / "2026-10-02-ibkr.json",
}
echt = modul.fahre_dockerhost
def fahre_dockerhost(tag, teil="alpaca"):
ergebnis = echt(tag, teil)
if (tag, teil) in gesperrt:
gesperrt[(tag, teil)].mkdir()
return ergebnis
modul.fahre_dockerhost = fahre_dockerhost
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-02T23:30", modul=modul, erster_tag=None
)
inbox = wurzel / "vault" / "Inbox"
assert namen(inbox) == ["2026-10-01.md", "2026-10-02.md"], f"Notizen {namen(inbox)}"
zusatz = ergebnis_zusatz(typ_beim_schreiben(gesperrt[("2026-10-01", "alpaca")]))
for uhrzeit, teil, gesperrt_hier in (
("2026-10-02 23:30", "alpaca", True),
("2026-10-02 23:30", "ibkr", False),
("23:30", "alpaca", False),
("23:30", "ibkr", True),
):
_, folge = zeile_mit_folge(notiz, f"- {uhrzeit} Uhr — {modul.KOEPFE[teil]}: ohne Befund.")
assert (folge == zusatz) == gesperrt_hier, (uhrzeit, teil, folge)
assert notiz.count("Ergebnisdatei nicht geschrieben") == 2, notiz
assert (ablage / "2026-10-02.json").is_file() and (ablage / "2026-10-01-ibkr.json").is_file(), namen(ablage)
assert all(pfad.is_dir() for pfad in gesperrt.values()), namen(ablage)
assert log_zeilen(notiz) == [], notiz
assert befehle == [], befehle
assert code == 1, code
# Mutanten fuer --mutanten: Name, Text in abendkontrolle.py (genau einmal), Ersatz und die Faelle,
# die davon rot werden muessen. MUTANTEN_AUFGABE (unten) tut dasselbe fuer abendkontrolle_einrichten.ps1.
# Ein Mutant, der das Verhalten nicht aendert, gehoert nicht hierher
# (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',
["ein_unlesbares_archiv_auf_dem_host_scheitert_nur_fuer_seine_instanz"]),
("Nachbardatei bleibt", " teil.unlink(missing_ok=True)", " pass",
["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt", "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]),
("Bestaetigung ungeprueft",
' 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"]),
# Seit Paket NT (Quorum 4): Host nicht erreichbar, --tag frueh geprueft, Kommentar ohne (S3).
("E729-01 Host weg: Exit 255 wie jeder andere", " if lauf.returncode == 255:", " if False:",
["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz",
"eine_abgebrochene_uebertragung_hinterlaesst_nichts"]),
("E729-02 Host weg: Zeitlimit wie jeder Fehler", " except subprocess.TimeoutExpired:",
" except ZeroDivisionError:", ["ein_zeitlimit_beim_host_zaehlt_wie_ein_unerreichbarer_host"]),
("E729-03 Host weg reisst nur die Instanz ab", " except HostNichtErreichbar as ausnahme:",
" except ZeroDivisionError as ausnahme:",
["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz",
"ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen"]),
("E729-04 Host weg: Zeile in der Notiz des Tags",
" schreibe_notiz(vault, jetzt.date().isoformat(), zeile)",
" schreibe_notiz(vault, kandidat, zeile)",
["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]),
("E729-05 Host weg mit Exit 0",
' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 1',
' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 0',
["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]),
("E729-06 Host weg: weiter mit der naechsten Instanz",
' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 1',
' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n continue',
["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]),
("E729-07 Host weg: nichts vom angefangenen Tag", " if stuecke:\n zeile +=",
" if False:\n zeile +=",
["ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen"]),
("E729-08 Host weg ohne lauf.log",
' logge(ablage, f"sicherung {kandidat}-{instanz}: Docker-Host nicht erreichbar ({ausnahme}), '
'Sicherung abgebrochen")',
" pass", ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]),
("E729-09 Tag spaet geprueft", " if argumente.tag is not None and not tag_gueltig(argumente.tag):",
" if False:", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]),
("E729-10 leerer Tag gilt als keiner", " if argumente.tag is not None and not tag_gueltig(argumente.tag):",
" if argumente.tag and not tag_gueltig(argumente.tag):",
["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]),
("E729-11 falscher Tag mit Exit 1",
' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)\n'
" return 2",
' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)\n'
" return 1",
["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]),
("E729-12 falscher Tag ohne Meldung",
' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)',
" pass", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]),
("E729-13 Form des Tags ungeprueft", " if not TAGFORM.fullmatch(tag):\n return False",
" if False:\n return False", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]),
("E729-14 Datum des Tags ungeprueft", " date.fromisoformat(tag)\n except ValueError:",
" pass\n except ValueError:", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]),
("E729-15 Verweis S3 zurueck",
"# Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026",
"# (S3). Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026",
["kein_kommentar_beginnt_mit_s3"]),
# Seit der Plankritik NT, Befund 1: Ein Abbruch im Archiv laesst nur die Instanz scheitern.
("E729-25 Abbruch im Archiv beendet die Sicherung", " except HostNichtErreichbar as abbruch:",
" except ZeroDivisionError as abbruch:",
["ein_zeitlimit_beim_archiv_laesst_nur_diese_instanz_scheitern",
"eine_abgebrochene_uebertragung_hinterlaesst_nichts"]),
# Seit der kritischen Pruefung NT, Befund 1: Eine Bestaetigung ohne Host bricht die Sicherung ab.
("E739-01 Bestaetigung ohne Host nur fuer die Instanz",
' lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)',
' try:\n lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n'
' except HostNichtErreichbar as abbruch:\n raise RuntimeError(f"nicht bestaetigt ({abbruch})") from None',
["eine_bestaetigung_ohne_host_bricht_die_sicherung_ab"]),
# Seit Paket NR (kritische Pruefung NT, Befund 3): lauf.log nicht schreibbar.
("E743-01 lauf.log wirft wieder",
" except Exception as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__",
" except ZeroDivisionError as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__",
["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf",
"ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht",
"ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht",
"eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab"]),
("E743-02 keine Zeile zu lauf.log", " if LOG_FEHLER is not None:\n code = 1",
" if False:\n code = 1",
["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf",
"ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht",
"ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]),
("E743-03 Exit 0 trotz lauf.log", " if LOG_FEHLER is not None:\n code = 1",
" if LOG_FEHLER is not None:\n pass",
["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf"]),
("E743-04 Fehler des vorigen Laufs bleibt", " LOG_FEHLER = None", " pass",
["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf"]),
("E743-05 Zeile in der Notiz des Tags",
" schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)",
" schreibe_notiz(vault, argumente.tag or jetzt.date().isoformat(), log_zeile)",
["ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]),
("E743-06 Zeile ohne Typ", "**nicht schreibbar** ({LOG_FEHLER}) #needs-review", "**nicht schreibbar** #needs-review",
["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf",
"ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht",
"ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]),
("E743-07 gesperrte Notiz bricht main ab",
" schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)\n except Exception:\n pass",
" schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)\n except ZeroDivisionError:\n pass",
["eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab"]),
# Seit der Plankritik NR: logge faengt jede Ausnahme und legt die Ablage im try an; eine nicht
# schreibbare Ergebnisdatei haelt weder die Zeile ihres Tages noch die folgenden Tage auf.
("E743-08 lauf.log faengt nur OSError",
" except Exception as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__",
" except OSError as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__",
["ein_fehler_in_logge_der_kein_oserror_ist_haelt_den_lauf_nicht_auf"]),
("E743-09 Ablage vor dem try angelegt",
" try:\n ablage.mkdir(parents=True, exist_ok=True)\n",
" ablage.mkdir(parents=True, exist_ok=True)\n try:\n",
["eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]),
("E743-10 Ergebnisdatei reisst den Teil ab",
' except Exception as ausnahme:\n code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei',
' except ZeroDivisionError as ausnahme:\n code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei',
["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf",
"eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]),
("E743-11 Exit 0 trotz fehlender Ergebnisdatei",
' code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei',
' pass\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei',
["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf"]),
("E743-12 Zeile ohne Unterzeile zur Ergebnisdatei",
" schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit) + zusatz)",
" schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit))",
["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf",
"eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]),
("E743-13 Unterzeile ohne Typ", "Ergebnisdatei nicht geschrieben ({type(ausnahme).__name__}) #needs-review",
"Ergebnisdatei nicht geschrieben #needs-review",
["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf",
"eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]),
("Name der Summendatei beim Host egal", ' if treffer is None or treffer.group(2) != f"{name}.tar":',
" if treffer is None:", ["eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv"]),
("Grossbuchstaben in der Summe", r'SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n")',
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())