Trading-Plattform-Pruefung: Host nicht erreichbar gibt eine Zeile, --tag frueh geprueft (Paket NT, Task 729)

- abendkontrolle.py: Exit 255 oder Zeitlimit bei Summe oder Bestaetigung beendet die Sicherung mit
  genau einer Zeile in der Notiz von heute und Exit 1; ein Abbruch im Archiv laesst nur die Instanz
  scheitern. --tag ausser der Form endet gleich mit Exit 2, ohne Notiz und ssh.
- abendkontrolle_dockerhost_angriffe.py: sieben neue Faelle, zwei angepasste, Mutanten E729-01 bis
  E729-15 und E729-25 (38 Faelle, 56 Mutanten, keiner ueberlebt).
- abendkontrolle_wsl.py: Kopf nennt die neuen fehler-Gruende.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
This commit is contained in:
Pascal MichelandClaude Opus 5.5 committed 2026-10-11 23:25:38 +02:00
1 parent 02ff0e878d
commit 88ec4fd40f
3 files changed
+355 -28

No files matched your search

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